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.
*/