Skip to content

Commit a2c6a8b

Browse files
authored
feat: implement optional legend popover (#29)
1 parent fc3ed6b commit a2c6a8b

File tree

8 files changed

+180
-4
lines changed

8 files changed

+180
-4
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pages/03-core/core-line-chart.page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export default function () {
111111
}}
112112
chartHeight={400}
113113
tooltip={{ placement: "outside" }}
114+
getLegendTooltipContent={({ legendItem }) => <div>{legendItem.name}</div>}
114115
/>
115116
</Page>
116117
);

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { act } from "react";
5+
import { fireEvent, waitFor } from "@testing-library/react";
56
import highcharts from "highcharts";
67

78
import { KeyCode } from "@cloudscape-design/component-toolkit/internal";
@@ -167,6 +168,61 @@ describe("CoreChart: legend", () => {
167168
},
168169
);
169170

171+
test("renders legend tooltip on hover in cartesian chart, if specified", async () => {
172+
const { wrapper } = renderChart({
173+
highcharts,
174+
options: {
175+
series: series.filter((s) => s.type === "line"),
176+
xAxis: { plotLines: [{ id: "L3", value: 0 }] },
177+
yAxis: { plotLines: [{ id: "L3", value: 0 }] },
178+
},
179+
visibleItems: ["L1", "L3"],
180+
getLegendTooltipContent: ({ legendItem }) => <div>{legendItem.name}</div>,
181+
});
182+
183+
expect(wrapper.findTooltip()).toBe(null);
184+
185+
act(() => mouseOver(getItem(0).getElement()));
186+
expect(wrapper.findTooltip()).not.toBe(null);
187+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("L1");
188+
189+
act(() => mouseOut(getItem(0).getElement()));
190+
act(() => mouseOver(getItem(2).getElement()));
191+
expect(wrapper.findTooltip()).not.toBe(null);
192+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("Line 3");
193+
194+
act(() => mouseOut(getItem(2).getElement()));
195+
await clearHighlightPause();
196+
expect(wrapper.findTooltip()).toBe(null);
197+
});
198+
199+
test("renders legend tooltip on focus in cartesian chart, if specified", async () => {
200+
const { wrapper } = renderChart({
201+
highcharts,
202+
options: {
203+
series: series.filter((s) => s.type === "line"),
204+
xAxis: { plotLines: [{ id: "L3", value: 0 }] },
205+
yAxis: { plotLines: [{ id: "L3", value: 0 }] },
206+
},
207+
visibleItems: ["L1", "L3"],
208+
getLegendTooltipContent: ({ legendItem }) => <div>{legendItem.name}</div>,
209+
});
210+
211+
expect(wrapper.findTooltip()).toBe(null);
212+
213+
act(() => getItem(0).focus());
214+
await waitFor(() => {
215+
expect(wrapper.findTooltip()).not.toBe(null);
216+
});
217+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("L1");
218+
219+
fireEvent.keyDown(getItem(0).getElement(), { keyCode: KeyCode.right });
220+
await waitFor(() => {
221+
expect(wrapper.findTooltip()).not.toBe(null);
222+
});
223+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("L2");
224+
});
225+
170226
test.each([{ position: "bottom" as const }, { position: "side" as const }])(
171227
"legend items are highlighted on hover in pie chart",
172228
async ({ position }) => {
@@ -208,6 +264,53 @@ describe("CoreChart: legend", () => {
208264
},
209265
);
210266

267+
test("renders legend tooltip on hover in pie chart, if specified", async () => {
268+
const { wrapper } = renderChart({
269+
highcharts,
270+
options: { series: series.filter((s) => s.type === "pie") },
271+
visibleItems: ["P1", "P3"],
272+
getLegendTooltipContent: ({ legendItem }) => <div>{legendItem.name}</div>,
273+
});
274+
275+
expect(wrapper.findTooltip()).toBe(null);
276+
277+
act(() => mouseOver(getItem(0).getElement()));
278+
expect(wrapper.findTooltip()).not.toBe(null);
279+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("P1");
280+
281+
act(() => mouseOut(getItem(0).getElement()));
282+
act(() => mouseOver(getItem(2).getElement()));
283+
expect(wrapper.findTooltip()).not.toBe(null);
284+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("Pie 3");
285+
286+
act(() => mouseOut(getItem(2).getElement()));
287+
await clearHighlightPause();
288+
expect(wrapper.findTooltip()).toBe(null);
289+
});
290+
291+
test("renders legend tooltip on focus in pie chart, if specified", async () => {
292+
const { wrapper } = renderChart({
293+
highcharts,
294+
options: { series: series.filter((s) => s.type === "pie") },
295+
visibleItems: ["P1", "P3"],
296+
getLegendTooltipContent: ({ legendItem }) => <div>{legendItem.name}</div>,
297+
});
298+
299+
expect(wrapper.findTooltip()).toBe(null);
300+
301+
act(() => getItem(0).focus());
302+
await waitFor(() => {
303+
expect(wrapper.findTooltip()).not.toBe(null);
304+
});
305+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("P1");
306+
307+
fireEvent.keyDown(getItem(0).getElement(), { keyCode: KeyCode.right });
308+
await waitFor(() => {
309+
expect(wrapper.findTooltip()).not.toBe(null);
310+
});
311+
expect(wrapper.findTooltip()!.getElement().textContent).toBe("P2");
312+
});
313+
211314
test.each([{ position: "bottom" as const }, { position: "side" as const }])(
212315
"legend items are highlighted when cartesian chart series point is highlighted",
213316
async () => {

src/core/chart-core.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,13 @@ export function InternalCoreChart({
280280
}}
281281
legend={
282282
settings.legendEnabled ? (
283-
<ChartLegend {...legendOptions} position={legendPosition} api={api} i18nStrings={i18nStrings} />
283+
<ChartLegend
284+
{...legendOptions}
285+
position={legendPosition}
286+
api={api}
287+
i18nStrings={i18nStrings}
288+
getLegendTooltipContent={rest.getLegendTooltipContent}
289+
/>
284290
) : null
285291
}
286292
verticalAxisTitle={

src/core/components/core-legend.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@ import { useInternalI18n } from "@cloudscape-design/components/internal/do-not-u
66
import { ChartLegend as ChartLegendComponent } from "../../internal/components/chart-legend";
77
import { useSelector } from "../../internal/utils/async-store";
88
import { ChartAPI } from "../chart-api";
9-
import { BaseI18nStrings } from "../interfaces";
9+
import { BaseI18nStrings, GetLegendTooltipContent } from "../interfaces";
1010

1111
export function ChartLegend({
1212
api,
1313
title,
1414
actions,
1515
position,
1616
i18nStrings,
17+
getLegendTooltipContent,
1718
}: {
1819
api: ChartAPI;
1920
title?: string;
2021
actions?: React.ReactNode;
22+
getLegendTooltipContent?: GetLegendTooltipContent;
2123
position: "bottom" | "side";
2224
i18nStrings?: BaseI18nStrings;
2325
}) {
@@ -37,6 +39,7 @@ export function ChartLegend({
3739
onItemVisibilityChange={api.onItemVisibilityChange}
3840
onItemHighlightEnter={(itemId) => api.onHighlightChartItems([itemId])}
3941
onItemHighlightExit={api.onClearChartItemsHighlight}
42+
getLegendTooltipContent={getLegendTooltipContent}
4043
/>
4144
);
4245
}

src/core/interfaces.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,11 @@ export interface CoreChartProps
265265
* Called whenever chart tooltip is rendered to provide content for tooltip's header, body, and (optional) footer.
266266
*/
267267
getTooltipContent?: GetTooltipContent;
268+
/**
269+
* Called whenever a legend item is hovered to provide content for the legend popover.
270+
* If not provided, no popover will be displayed. The function receives the legend item and should return React node to display.
271+
*/
272+
getLegendTooltipContent?: GetLegendTooltipContent;
268273
/**
269274
* Called whenever chart point or group is highlighted.
270275
*/
@@ -332,6 +337,12 @@ export interface GetTooltipContentProps {
332337
group: readonly Highcharts.Point[];
333338
}
334339

340+
export type GetLegendTooltipContent = (props: GetLegendTooltipContentProps) => React.ReactNode;
341+
342+
export interface GetLegendTooltipContentProps {
343+
legendItem: CoreLegendItem;
344+
}
345+
335346
export interface CoreTooltipContent {
336347
point?: (props: TooltipPointProps) => TooltipPointFormatted;
337348
header?: (props: TooltipSlotProps) => React.ReactNode;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { InternalChartTooltip } from "@cloudscape-design/components/internal/do-not-use/chart-tooltip";
5+
6+
import { CoreLegendItem, GetLegendTooltipContent } from "../../../core/interfaces";
7+
8+
interface ChartLegendTooltipProps {
9+
legendItem: CoreLegendItem;
10+
getLegendTooltipContent?: GetLegendTooltipContent;
11+
trackRef: React.RefObject<HTMLElement>;
12+
}
13+
14+
export function ChartLegendTooltip({ legendItem, getLegendTooltipContent, trackRef }: ChartLegendTooltipProps) {
15+
if (!getLegendTooltipContent) {
16+
return null;
17+
}
18+
19+
const content = getLegendTooltipContent({ legendItem });
20+
21+
return (
22+
<InternalChartTooltip
23+
trackRef={trackRef}
24+
trackKey={`legend-${legendItem.id}`}
25+
container={null}
26+
dismissButton={false}
27+
onDismiss={() => {}}
28+
position="bottom"
29+
>
30+
{content}
31+
</InternalChartTooltip>
32+
);
33+
}

src/internal/components/chart-legend/index.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import {
1515
} from "@cloudscape-design/component-toolkit/internal";
1616
import Box from "@cloudscape-design/components/box";
1717

18-
import { CoreLegendItem } from "../../../core/interfaces";
18+
import { CoreLegendItem, GetLegendTooltipContent } from "../../../core/interfaces";
1919
import { DebouncedCall } from "../../utils/utils";
20+
import { ChartLegendTooltip } from "../chart-legend-tooltip";
2021

2122
import styles from "./styles.css.js";
2223
import testClasses from "./test-classes/styles.css.js";
@@ -30,6 +31,7 @@ export interface ChartLegendProps {
3031
onItemHighlightEnter: (itemId: string) => void;
3132
onItemHighlightExit: () => void;
3233
onItemVisibilityChange: (hiddenItems: string[]) => void;
34+
getLegendTooltipContent?: GetLegendTooltipContent;
3335
}
3436

3537
export const ChartLegend = ({
@@ -41,11 +43,13 @@ export const ChartLegend = ({
4143
onItemVisibilityChange,
4244
onItemHighlightEnter,
4345
onItemHighlightExit,
46+
getLegendTooltipContent,
4447
}: ChartLegendProps) => {
4548
const containerRef = useRef<HTMLDivElement>(null);
4649
const segmentsRef = useRef<Record<number, HTMLElement>>([]);
4750
const highlightControl = useMemo(() => new DebouncedCall(), []);
4851
const [selectedIndex, setSelectedIndex] = useState<number>(0);
52+
const [tooltipItemId, setTooltipItemId] = useState<string | null>(null);
4953
const showHighlight = (itemId: string) => {
5054
const item = items.find((item) => item.id === itemId);
5155
if (item?.visible) {
@@ -63,10 +67,15 @@ export const ChartLegend = ({
6367
setSelectedIndex(index);
6468
navigationAPI.current!.updateFocusTarget();
6569
showHighlight(itemId);
70+
71+
setTooltipItemId(null);
72+
// Force separate render cycle to dismiss the existing popover first
73+
setTimeout(() => setTooltipItemId(itemId), 0);
6674
}
6775

6876
function onBlur() {
6977
navigationAPI.current!.updateFocusTarget();
78+
setTooltipItemId(null);
7079
}
7180

7281
function focusElement(index: number) {
@@ -200,9 +209,11 @@ export const ChartLegend = ({
200209
const handlers = {
201210
onMouseEnter: () => {
202211
showHighlight(item.id);
212+
setTooltipItemId(item.id);
203213
},
204214
onMouseLeave: () => {
205215
clearHighlight();
216+
setTooltipItemId(null);
206217
},
207218
onFocus: () => {
208219
onFocus(index, item.id);
@@ -245,6 +256,14 @@ export const ChartLegend = ({
245256
</div>
246257
</div>
247258
</div>
259+
260+
{tooltipItemId && (
261+
<ChartLegendTooltip
262+
legendItem={items.find((item) => item.id === tooltipItemId)!}
263+
getLegendTooltipContent={getLegendTooltipContent}
264+
trackRef={{ current: segmentsRef.current[items.findIndex((item) => item.id === tooltipItemId)] }}
265+
/>
266+
)}
248267
</SingleTabStopNavigationProvider>
249268
);
250269
};

0 commit comments

Comments
 (0)