Skip to content

Commit 7c5a5bd

Browse files
author
Hrvoje Hemen
committed
feat: emit event on legend highlight exit
1 parent 80443d9 commit 7c5a5bd

File tree

6 files changed

+289
-1
lines changed

6 files changed

+289
-1
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useMemo, useState } from "react";
5+
6+
import Alert from "@cloudscape-design/components/alert";
7+
import Badge from "@cloudscape-design/components/badge";
8+
import Box from "@cloudscape-design/components/box";
9+
import SpaceBetween from "@cloudscape-design/components/space-between";
10+
11+
import { CoreLegend } from "../../lib/components/internal-do-not-use/core-legend";
12+
import { Page } from "../common/templates";
13+
14+
// Base legend items data
15+
const baseLegendItems = [
16+
{
17+
id: "series-a",
18+
name: "Series A",
19+
marker: <div style={{ width: 12, height: 12, backgroundColor: "#1f77b4", borderRadius: 2 }} />,
20+
visible: true,
21+
},
22+
{
23+
id: "series-b",
24+
name: "Series B",
25+
marker: <div style={{ width: 12, height: 12, backgroundColor: "#ff7f0e", borderRadius: 2 }} />,
26+
visible: true,
27+
},
28+
{
29+
id: "series-c",
30+
name: "Series C",
31+
marker: <div style={{ width: 12, height: 12, backgroundColor: "#2ca02c", borderRadius: 2 }} />,
32+
visible: true,
33+
},
34+
];
35+
36+
export default function LegendEventsDemo() {
37+
// State for tracking which item is highlighted in each legend
38+
const [highlightedItemWithExit, setHighlightedItemWithExit] = useState<string | null>(null);
39+
const [highlightedItemWithoutExit, setHighlightedItemWithoutExit] = useState<string | null>(null);
40+
41+
// Create legend items with dynamic highlighted state for the legend WITH exit handler
42+
const legendItemsWithExit = useMemo(
43+
() =>
44+
baseLegendItems.map((item) => ({
45+
...item,
46+
highlighted: item.name === highlightedItemWithExit,
47+
})),
48+
[highlightedItemWithExit],
49+
);
50+
51+
// Create legend items with dynamic highlighted state for the legend WITHOUT exit handler
52+
const legendItemsWithoutExit = useMemo(
53+
() =>
54+
baseLegendItems.map((item) => ({
55+
...item,
56+
highlighted: item.name === highlightedItemWithoutExit,
57+
})),
58+
[highlightedItemWithoutExit],
59+
);
60+
61+
// Handler for legend WITH exit handler - controls both legends
62+
const handleLegendItemHighlightWithExit = (event: { detail: { item: { name: string } } }) => {
63+
const itemName = event.detail.item.name;
64+
setHighlightedItemWithExit(itemName);
65+
// Cross-control: also highlight the corresponding item in the other legend
66+
setHighlightedItemWithoutExit(itemName);
67+
};
68+
69+
// Handler for exit event - only clears the legend WITH exit handler
70+
const handleLegendItemHighlightExitWithExit = () => {
71+
setHighlightedItemWithExit(null);
72+
// Cross-control: also clear the other legend (this shows proper bidirectional control)
73+
setHighlightedItemWithoutExit(null);
74+
};
75+
76+
// Handler for legend WITHOUT exit handler - controls both legends
77+
const handleLegendItemHighlightWithoutExit = (event: { detail: { item: { name: string } } }) => {
78+
const itemName = event.detail.item.name;
79+
setHighlightedItemWithoutExit(itemName);
80+
// Cross-control: also highlight the corresponding item in the other legend
81+
setHighlightedItemWithExit(itemName);
82+
};
83+
84+
// Note: No exit handler for the second legend - this demonstrates the issue
85+
86+
return (
87+
<Page
88+
title="Legend Events"
89+
subtitle="Demonstrates the difference between having onLegendItemHighlightExit and not having it"
90+
>
91+
<SpaceBetween direction="vertical" size="l">
92+
{/* Legend WITH exit handler */}
93+
<Box>
94+
<SpaceBetween direction="vertical" size="m">
95+
<SpaceBetween direction="horizontal" size="s" alignItems="center">
96+
<Box variant="h2">Legend WITH onClearHighlight (Controls Both)</Box>
97+
<Badge color={highlightedItemWithExit ? "blue" : "grey"}>
98+
{highlightedItemWithExit ? `Controlling: ${highlightedItemWithExit}` : "Not Controlling"}
99+
</Badge>
100+
</SpaceBetween>
101+
102+
<CoreLegend
103+
items={legendItemsWithExit}
104+
title="Master Legend (Has Exit Handler)"
105+
ariaLabel="Legend with exit handler that controls both legends"
106+
onItemHighlight={handleLegendItemHighlightWithExit}
107+
onClearHighlight={handleLegendItemHighlightExitWithExit}
108+
/>
109+
</SpaceBetween>
110+
</Box>
111+
112+
{/* Legend WITHOUT exit handler */}
113+
<Box>
114+
<SpaceBetween direction="vertical" size="m">
115+
<SpaceBetween direction="horizontal" size="s" alignItems="center">
116+
<Box variant="h2">Legend WITHOUT onClearHighlight (Controls Both)</Box>
117+
<Badge color={highlightedItemWithoutExit ? "red" : "grey"}>
118+
{highlightedItemWithoutExit ? `Stuck Controlling: ${highlightedItemWithoutExit}` : "Not Controlling"}
119+
</Badge>
120+
</SpaceBetween>
121+
122+
<CoreLegend
123+
items={legendItemsWithoutExit}
124+
title="Broken Legend (No Exit Handler)"
125+
ariaLabel="Legend without exit handler that controls both legends"
126+
onItemHighlight={handleLegendItemHighlightWithoutExit}
127+
// Note: No onClearHighlight prop - this demonstrates the issue
128+
/>
129+
</SpaceBetween>
130+
</Box>
131+
132+
<Alert type="success">
133+
<strong>Expected Behavior:</strong>
134+
<br /><strong>Top Legend (With Exit Handler):</strong> When you hover and then move away, both legends clear
135+
their highlights properly - demonstrating proper bidirectional control.
136+
<br /><strong>Bottom Legend (Without Exit Handler):</strong> When you hover and then move away, both legends
137+
remain stuck in the highlighted state - demonstrating the broken one-way control that the exit handler fixes.
138+
<br />
139+
<br />
140+
This shows how the exit handler is essential for proper cross-component communication and state management.
141+
</Alert>
142+
</SpaceBetween>
143+
</Page>
144+
);
145+
}

src/__tests__/__snapshots__/documenter.test.ts.snap

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,15 @@ exports[`internal core API matches snapshot > internal-core-chart 1`] = `
12361236
"name": "onLegendItemHighlight",
12371237
"systemTags": undefined,
12381238
},
1239+
{
1240+
"cancelable": false,
1241+
"deprecatedTag": undefined,
1242+
"description": "Called when a legend item highlight is cleared.",
1243+
"detailInlineType": undefined,
1244+
"detailType": undefined,
1245+
"name": "onLegendItemHighlightExit",
1246+
"systemTags": undefined,
1247+
},
12391248
{
12401249
"cancelable": false,
12411250
"deprecatedTag": undefined,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { act } from "react";
5+
import highcharts from "highcharts";
6+
import { vi } from "vitest";
7+
8+
import { KeyCode } from "@cloudscape-design/component-toolkit/internal";
9+
10+
import { createChartWrapper, renderChart } from "./common";
11+
12+
import legendTestClasses from "../../../lib/components/internal/components/chart-legend/test-classes/styles.selectors.js";
13+
14+
const series: Highcharts.SeriesOptionsType[] = [
15+
{
16+
type: "line",
17+
name: "L1",
18+
data: [1],
19+
},
20+
{
21+
type: "line",
22+
name: "L2",
23+
data: [2],
24+
},
25+
{
26+
type: "line",
27+
id: "L3",
28+
name: "Line 3",
29+
data: [3],
30+
},
31+
];
32+
33+
const getItemSelector = (options?: { active?: boolean; dimmed?: boolean }) => {
34+
let selector = `.${legendTestClasses.item}`;
35+
if (options?.active === true) {
36+
selector += `:not(.${legendTestClasses["hidden-item"]})`;
37+
}
38+
if (options?.active === false) {
39+
selector += `.${legendTestClasses["hidden-item"]}`;
40+
}
41+
if (options?.dimmed === true) {
42+
selector += `.${legendTestClasses["dimmed-item"]}`;
43+
}
44+
if (options?.dimmed === false) {
45+
selector += `:not(.${legendTestClasses["dimmed-item"]})`;
46+
}
47+
return selector;
48+
};
49+
50+
const getItem = (index: number, options?: { active?: boolean; dimmed?: boolean }) =>
51+
createChartWrapper().findLegend()!.findAll(getItemSelector(options))[index];
52+
53+
const mouseOver = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
54+
const mouseOut = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
55+
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
56+
57+
describe("CoreChart: legend events", () => {
58+
test("calls onLegendItemHighlightExit when leaving a legend item", async () => {
59+
const onLegendItemHighlightExit = vi.fn();
60+
renderChart({ highcharts, options: { series }, onLegendItemHighlightExit });
61+
62+
// Hover over a legend item first
63+
act(() => mouseOver(getItem(0).getElement()));
64+
expect(onLegendItemHighlightExit).not.toHaveBeenCalled();
65+
66+
// Leave the legend item
67+
act(() => mouseOut(getItem(0).getElement()));
68+
await clearHighlightPause();
69+
70+
expect(onLegendItemHighlightExit).toHaveBeenCalled();
71+
});
72+
73+
test("calls onLegendItemHighlightExit when pressing escape on a focused legend item", () => {
74+
const onLegendItemHighlightExit = vi.fn();
75+
renderChart({ highcharts, options: { series }, onLegendItemHighlightExit });
76+
77+
// Focus on a legend item first
78+
getItem(0).focus();
79+
expect(onLegendItemHighlightExit).not.toHaveBeenCalled();
80+
81+
// Press escape to clear highlight
82+
getItem(0).keydown({ keyCode: KeyCode.escape });
83+
84+
expect(onLegendItemHighlightExit).toHaveBeenCalled();
85+
});
86+
87+
test("calls onLegendItemHighlightExit only once when multiple legend items are involved", async () => {
88+
const onLegendItemHighlightExit = vi.fn();
89+
renderChart({ highcharts, options: { series }, onLegendItemHighlightExit });
90+
91+
// Hover over first legend item
92+
act(() => mouseOver(getItem(0).getElement()));
93+
expect(onLegendItemHighlightExit).not.toHaveBeenCalled();
94+
95+
// Move to second legend item (should not trigger onLegendItemHighlightExit)
96+
act(() => mouseOut(getItem(0).getElement()));
97+
act(() => mouseOver(getItem(1).getElement()));
98+
expect(onLegendItemHighlightExit).not.toHaveBeenCalled();
99+
100+
// Leave the second legend item (should trigger onLegendItemHighlightExit)
101+
act(() => mouseOut(getItem(1).getElement()));
102+
await clearHighlightPause();
103+
104+
expect(onLegendItemHighlightExit).toHaveBeenCalledTimes(1);
105+
});
106+
107+
test("does not call onLegendItemHighlightExit when onLegendItemHighlightExit prop is not provided", async () => {
108+
// This test ensures the component doesn't crash when the event handler is not provided
109+
renderChart({ highcharts, options: { series } });
110+
111+
// Hover over a legend item first
112+
act(() => mouseOver(getItem(0).getElement()));
113+
114+
// Leave the legend item - should not crash
115+
act(() => mouseOut(getItem(0).getElement()));
116+
await clearHighlightPause();
117+
118+
// Test passes if no error is thrown
119+
expect(true).toBe(true);
120+
});
121+
});

src/core/chart-core.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export function InternalCoreChart({
5959
keyboardNavigation = true,
6060
onHighlight,
6161
onLegendItemHighlight,
62+
onLegendItemHighlightExit,
6263
onClearHighlight,
6364
onVisibleItemsChange,
6465
visibleItems,
@@ -88,6 +89,7 @@ export function InternalCoreChart({
8889
api: api,
8990
i18nStrings,
9091
onItemHighlight: onLegendItemHighlight,
92+
onItemHighlightExit: onLegendItemHighlightExit,
9193
getLegendTooltipContent: rest.getLegendTooltipContent,
9294
};
9395
const containerProps = {

src/core/components/core-legend.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export function ChartLegend({
1818
isSecondary,
1919
i18nStrings,
2020
onItemHighlight,
21+
onItemHighlightExit,
2122
getLegendTooltipContent,
2223
horizontalAlignment = "start",
2324
}: {
@@ -30,6 +31,7 @@ export function ChartLegend({
3031
horizontalAlignment?: "start" | "center" | "end";
3132
i18nStrings?: CoreI18nStrings;
3233
onItemHighlight?: NonCancelableEventHandler<CoreChartProps.LegendItemHighlightDetail>;
34+
onItemHighlightExit?: NonCancelableEventHandler;
3335
getLegendTooltipContent?: CoreChartProps.GetLegendTooltipContent;
3436
}) {
3537
const i18n = useInternalI18n("[charts]");
@@ -83,7 +85,10 @@ export function ChartLegend({
8385
alignment={alignment}
8486
onToggleItem={onToggleItem}
8587
onSelectItem={onSelectItem}
86-
onItemHighlightExit={api.onClearChartItemsHighlight}
88+
onItemHighlightExit={() => {
89+
api.onClearChartItemsHighlight();
90+
fireNonCancelableEvent(onItemHighlightExit);
91+
}}
8792
onItemHighlightEnter={(item) => {
8893
api.onHighlightChartItems([item.id]);
8994
fireNonCancelableEvent(onItemHighlight, { item });

src/core/interfaces.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ export interface CoreChartProps
368368
* Called when a legend item is highlighted.
369369
*/
370370
onLegendItemHighlight?: NonCancelableEventHandler<CoreChartProps.LegendItemHighlightDetail>;
371+
/**
372+
* Called when a legend item highlight is cleared.
373+
*/
374+
onLegendItemHighlightExit?: NonCancelableEventHandler;
371375
/**
372376
* Called when series/points visibility changes due to user interaction with legend or filter.
373377
*/
@@ -523,6 +527,8 @@ export interface CoreLegendProps {
523527
onClearHighlight?: NonCancelableEventHandler;
524528
onItemHighlight?: NonCancelableEventHandler<CoreLegendProps.ItemHighlightDetail>;
525529
onVisibleItemsChange?: NonCancelableEventHandler<CoreLegendProps.VisibleItemsChangeDetail>;
530+
onLegendItemHighlight?: NonCancelableEventHandler<CoreChartProps.LegendItemHighlightDetail>;
531+
onLegendItemHighlightExit?: NonCancelableEventHandler;
526532
getLegendTooltipContent?: CoreLegendProps.GetTooltipContent;
527533
}
528534

0 commit comments

Comments
 (0)