Skip to content

Commit 90910aa

Browse files
committed
fix: Fixes core legend sync when item highlight exits
1 parent d03e827 commit 90910aa

File tree

7 files changed

+201
-17
lines changed

7 files changed

+201
-17
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useRef, useState } from "react";
5+
import Highcharts from "highcharts";
6+
import { omit } from "lodash";
7+
8+
import ColumnLayout from "@cloudscape-design/components/column-layout";
9+
import {
10+
colorChartsPaletteCategorical1,
11+
colorChartsPaletteCategorical2,
12+
colorChartsPaletteCategorical3,
13+
} from "@cloudscape-design/design-tokens";
14+
15+
import { ChartSeriesMarker } from "../../lib/components/internal/components/series-marker";
16+
import CoreChart from "../../lib/components/internal-do-not-use/core-chart";
17+
import { CoreLegend } from "../../lib/components/internal-do-not-use/core-legend";
18+
import { CoreChartProps } from "../../src/core/interfaces";
19+
import { LegendItem } from "../../src/internal/components/interfaces";
20+
import { dateFormatter } from "../common/formatters";
21+
import { useChartSettings } from "../common/page-settings";
22+
import { Page } from "../common/templates";
23+
import pseudoRandom from "../utils/pseudo-random";
24+
25+
function randomInt(min: number, max: number) {
26+
return min + Math.floor(pseudoRandom() * (max - min));
27+
}
28+
29+
const baseline = [
30+
{ x: 1600984800000, y: 58020 },
31+
{ x: 1600985700000, y: 102402 },
32+
{ x: 1600986600000, y: 104920 },
33+
{ x: 1600987500000, y: 94031 },
34+
{ x: 1600988400000, y: 125021 },
35+
{ x: 1600989300000, y: 159219 },
36+
{ x: 1600990200000, y: 193082 },
37+
{ x: 1600991100000, y: 162592 },
38+
{ x: 1600992000000, y: 274021 },
39+
{ x: 1600992900000, y: 264286 },
40+
];
41+
42+
const dataA = baseline.map(({ x, y }) => ({ x, y }));
43+
const dataB = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-100000, 100000) }));
44+
const dataC = baseline.map(({ x, y }) => ({ x, y: y === null ? null : y + randomInt(-150000, 50000) }));
45+
46+
const series = [
47+
{
48+
name: "Series A",
49+
type: "line" as const,
50+
data: dataA,
51+
color: colorChartsPaletteCategorical1,
52+
},
53+
{
54+
name: "Series B",
55+
type: "line" as const,
56+
data: dataB,
57+
color: colorChartsPaletteCategorical2,
58+
},
59+
{
60+
name: "Series C",
61+
type: "line" as const,
62+
data: dataC,
63+
color: colorChartsPaletteCategorical3,
64+
},
65+
];
66+
67+
export default function LegendEventsDemo() {
68+
const [visibleItems, setVisibleItems] = useState(new Set<string>(["Series A", "Series B", "Series C"]));
69+
const [highlightedItem, setHighlightedItem] = useState<null | string>(null);
70+
71+
const { chartProps } = useChartSettings();
72+
const chart1API = useRef<CoreChartProps.ChartAPI>(null) as React.MutableRefObject<CoreChartProps.ChartAPI>;
73+
const chart2API = useRef<CoreChartProps.ChartAPI>(null) as React.MutableRefObject<CoreChartProps.ChartAPI>;
74+
75+
const createOnItemHighlight =
76+
(referrer: "legend" | "chart1" | "chart2") =>
77+
({ detail }: { detail: { item: LegendItem } }) => {
78+
setHighlightedItem(detail.item.name);
79+
if (referrer !== "chart1") {
80+
chart1API.current.highlightItems([detail.item.name]);
81+
}
82+
if (referrer !== "chart2") {
83+
chart2API.current.highlightItems([detail.item.name]);
84+
}
85+
};
86+
const createOnClearHighlight =
87+
(referrer: "legend" | "chart1" | "chart2") =>
88+
({ detail }: { detail: { isApiCall: boolean } }) => {
89+
if (!detail.isApiCall) {
90+
setHighlightedItem(null);
91+
if (referrer !== "chart1") {
92+
chart1API.current.clearChartHighlight();
93+
}
94+
if (referrer !== "chart2") {
95+
chart2API.current.clearChartHighlight();
96+
}
97+
}
98+
};
99+
100+
const legendItems: LegendItem[] = series.map((s) => ({
101+
id: s.name,
102+
name: s.name,
103+
marker: <ChartSeriesMarker color={s.color} type="line" />,
104+
visible: visibleItems.has(s.name),
105+
highlighted: highlightedItem === s.name,
106+
}));
107+
108+
return (
109+
<Page
110+
title="Legend events sync"
111+
subtitle="Demonstrates the highlight synchronization between standalone legends and charts with legends"
112+
>
113+
<ColumnLayout columns={2}>
114+
<CoreLegend
115+
items={legendItems}
116+
title="Standalone legend 1"
117+
onItemHighlight={createOnItemHighlight("legend")}
118+
onClearHighlight={createOnClearHighlight("legend")?.bind(null, { detail: { isApiCall: false } })}
119+
onVisibleItemsChange={({ detail }) => setVisibleItems(new Set(detail.items))}
120+
/>
121+
122+
<CoreLegend
123+
items={legendItems}
124+
title="Standalone legend 2"
125+
onItemHighlight={createOnItemHighlight("legend")}
126+
onClearHighlight={createOnClearHighlight("legend")?.bind(null, { detail: { isApiCall: false } })}
127+
onVisibleItemsChange={({ detail }) => setVisibleItems(new Set(detail.items))}
128+
/>
129+
130+
<CoreChart
131+
{...omit(chartProps.cartesian, "ref")}
132+
highcharts={Highcharts}
133+
callback={(chartApi) => (chart1API.current = chartApi)}
134+
ariaLabel="Chart 1"
135+
options={{
136+
series: series,
137+
xAxis: [{ type: "datetime", title: { text: "Time (UTC)" }, valueFormatter: dateFormatter }],
138+
yAxis: [{ title: { text: "Events" } }],
139+
}}
140+
legend={{ title: "Chart legend 1" }}
141+
onLegendItemHighlight={createOnItemHighlight("chart1")}
142+
onClearHighlight={createOnClearHighlight("chart1")}
143+
visibleItems={[...visibleItems]}
144+
onVisibleItemsChange={({ detail }) =>
145+
setVisibleItems(new Set(detail.items.filter((i) => i.visible).map((i) => i.name)))
146+
}
147+
/>
148+
149+
<CoreChart
150+
{...omit(chartProps.cartesian, "ref")}
151+
highcharts={Highcharts}
152+
callback={(chartApi) => (chart2API.current = chartApi)}
153+
ariaLabel="Chart 2"
154+
options={{
155+
series: series,
156+
xAxis: [{ type: "datetime", title: { text: "Time (UTC)" }, valueFormatter: dateFormatter }],
157+
yAxis: [{ title: { text: "Events" } }],
158+
}}
159+
legend={{ title: "Chart legend 2" }}
160+
onLegendItemHighlight={createOnItemHighlight("chart2")}
161+
onClearHighlight={createOnClearHighlight("chart2")}
162+
visibleItems={[...visibleItems]}
163+
onVisibleItemsChange={({ detail }) =>
164+
setVisibleItems(new Set(detail.items.filter((i) => i.visible).map((i) => i.name)))
165+
}
166+
/>
167+
</ColumnLayout>
168+
</Page>
169+
);
170+
}

src/core/__tests__/chart-core-api.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import { CoreChartProps } from "../../../lib/components/core/interfaces";
99
import { renderChart, selectLegendItem } from "./common";
1010
import { HighchartsTestHelper } from "./highcharts-utils";
1111

12-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
13-
1412
const hc = new HighchartsTestHelper(highcharts);
1513

1614
const series: Highcharts.SeriesOptionsType[] = [
@@ -68,7 +66,7 @@ describe("CoreChart: API tests", () => {
6866

6967
act(() => hc.highlightChartPoint(0, 0));
7068
act(() => hc.leaveChartPoint(0, 0));
71-
await clearHighlightPause();
69+
await hc.clearHighlightPause();
7270

7371
expect(onClearHighlight).toHaveBeenCalledWith(expect.objectContaining({ detail: { isApiCall: false } }));
7472
});

src/core/__tests__/chart-core-highlight.test.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@ import { HighchartsTestHelper } from "./highcharts-utils";
1010

1111
const hc = new HighchartsTestHelper(highcharts);
1212

13-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
14-
1513
describe("CoreChart: highlight", () => {
1614
test("highlights linked errorbar when target series is highlighted", async () => {
1715
renderChart({
@@ -55,7 +53,7 @@ describe("CoreChart: highlight", () => {
5553
expect(hc.getChartSeries(3).state).toBe("hover");
5654

5755
act(() => hc.leaveChartPoint(1, 0));
58-
await clearHighlightPause();
56+
await hc.clearHighlightPause();
5957

6058
expect(hc.getChartSeries(0).state).toBe("");
6159
expect(hc.getChartSeries(1).state).toBe("");
@@ -90,7 +88,7 @@ describe("CoreChart: highlight", () => {
9088
expect(hc.getChartPoint(1, 1).state).toBe("inactive");
9189

9290
act(() => hc.leaveChartPoint(1, 0));
93-
await clearHighlightPause();
91+
await hc.clearHighlightPause();
9492

9593
expect(hc.getChartSeries(0).state).toBe("");
9694
expect(hc.getChartPoint(0, 0).state).toBe("");

src/core/__tests__/chart-core-legend.test.tsx

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createChartWrapper,
1212
hoverLegendItem,
1313
hoverSecondaryLegendItem,
14+
leaveLegendItem,
1415
renderChart,
1516
renderStatefulChart,
1617
selectSecondaryLegendItem,
@@ -88,7 +89,6 @@ const getItem = (index: number, options?: { active?: boolean; dimmed?: boolean }
8889
createChartWrapper().findLegend()!.findAll(getItemSelector(options))[index];
8990
const mouseOver = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
9091
const mouseOut = (element: HTMLElement) => element.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
91-
const clearHighlightPause = () => new Promise((resolve) => setTimeout(resolve, 100));
9292
const mouseLeavePause = () => new Promise((resolve) => setTimeout(resolve, 300));
9393

9494
describe("CoreChart: legend", () => {
@@ -180,7 +180,7 @@ describe("CoreChart: legend", () => {
180180
expect(hc.getPlotLinesById("L3").map((l) => l.svgElem.opacity)).toEqual([1, 1]);
181181

182182
act(() => mouseOut(getItem(0).getElement()));
183-
await clearHighlightPause();
183+
await hc.clearHighlightPause();
184184
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual([
185185
"L1",
186186
"Line 3",
@@ -213,7 +213,7 @@ describe("CoreChart: legend", () => {
213213
expect(hc.getChartPoint(0, 2).state).toBe(undefined);
214214

215215
act(() => mouseOut(getItem(0).getElement()));
216-
await clearHighlightPause();
216+
await hc.clearHighlightPause();
217217
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
218218
expect(hc.getChartPoint(0, 0).state).toBe("");
219219
expect(hc.getChartPoint(0, 2).state).toBe("");
@@ -225,7 +225,7 @@ describe("CoreChart: legend", () => {
225225
expect(hc.getChartPoint(0, 2).state).toBe("hover");
226226

227227
act(() => mouseOut(getItem(2).getElement()));
228-
await clearHighlightPause();
228+
await hc.clearHighlightPause();
229229
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
230230
expect(hc.getChartPoint(0, 0).state).toBe("");
231231
expect(hc.getChartPoint(0, 2).state).toBe("");
@@ -255,7 +255,7 @@ describe("CoreChart: legend", () => {
255255

256256
act(() => hc.leaveChartPoint(2, 0));
257257
await mouseLeavePause();
258-
await clearHighlightPause();
258+
await hc.clearHighlightPause();
259259
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual([
260260
"L1",
261261
"Line 3",
@@ -283,7 +283,7 @@ describe("CoreChart: legend", () => {
283283

284284
act(() => hc.leaveChartPoint(0, 2));
285285
await mouseLeavePause();
286-
await clearHighlightPause();
286+
await hc.clearHighlightPause();
287287
expect(getItems({ dimmed: false, active: true }).map((w) => w.getElement().textContent)).toEqual(["P1", "Pie 3"]);
288288
},
289289
);
@@ -388,7 +388,7 @@ describe("CoreChart: legend", () => {
388388

389389
act(() => mouseOut(getItem(2).getElement()));
390390

391-
await clearHighlightPause();
391+
await hc.clearHighlightPause();
392392
expect(wrapper.findLegend()!.findItemTooltip()).toBe(null);
393393
});
394394

@@ -447,7 +447,7 @@ describe("CoreChart: legend", () => {
447447

448448
act(() => mouseOut(getItem(2).getElement()));
449449

450-
await clearHighlightPause();
450+
await hc.clearHighlightPause();
451451
expect(wrapper.findLegend()!.findItemTooltip()).toBe(null);
452452
});
453453

@@ -476,9 +476,10 @@ describe("CoreChart: legend", () => {
476476
});
477477
});
478478

479-
test("calls onLegendItemHighlight when hovering over a legend item", () => {
479+
test("calls onLegendItemHighlight and onClearHighlight when hovering over a legend item", async () => {
480480
const onLegendItemHighlight = vi.fn();
481-
const { wrapper } = renderChart({ highcharts, options: { series }, onLegendItemHighlight });
481+
const onClearHighlight = vi.fn();
482+
const { wrapper } = renderChart({ highcharts, options: { series }, onLegendItemHighlight, onClearHighlight });
482483

483484
hoverLegendItem(0, wrapper);
484485

@@ -495,6 +496,11 @@ describe("CoreChart: legend", () => {
495496
},
496497
}),
497498
);
499+
500+
leaveLegendItem(0, wrapper);
501+
await hc.clearHighlightPause();
502+
503+
expect(onClearHighlight).toHaveBeenCalled();
498504
});
499505
});
500506

src/core/__tests__/common.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ export function hoverLegendItem(index: number, wrapper: BaseChartWrapper = creat
9393
fireEvent.mouseOver(wrapper.findLegend()!.findItems()[index].getElement());
9494
});
9595
}
96+
export function leaveLegendItem(index: number, wrapper: BaseChartWrapper = createChartWrapper()) {
97+
act(() => {
98+
fireEvent.mouseLeave(wrapper.findLegend()!.findItems()[index].getElement());
99+
});
100+
}
96101

97102
export function selectSecondaryLegendItem(index: number, wrapper: ExtendedTestWrapper = createChartWrapper()) {
98103
act(() => wrapper.findSecondaryLegend()!.findItems()[index].click());

src/core/__tests__/highcharts-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export class HighchartsTestHelper {
4141
.axes.flatMap((axis) => (axis as any).plotLinesAndBands)
4242
.filter((plotLine) => plotLine.options.id === id);
4343
}
44+
45+
public clearHighlightPause() {
46+
return new Promise((resolve) => setTimeout(resolve, 100));
47+
}
4448
}
4549

4650
export class ChartRendererStub {

src/core/chart-api/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ export class ChartAPI {
214214
if (!this.isTooltipPinned) {
215215
this.chartExtraHighlight.clearChartItemsHighlight();
216216
this.chartExtraLegend.onClearHighlight();
217+
218+
// Notify the consumer that a clear-highlight action was made.
219+
fireNonCancelableEvent(this.context.handlers.onClearHighlight, { isApiCall: false });
217220
}
218221
};
219222

0 commit comments

Comments
 (0)