diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 9a6caa61..580b86a9 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -1744,6 +1744,21 @@ for each visited { x, y } point.", "inlineType": { "name": "CoreChartProps.TooltipOptions", "properties": [ + { + "inlineType": { + "name": "number | boolean", + "type": "union", + "valueDescriptions": undefined, + "values": [ + "number", + "false", + "true", + ], + }, + "name": "debounce", + "optional": true, + "type": "number | boolean", + }, { "name": "enabled", "optional": true, diff --git a/src/core/__tests__/chart-core-tooltip.test.tsx b/src/core/__tests__/chart-core-tooltip.test.tsx index 2fe28bc2..a1eb7eb6 100644 --- a/src/core/__tests__/chart-core-tooltip.test.tsx +++ b/src/core/__tests__/chart-core-tooltip.test.tsx @@ -588,4 +588,76 @@ describe("CoreChart: tooltip", () => { }); }); }); + + describe("debounce functionality", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("debounces tooltip rendering with debounce", () => { + const { wrapper } = renderChart({ + highcharts, + options: { series: pieSeries }, + tooltip: { debounce: 100 }, + getTooltipContent: () => ({ header: () => "Header", body: () => "Body" }), + }); + + act(() => hc.highlightChartPoint(0, 0)); + + // Tooltip should not be visible immediately + expect(wrapper.findTooltip()).toBe(null); + + // Fast forward time by 50ms - still not visible + act(() => vi.advanceTimersByTime(50)); + expect(wrapper.findTooltip()).toBe(null); + + // Fast forward time by another 50ms (100ms total) - now visible + act(() => vi.advanceTimersByTime(50)); + expect(wrapper.findTooltip()).not.toBe(null); + }); + + test("cancels previous debounced call when new highlight occurs", () => { + const { wrapper } = renderChart({ + highcharts, + options: { series: pieSeries }, + tooltip: { debounce: 100 }, + getTooltipContent: ({ point }) => ({ header: () => `Point ${point?.name}`, body: () => "Body" }), + }); + + // Highlight first point + act(() => hc.highlightChartPoint(0, 0)); + + // Fast forward 50ms + act(() => vi.advanceTimersByTime(50)); + expect(wrapper.findTooltip()).toBe(null); + + // Highlight second point before first debounce completes + act(() => hc.highlightChartPoint(0, 1)); + + // Fast forward another 50ms (100ms from first highlight, 50ms from second) + act(() => vi.advanceTimersByTime(50)); + expect(wrapper.findTooltip()).toBe(null); + + // Fast forward another 50ms (100ms from second highlight) + act(() => vi.advanceTimersByTime(50)); + expect(wrapper.findTooltip()).not.toBe(null); + expect(wrapper.findTooltip()!.findHeader()!.getElement().textContent).toBe("Point P2"); + }); + + test("renders immediately when debounce is 0", () => { + const { wrapper } = renderChart({ + highcharts, + options: { series: pieSeries }, + tooltip: { debounce: 0 }, + getTooltipContent: () => ({ header: () => "Header", body: () => "Body" }), + }); + + act(() => hc.highlightChartPoint(0, 0)); + expect(wrapper.findTooltip()).not.toBe(null); + }); + }); }); diff --git a/src/core/components/core-tooltip.tsx b/src/core/components/core-tooltip.tsx index f50cb5fa..55d131d9 100644 --- a/src/core/components/core-tooltip.tsx +++ b/src/core/components/core-tooltip.tsx @@ -11,6 +11,7 @@ import LiveRegion from "@cloudscape-design/components/live-region"; import ChartSeriesDetails, { ChartSeriesDetailItem } from "../../internal/components/series-details"; import { useSelector } from "../../internal/utils/async-store"; import { getChartSeries } from "../../internal/utils/chart-series"; +import { useDebouncedValue } from "../../internal/utils/use-debounced-value"; import { ChartAPI } from "../chart-api"; import { getFormatter } from "../formatters"; import { BaseI18nStrings, CoreChartProps } from "../interfaces"; @@ -19,6 +20,7 @@ import { getPointColor, getSeriesColor, getSeriesId, getSeriesMarkerType, isXThr import styles from "../styles.css.js"; const MIN_VISIBLE_BLOCK_SIZE = 200; +const DEFAULT_DEBOUNCE = 200; type ExpandedSeriesState = Record>; @@ -44,6 +46,7 @@ export function ChartTooltip({ getTooltipContent: getTooltipContentOverrides, api, i18nStrings, + debounce = false, }: CoreChartProps.TooltipOptions & { i18nStrings?: BaseI18nStrings; getTooltipContent?: CoreChartProps.GetTooltipContent; @@ -51,11 +54,14 @@ export function ChartTooltip({ }) { const [expandedSeries, setExpandedSeries] = useState({}); const tooltip = useSelector(api.tooltipStore, (s) => s); - if (!tooltip.visible || tooltip.group.length === 0) { + const debouncedTooltip = useDebouncedValue(tooltip, debounce === true ? DEFAULT_DEBOUNCE : debounce || 0); + + if (!debouncedTooltip || !debouncedTooltip.visible || debouncedTooltip.group.length === 0) { return null; } - const chart = tooltip.group[0]?.series.chart; - const renderers = getTooltipContentOverrides?.({ point: tooltip.point, group: tooltip.group }); + + const chart = debouncedTooltip.group[0]?.series.chart; + const renderers = getTooltipContentOverrides?.({ point: debouncedTooltip.point, group: debouncedTooltip.group }); const getTrack = placement === "target" ? api.getTargetTrack : api.getGroupTrack; const position = (() => { if (placement === "target" || placement === "middle") { @@ -66,8 +72,8 @@ export function ChartTooltip({ })(); const content = getTooltipContent(api, { renderers, - point: tooltip.point, - group: tooltip.group, + point: debouncedTooltip.point, + group: debouncedTooltip.group, expandedSeries, setExpandedSeries, hideTooltip: () => { @@ -80,9 +86,9 @@ export function ChartTooltip({ return ( TooltipContentRenderer; diff --git a/src/internal/utils/use-debounced-value.tsx b/src/internal/utils/use-debounced-value.tsx new file mode 100644 index 00000000..a3722093 --- /dev/null +++ b/src/internal/utils/use-debounced-value.tsx @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef, useState } from "react"; + +import { DebouncedCall } from "./utils"; + +export function useDebouncedValue(value: T, duration: number) { + const [debouncedValue, setDebouncedValue] = useState(null); + const [shouldRender, setShouldRender] = useState(false); + const debouncedCall = useRef(new DebouncedCall()); + + useEffect(() => { + if (duration <= 0) { + return; + } + + setShouldRender(false); + const current = debouncedCall.current; + current.call(() => { + setDebouncedValue(value); + setShouldRender(true); + }, duration); + + return () => current.cancelPrevious(); + }, [value, duration]); + + if (duration <= 0) { + return value; + } + + return shouldRender ? debouncedValue : null; +}