From e11dc9378a307a04bf0f05c6e09b38fd1ba48ae0 Mon Sep 17 00:00:00 2001 From: Hrvoje Hemen Date: Mon, 8 Dec 2025 10:40:13 +0000 Subject: [PATCH] feat: emit event on legend highlight exit --- pages/03-core/legend-events.page.tsx | 145 ++++++++++++++++++ .../__snapshots__/documenter.test.ts.snap | 9 ++ .../chart-core-legend-events.test.tsx | 121 +++++++++++++++ src/core/chart-core.tsx | 2 + src/core/components/core-legend.tsx | 7 +- src/core/interfaces.ts | 4 + 6 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 pages/03-core/legend-events.page.tsx create mode 100644 src/core/__tests__/chart-core-legend-events.test.tsx diff --git a/pages/03-core/legend-events.page.tsx b/pages/03-core/legend-events.page.tsx new file mode 100644 index 00000000..2713b2d6 --- /dev/null +++ b/pages/03-core/legend-events.page.tsx @@ -0,0 +1,145 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useMemo, useState } from "react"; + +import Alert from "@cloudscape-design/components/alert"; +import Badge from "@cloudscape-design/components/badge"; +import Box from "@cloudscape-design/components/box"; +import SpaceBetween from "@cloudscape-design/components/space-between"; + +import { CoreLegend } from "../../lib/components/internal-do-not-use/core-legend"; +import { Page } from "../common/templates"; + +// Base legend items data +const baseLegendItems = [ + { + id: "series-a", + name: "Series A", + marker:
, + visible: true, + }, + { + id: "series-b", + name: "Series B", + marker:
, + visible: true, + }, + { + id: "series-c", + name: "Series C", + marker:
, + visible: true, + }, +]; + +export default function LegendEventsDemo() { + // State for tracking which item is highlighted in each legend + const [highlightedItemWithExit, setHighlightedItemWithExit] = useState(null); + const [highlightedItemWithoutExit, setHighlightedItemWithoutExit] = useState(null); + + // Create legend items with dynamic highlighted state for the legend WITH exit handler + const legendItemsWithExit = useMemo( + () => + baseLegendItems.map((item) => ({ + ...item, + highlighted: item.name === highlightedItemWithExit, + })), + [highlightedItemWithExit], + ); + + // Create legend items with dynamic highlighted state for the legend WITHOUT exit handler + const legendItemsWithoutExit = useMemo( + () => + baseLegendItems.map((item) => ({ + ...item, + highlighted: item.name === highlightedItemWithoutExit, + })), + [highlightedItemWithoutExit], + ); + + // Handler for legend WITH exit handler - controls both legends + const handleLegendItemHighlightWithExit = (event: { detail: { item: { name: string } } }) => { + const itemName = event.detail.item.name; + setHighlightedItemWithExit(itemName); + // Cross-control: also highlight the corresponding item in the other legend + setHighlightedItemWithoutExit(itemName); + }; + + // Handler for exit event - only clears the legend WITH exit handler + const handleLegendItemHighlightExitWithExit = () => { + setHighlightedItemWithExit(null); + // Cross-control: also clear the other legend (this shows proper bidirectional control) + setHighlightedItemWithoutExit(null); + }; + + // Handler for legend WITHOUT exit handler - controls both legends + const handleLegendItemHighlightWithoutExit = (event: { detail: { item: { name: string } } }) => { + const itemName = event.detail.item.name; + setHighlightedItemWithoutExit(itemName); + // Cross-control: also highlight the corresponding item in the other legend + setHighlightedItemWithExit(itemName); + }; + + // Note: No exit handler for the second legend - this demonstrates the issue + + return ( + + + {/* Legend WITH exit handler */} + + + + Legend WITH onClearHighlight (Controls Both) + + {highlightedItemWithExit ? `Controlling: ${highlightedItemWithExit}` : "Not Controlling"} + + + + + + + + {/* Legend WITHOUT exit handler */} + + + + Legend WITHOUT onClearHighlight (Controls Both) + + {highlightedItemWithoutExit ? `Stuck Controlling: ${highlightedItemWithoutExit}` : "Not Controlling"} + + + + + + + + + Expected Behavior: +
Top Legend (With Exit Handler): When you hover and then move away, both legends clear + their highlights properly - demonstrating proper bidirectional control. +
Bottom Legend (Without Exit Handler): When you hover and then move away, both legends + remain stuck in the highlighted state - demonstrating the broken one-way control that the exit handler fixes. +
+
+ This shows how the exit handler is essential for proper cross-component communication and state management. +
+
+
+ ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index d7622679..318115b3 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -1236,6 +1236,15 @@ exports[`internal core API matches snapshot > internal-core-chart 1`] = ` "name": "onLegendItemHighlight", "systemTags": undefined, }, + { + "cancelable": false, + "deprecatedTag": undefined, + "description": "Called when a legend item highlight is cleared.", + "detailInlineType": undefined, + "detailType": undefined, + "name": "onLegendItemHighlightExit", + "systemTags": undefined, + }, { "cancelable": false, "deprecatedTag": undefined, diff --git a/src/core/__tests__/chart-core-legend-events.test.tsx b/src/core/__tests__/chart-core-legend-events.test.tsx new file mode 100644 index 00000000..2c0aa9b5 --- /dev/null +++ b/src/core/__tests__/chart-core-legend-events.test.tsx @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act } from "react"; +import highcharts from "highcharts"; +import { vi } from "vitest"; + +import { KeyCode } from "@cloudscape-design/component-toolkit/internal"; + +import { createChartWrapper, renderChart } from "./common"; + +import legendTestClasses from "../../../lib/components/internal/components/chart-legend/test-classes/styles.selectors.js"; + +const series: Highcharts.SeriesOptionsType[] = [ + { + type: "line", + name: "L1", + data: [1], + }, + { + type: "line", + name: "L2", + data: [2], + }, + { + type: "line", + id: "L3", + name: "Line 3", + data: [3], + }, +]; + +const getItemSelector = (options?: { active?: boolean; dimmed?: boolean }) => { + let selector = `.${legendTestClasses.item}`; + if (options?.active === true) { + selector += `:not(.${legendTestClasses["hidden-item"]})`; + } + if (options?.active === false) { + selector += `.${legendTestClasses["hidden-item"]}`; + } + if (options?.dimmed === true) { + selector += `.${legendTestClasses["dimmed-item"]}`; + } + if (options?.dimmed === false) { + selector += `:not(.${legendTestClasses["dimmed-item"]})`; + } + return selector; +}; + +const getItem = (index: number, options?: { active?: boolean; dimmed?: boolean }) => + createChartWrapper().findLegend()!.findAll(getItemSelector(options))[index]; + +const mouseOver = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); +const mouseOut = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); +const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100)); + +describe("CoreChart: legend events", () => { + test("calls onLegendItemHighlightExit when leaving a legend item", async () => { + const onLegendItemHighlightExit = vi.fn(); + renderChart({ highcharts, options: { series }, onLegendItemHighlightExit }); + + // Hover over a legend item first + act(() => mouseOver(getItem(0).getElement())); + expect(onLegendItemHighlightExit).not.toHaveBeenCalled(); + + // Leave the legend item + act(() => mouseOut(getItem(0).getElement())); + await clearHighlightPause(); + + expect(onLegendItemHighlightExit).toHaveBeenCalled(); + }); + + test("calls onLegendItemHighlightExit when pressing escape on a focused legend item", () => { + const onLegendItemHighlightExit = vi.fn(); + renderChart({ highcharts, options: { series }, onLegendItemHighlightExit }); + + // Focus on a legend item first + getItem(0).focus(); + expect(onLegendItemHighlightExit).not.toHaveBeenCalled(); + + // Press escape to clear highlight + getItem(0).keydown({ keyCode: KeyCode.escape }); + + expect(onLegendItemHighlightExit).toHaveBeenCalled(); + }); + + test("calls onLegendItemHighlightExit only once when multiple legend items are involved", async () => { + const onLegendItemHighlightExit = vi.fn(); + renderChart({ highcharts, options: { series }, onLegendItemHighlightExit }); + + // Hover over first legend item + act(() => mouseOver(getItem(0).getElement())); + expect(onLegendItemHighlightExit).not.toHaveBeenCalled(); + + // Move to second legend item (should not trigger onLegendItemHighlightExit) + act(() => mouseOut(getItem(0).getElement())); + act(() => mouseOver(getItem(1).getElement())); + expect(onLegendItemHighlightExit).not.toHaveBeenCalled(); + + // Leave the second legend item (should trigger onLegendItemHighlightExit) + act(() => mouseOut(getItem(1).getElement())); + await clearHighlightPause(); + + expect(onLegendItemHighlightExit).toHaveBeenCalledTimes(1); + }); + + test("does not call onLegendItemHighlightExit when onLegendItemHighlightExit prop is not provided", async () => { + // This test ensures the component doesn't crash when the event handler is not provided + renderChart({ highcharts, options: { series } }); + + // Hover over a legend item first + act(() => mouseOver(getItem(0).getElement())); + + // Leave the legend item - should not crash + act(() => mouseOut(getItem(0).getElement())); + await clearHighlightPause(); + + // Test passes if no error is thrown + expect(true).toBe(true); + }); +}); diff --git a/src/core/chart-core.tsx b/src/core/chart-core.tsx index 1678c3a7..8ff31470 100644 --- a/src/core/chart-core.tsx +++ b/src/core/chart-core.tsx @@ -59,6 +59,7 @@ export function InternalCoreChart({ keyboardNavigation = true, onHighlight, onLegendItemHighlight, + onLegendItemHighlightExit, onClearHighlight, onVisibleItemsChange, visibleItems, @@ -88,6 +89,7 @@ export function InternalCoreChart({ api: api, i18nStrings, onItemHighlight: onLegendItemHighlight, + onItemHighlightExit: onLegendItemHighlightExit, getLegendTooltipContent: rest.getLegendTooltipContent, }; const containerProps = { diff --git a/src/core/components/core-legend.tsx b/src/core/components/core-legend.tsx index 600fe725..550721f8 100644 --- a/src/core/components/core-legend.tsx +++ b/src/core/components/core-legend.tsx @@ -18,6 +18,7 @@ export function ChartLegend({ isSecondary, i18nStrings, onItemHighlight, + onItemHighlightExit, getLegendTooltipContent, horizontalAlignment = "start", }: { @@ -30,6 +31,7 @@ export function ChartLegend({ horizontalAlignment?: "start" | "center" | "end"; i18nStrings?: CoreI18nStrings; onItemHighlight?: NonCancelableEventHandler; + onItemHighlightExit?: NonCancelableEventHandler; getLegendTooltipContent?: CoreChartProps.GetLegendTooltipContent; }) { const i18n = useInternalI18n("[charts]"); @@ -83,7 +85,10 @@ export function ChartLegend({ alignment={alignment} onToggleItem={onToggleItem} onSelectItem={onSelectItem} - onItemHighlightExit={api.onClearChartItemsHighlight} + onItemHighlightExit={() => { + api.onClearChartItemsHighlight(); + fireNonCancelableEvent(onItemHighlightExit); + }} onItemHighlightEnter={(item) => { api.onHighlightChartItems([item.id]); fireNonCancelableEvent(onItemHighlight, { item }); diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 1b88be10..6f369f17 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -368,6 +368,10 @@ export interface CoreChartProps * Called when a legend item is highlighted. */ onLegendItemHighlight?: NonCancelableEventHandler; + /** + * Called when a legend item highlight is cleared. + */ + onLegendItemHighlightExit?: NonCancelableEventHandler; /** * Called when series/points visibility changes due to user interaction with legend or filter. */