diff --git a/pages/03-core/core-line-chart.page.tsx b/pages/03-core/core-line-chart.page.tsx index a7d8bb6d..7d0f70fb 100644 --- a/pages/03-core/core-line-chart.page.tsx +++ b/pages/03-core/core-line-chart.page.tsx @@ -122,18 +122,18 @@ export default function () { chartHeight={400} tooltip={{ placement: "outside" }} getTooltipContent={() => ({ - point({ item }) { + point({ item, hideTooltip }) { const value = item ? (item.point.y ?? null) : null; return { value: (
- {numberFormatter(value)}
), }; }, - footer() { - return ; + footer({ hideTooltip }) { + return ; }, })} getLegendTooltipContent={({ legendItem }) => ({ diff --git a/src/core/__tests__/chart-core-tooltip.test.tsx b/src/core/__tests__/chart-core-tooltip.test.tsx index 12bb13b3..2fe28bc2 100644 --- a/src/core/__tests__/chart-core-tooltip.test.tsx +++ b/src/core/__tests__/chart-core-tooltip.test.tsx @@ -6,8 +6,8 @@ import { waitFor } from "@testing-library/react"; import highcharts from "highcharts"; import { vi } from "vitest"; -import { CoreChartProps } from "../../../lib/components/core/interfaces"; import testClasses from "../../../lib/components/core/test-classes/styles.selectors"; +import { CoreChartProps } from "../../../lib/components/internal-do-not-use/core-chart"; import { createChartWrapper, renderChart } from "./common"; import { HighchartsTestHelper } from "./highcharts-utils"; @@ -460,4 +460,132 @@ describe("CoreChart: tooltip", () => { expect(wrapper.findTooltip()!.findBody()!.getElement().textContent).toBe("[P3] [60] [custom key] [custom value]"); }); + + describe("dismissTooltip", () => { + test.each<{ + name: string; + series: highcharts.SeriesOptionsType[]; + getTooltipContent: () => CoreChartProps.GetTooltipContent; + }>( + [ + [lineSeries, "line"], + [pieSeries, "pie"], + ].flatMap(([series, type]) => { + return [ + { + name: `header renderer - ${type} chart`, + series, + getTooltipContent: () => ({ + body: () => "Body", + footer: () => "Footer", + header: ({ hideTooltip }) => { + return ( + + ); + }, + }), + }, + { + name: `body renderer - ${type} chart`, + series, + getTooltipContent: () => ({ + header: () => "Header", + footer: () => "Footer", + body: ({ hideTooltip }) => { + return ( + + ); + }, + }), + }, + { + name: `footer renderer - ${type} chart`, + series, + getTooltipContent: () => ({ + header: () => "Header", + body: () => "Body", + footer: ({ hideTooltip }) => { + return ( + + ); + }, + }), + }, + ]; + }), + )("provides dismissTooltip callback to $name", async ({ series, getTooltipContent }) => { + const { wrapper } = renderChart({ + highcharts, + options: { series }, + getTooltipContent: getTooltipContent, + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(wrapper.findTooltip()).not.toBe(null); + }); + + act(() => { + hoverTooltip(); + }); + + await waitFor(() => { + expect(wrapper.findTooltip()).not.toBe(null); + }); + + act(() => { + wrapper.findTooltip()!.find(`[data-testid="hideTooltip"]`).click(); + }); + + await waitFor(() => { + expect(wrapper.findTooltip()).toBe(null); + }); + }); + + test("dismissTooltip callback works when tooltip is pinned", async () => { + let dismissCallback: (() => void) | undefined; + const { wrapper } = renderChart({ + highcharts, + options: { series: pieSeries }, + getTooltipContent: () => ({ + header: ({ hideTooltip }) => { + dismissCallback = hideTooltip; + return "Header"; + }, + body: () => "Body", + }), + }); + + act(() => hc.highlightChartPoint(0, 0)); + + await waitFor(() => { + expect(wrapper.findTooltip()).not.toBe(null); + expect(wrapper.findTooltip()!.findDismissButton()).toBe(null); + expect(dismissCallback).toBeDefined(); + }); + + // Pin tooltip + act(() => hc.clickChartPoint(0, 0)); + + await waitFor(() => { + expect(wrapper.findTooltip()).not.toBe(null); + expect(wrapper.findTooltip()!.findDismissButton()).not.toBe(null); + }); + + act(() => { + dismissCallback!(); + }); + + await waitFor(() => { + expect(wrapper.findTooltip()).toBe(null); + }); + }); + }); }); diff --git a/src/core/chart-api/index.tsx b/src/core/chart-api/index.tsx index f0056750..95a0cbde 100644 --- a/src/core/chart-api/index.tsx +++ b/src/core/chart-api/index.tsx @@ -184,6 +184,14 @@ export class ChartAPI { } }; + // Hide the tooltip from an action initiated by the tooltip's content + public hideTooltip = () => { + this.chartExtraTooltip.hideTooltip(); + // The chart highlight is preserved while the tooltip is pinned. We need to clear it manually here, for the case + // when the pointer lands outside the chart after the tooltip is dismissed, so that the mouse-out event won't fire. + this.clearChartHighlight({ isApiCall: false }); + }; + // Reference to the role="application" element used for navigation. public setApplication = this.chartExtraNavigation.setApplication.bind(this.chartExtraNavigation); diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index 25ce55eb..f50cb5fa 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -70,6 +70,9 @@ export function ChartTooltip({ group: tooltip.group, expandedSeries, setExpandedSeries, + hideTooltip: () => { + api.hideTooltip(); + }, }); if (!content) { return null; @@ -117,6 +120,7 @@ function getTooltipContent( api: ChartAPI, props: CoreChartProps.GetTooltipContentProps & { renderers?: CoreChartProps.TooltipContentRenderer; + hideTooltip: () => void; } & ExpandedSeriesStateProps, ): null | RenderedTooltipContent { if (props.point && props.point.series.type === "pie") { @@ -136,8 +140,10 @@ function getTooltipContentCartesian( expandedSeries, renderers = {}, setExpandedSeries, + hideTooltip, }: CoreChartProps.GetTooltipContentProps & { renderers?: CoreChartProps.TooltipContentRenderer; + hideTooltip: () => void; } & ExpandedSeriesStateProps, ): RenderedTooltipContent { // 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( const detailItems: ChartSeriesDetailItem[] = matchedItems.map((item) => { const valueFormatter = getFormatter(item.point.series.yAxis); const itemY = isXThreshold(item.point.series) ? null : (item.point.y ?? null); - const customContent = renderers.point ? renderers.point({ item }) : undefined; + const customContent = renderers.point + ? renderers.point({ + item, + hideTooltip, + }) + : undefined; return { key: customContent?.key ?? item.point.series.name, value: customContent?.value ?? valueFormatter(itemY), @@ -177,7 +188,11 @@ function getTooltipContentCartesian( }); // We only support cartesian charts with a single x axis. const titleFormatter = getFormatter(chart.xAxis[0]); - const slotRenderProps: CoreChartProps.TooltipSlotProps = { x, items: matchedItems }; + const slotRenderProps: CoreChartProps.TooltipSlotProps = { + x, + items: matchedItems, + hideTooltip: hideTooltip, + }; return { header: renderers.header?.(slotRenderProps) ?? titleFormatter(x), body: renderers.body?.(slotRenderProps) ?? ( @@ -203,9 +218,17 @@ function getTooltipContentCartesian( function getTooltipContentPie( api: ChartAPI, - { point, renderers = {} }: { point: Highcharts.Point } & { renderers?: CoreChartProps.TooltipContentRenderer }, + { + point, + renderers = {}, + hideTooltip, + }: { point: Highcharts.Point } & { renderers?: CoreChartProps.TooltipContentRenderer; hideTooltip: () => void }, ): RenderedTooltipContent { - const tooltipDetails: CoreChartProps.TooltipSlotProps = { x: point.x, items: [{ point, errorRanges: [] }] }; + const tooltipDetails: CoreChartProps.TooltipSlotProps = { + x: point.x, + items: [{ point, errorRanges: [] }], + hideTooltip, + }; return { header: renderers.header?.(tooltipDetails) ?? (
@@ -218,7 +241,13 @@ function getTooltipContentPie( body: renderers.body?.(tooltipDetails) ?? (renderers.details ? ( - + ) : ( // We expect all pie chart segments to have defined y values. We use y=0 as fallback // because the property is optional in Highcharts types. diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index e299768e..0d7c028f 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -451,14 +451,17 @@ export namespace CoreChartProps { } export interface TooltipPointProps { item: TooltipContentItem; + hideTooltip: () => void; } export interface TooltipSlotProps { x: number; items: TooltipContentItem[]; + hideTooltip: () => void; } export interface TooltipDetailsProps { point: Highcharts.Point; + hideTooltip: () => void; } export type TooltipDetail = BaseTooltipDetail; diff --git a/src/pie-chart/chart-pie-internal.tsx b/src/pie-chart/chart-pie-internal.tsx index 68c401f2..8392f71c 100644 --- a/src/pie-chart/chart-pie-internal.tsx +++ b/src/pie-chart/chart-pie-internal.tsx @@ -73,7 +73,7 @@ export const InternalPieChart = forwardRef( }; const transformSlotProps = (props: CoreChartProps.TooltipSlotProps): PieChartProps.TooltipDetailsRenderProps => { const point = props.items[0].point; - return transformDetailsProps({ point }); + return transformDetailsProps({ point, hideTooltip: props.hideTooltip }); }; return { header: tooltip?.header ? (props) => tooltip.header!(transformSlotProps(props)) : undefined,