Skip to content

Commit 0e235d0

Browse files
authored
feat: Add dismissTooltip callback to custom content renderers (#121)
* feat(tooltip): add dismissTooltip callback to custom content renderers Add dismissTooltip callback function to tooltip content renderers (point, header, footer, body) allowing custom content to programmatically dismiss tooltips. This enables interactive elements like buttons within tooltips to close the tooltip when clicked. * refactor: rename dismissTooltip to hideTooltip in chart tooltip API - Rename dismissTooltip to hideTooltip in header, body, footer, and point tooltip renderers - Refactor tooltip tests to use parameterized tests (test.each) for both line and pie chart types, improving test maintainability * chore: remove usage of getByTestId * test: remove unneeded leaveChartPoint --------- Co-authored-by: Mael Kerichard <[email protected]>
1 parent 15410ba commit 0e235d0

File tree

6 files changed

+179
-11
lines changed

6 files changed

+179
-11
lines changed

pages/03-core/core-line-chart.page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,18 +129,18 @@ export default function () {
129129
chartHeight={400}
130130
tooltip={{ placement: "outside" }}
131131
getTooltipContent={() => ({
132-
point({ item }) {
132+
point({ item, hideTooltip }) {
133133
const value = item ? (item.point.y ?? null) : null;
134134
return {
135135
value: (
136136
<div>
137-
{numberFormatter(value)} <Button variant="inline-icon" iconName="settings" />
137+
{numberFormatter(value)} <Button variant="inline-icon" iconName="settings" onClick={hideTooltip} />
138138
</div>
139139
),
140140
};
141141
},
142-
footer() {
143-
return <Button>Footer action</Button>;
142+
footer({ hideTooltip }) {
143+
return <Button onClick={hideTooltip}>Footer action</Button>;
144144
},
145145
})}
146146
getLegendTooltipContent={({ legendItem }) => ({

src/core/__tests__/chart-core-tooltip.test.tsx

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { waitFor } from "@testing-library/react";
66
import highcharts from "highcharts";
77
import { vi } from "vitest";
88

9-
import { CoreChartProps } from "../../../lib/components/core/interfaces";
109
import testClasses from "../../../lib/components/core/test-classes/styles.selectors";
10+
import { CoreChartProps } from "../../../lib/components/internal-do-not-use/core-chart";
1111
import { createChartWrapper, renderChart } from "./common";
1212
import { HighchartsTestHelper } from "./highcharts-utils";
1313

@@ -460,4 +460,132 @@ describe("CoreChart: tooltip", () => {
460460

461461
expect(wrapper.findTooltip()!.findBody()!.getElement().textContent).toBe("[P3] [60] [custom key] [custom value]");
462462
});
463+
464+
describe("dismissTooltip", () => {
465+
test.each<{
466+
name: string;
467+
series: highcharts.SeriesOptionsType[];
468+
getTooltipContent: () => CoreChartProps.GetTooltipContent;
469+
}>(
470+
[
471+
[lineSeries, "line"],
472+
[pieSeries, "pie"],
473+
].flatMap(([series, type]) => {
474+
return [
475+
{
476+
name: `header renderer - ${type} chart`,
477+
series,
478+
getTooltipContent: () => ({
479+
body: () => "Body",
480+
footer: () => "Footer",
481+
header: ({ hideTooltip }) => {
482+
return (
483+
<button data-testid="hideTooltip" onClick={hideTooltip}>
484+
hideTooltip
485+
</button>
486+
);
487+
},
488+
}),
489+
},
490+
{
491+
name: `body renderer - ${type} chart`,
492+
series,
493+
getTooltipContent: () => ({
494+
header: () => "Header",
495+
footer: () => "Footer",
496+
body: ({ hideTooltip }) => {
497+
return (
498+
<button data-testid="hideTooltip" onClick={hideTooltip}>
499+
hideTooltip
500+
</button>
501+
);
502+
},
503+
}),
504+
},
505+
{
506+
name: `footer renderer - ${type} chart`,
507+
series,
508+
getTooltipContent: () => ({
509+
header: () => "Header",
510+
body: () => "Body",
511+
footer: ({ hideTooltip }) => {
512+
return (
513+
<button data-testid="hideTooltip" onClick={hideTooltip}>
514+
hideTooltip
515+
</button>
516+
);
517+
},
518+
}),
519+
},
520+
];
521+
}),
522+
)("provides dismissTooltip callback to $name", async ({ series, getTooltipContent }) => {
523+
const { wrapper } = renderChart({
524+
highcharts,
525+
options: { series },
526+
getTooltipContent: getTooltipContent,
527+
});
528+
529+
act(() => hc.highlightChartPoint(0, 0));
530+
531+
await waitFor(() => {
532+
expect(wrapper.findTooltip()).not.toBe(null);
533+
});
534+
535+
act(() => {
536+
hoverTooltip();
537+
});
538+
539+
await waitFor(() => {
540+
expect(wrapper.findTooltip()).not.toBe(null);
541+
});
542+
543+
act(() => {
544+
wrapper.findTooltip()!.find(`[data-testid="hideTooltip"]`).click();
545+
});
546+
547+
await waitFor(() => {
548+
expect(wrapper.findTooltip()).toBe(null);
549+
});
550+
});
551+
552+
test("dismissTooltip callback works when tooltip is pinned", async () => {
553+
let dismissCallback: (() => void) | undefined;
554+
const { wrapper } = renderChart({
555+
highcharts,
556+
options: { series: pieSeries },
557+
getTooltipContent: () => ({
558+
header: ({ hideTooltip }) => {
559+
dismissCallback = hideTooltip;
560+
return "Header";
561+
},
562+
body: () => "Body",
563+
}),
564+
});
565+
566+
act(() => hc.highlightChartPoint(0, 0));
567+
568+
await waitFor(() => {
569+
expect(wrapper.findTooltip()).not.toBe(null);
570+
expect(wrapper.findTooltip()!.findDismissButton()).toBe(null);
571+
expect(dismissCallback).toBeDefined();
572+
});
573+
574+
// Pin tooltip
575+
act(() => hc.clickChartPoint(0, 0));
576+
577+
await waitFor(() => {
578+
expect(wrapper.findTooltip()).not.toBe(null);
579+
expect(wrapper.findTooltip()!.findDismissButton()).not.toBe(null);
580+
});
581+
582+
act(() => {
583+
dismissCallback!();
584+
});
585+
586+
await waitFor(() => {
587+
expect(wrapper.findTooltip()).toBe(null);
588+
});
589+
});
590+
});
463591
});

src/core/chart-api/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,14 @@ export class ChartAPI {
184184
}
185185
};
186186

187+
// Hide the tooltip from an action initiated by the tooltip's content
188+
public hideTooltip = () => {
189+
this.chartExtraTooltip.hideTooltip();
190+
// The chart highlight is preserved while the tooltip is pinned. We need to clear it manually here, for the case
191+
// when the pointer lands outside the chart after the tooltip is dismissed, so that the mouse-out event won't fire.
192+
this.clearChartHighlight({ isApiCall: false });
193+
};
194+
187195
// Reference to the role="application" element used for navigation.
188196
public setApplication = this.chartExtraNavigation.setApplication.bind(this.chartExtraNavigation);
189197

src/core/components/core-tooltip.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export function ChartTooltip({
7070
group: tooltip.group,
7171
expandedSeries,
7272
setExpandedSeries,
73+
hideTooltip: () => {
74+
api.hideTooltip();
75+
},
7376
});
7477
if (!content) {
7578
return null;
@@ -117,6 +120,7 @@ function getTooltipContent(
117120
api: ChartAPI,
118121
props: CoreChartProps.GetTooltipContentProps & {
119122
renderers?: CoreChartProps.TooltipContentRenderer;
123+
hideTooltip: () => void;
120124
} & ExpandedSeriesStateProps,
121125
): null | RenderedTooltipContent {
122126
if (props.point && props.point.series.type === "pie") {
@@ -136,8 +140,10 @@ function getTooltipContentCartesian(
136140
expandedSeries,
137141
renderers = {},
138142
setExpandedSeries,
143+
hideTooltip,
139144
}: CoreChartProps.GetTooltipContentProps & {
140145
renderers?: CoreChartProps.TooltipContentRenderer;
146+
hideTooltip: () => void;
141147
} & ExpandedSeriesStateProps,
142148
): RenderedTooltipContent {
143149
// The cartesian tooltip might or might not have a selected point, but it always has a non-empty group.
@@ -150,7 +156,12 @@ function getTooltipContentCartesian(
150156
const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => {
151157
const valueFormatter = getFormatter(item.point.series.yAxis);
152158
const itemY = isXThreshold(item.point.series) ? null : (item.point.y ?? null);
153-
const customContent = renderers.point ? renderers.point({ item }) : undefined;
159+
const customContent = renderers.point
160+
? renderers.point({
161+
item,
162+
hideTooltip,
163+
})
164+
: undefined;
154165
return {
155166
key: customContent?.key ?? item.point.series.name,
156167
value: customContent?.value ?? valueFormatter(itemY),
@@ -177,7 +188,11 @@ function getTooltipContentCartesian(
177188
});
178189
// We only support cartesian charts with a single x axis.
179190
const titleFormatter = getFormatter(chart.xAxis[0]);
180-
const slotRenderProps: CoreChartProps.TooltipSlotProps = { x, items: matchedItems };
191+
const slotRenderProps: CoreChartProps.TooltipSlotProps = {
192+
x,
193+
items: matchedItems,
194+
hideTooltip: hideTooltip,
195+
};
181196
return {
182197
header: renderers.header?.(slotRenderProps) ?? titleFormatter(x),
183198
body: renderers.body?.(slotRenderProps) ?? (
@@ -203,9 +218,17 @@ function getTooltipContentCartesian(
203218

204219
function getTooltipContentPie(
205220
api: ChartAPI,
206-
{ point, renderers = {} }: { point: Highcharts.Point } & { renderers?: CoreChartProps.TooltipContentRenderer },
221+
{
222+
point,
223+
renderers = {},
224+
hideTooltip,
225+
}: { point: Highcharts.Point } & { renderers?: CoreChartProps.TooltipContentRenderer; hideTooltip: () => void },
207226
): RenderedTooltipContent {
208-
const tooltipDetails: CoreChartProps.TooltipSlotProps = { x: point.x, items: [{ point, errorRanges: [] }] };
227+
const tooltipDetails: CoreChartProps.TooltipSlotProps = {
228+
x: point.x,
229+
items: [{ point, errorRanges: [] }],
230+
hideTooltip,
231+
};
209232
return {
210233
header: renderers.header?.(tooltipDetails) ?? (
211234
<div className={styles["tooltip-default-header"]}>
@@ -218,7 +241,13 @@ function getTooltipContentPie(
218241
body:
219242
renderers.body?.(tooltipDetails) ??
220243
(renderers.details ? (
221-
<ChartSeriesDetails details={renderers.details({ point })} compactList={true} />
244+
<ChartSeriesDetails
245+
details={renderers.details({
246+
point,
247+
hideTooltip,
248+
})}
249+
compactList={true}
250+
/>
222251
) : (
223252
// We expect all pie chart segments to have defined y values. We use y=0 as fallback
224253
// because the property is optional in Highcharts types.

src/core/interfaces.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,14 +458,17 @@ export namespace CoreChartProps {
458458
}
459459
export interface TooltipPointProps {
460460
item: TooltipContentItem;
461+
hideTooltip: () => void;
461462
}
462463
export interface TooltipSlotProps {
463464
x: number;
464465
items: TooltipContentItem[];
466+
hideTooltip: () => void;
465467
}
466468

467469
export interface TooltipDetailsProps {
468470
point: Highcharts.Point;
471+
hideTooltip: () => void;
469472
}
470473

471474
export type TooltipDetail = BaseTooltipDetail;

src/pie-chart/chart-pie-internal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export const InternalPieChart = forwardRef(
7373
};
7474
const transformSlotProps = (props: CoreChartProps.TooltipSlotProps): PieChartProps.TooltipDetailsRenderProps => {
7575
const point = props.items[0].point;
76-
return transformDetailsProps({ point });
76+
return transformDetailsProps({ point, hideTooltip: props.hideTooltip });
7777
};
7878
return {
7979
header: tooltip?.header ? (props) => tooltip.header!(transformSlotProps(props)) : undefined,

0 commit comments

Comments
 (0)