Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions pages/03-core/legend-events.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page only covers the standalone legend use case, but not charts.

How about we create a page that:

  1. Has a standalone legend and a two charts where each has a legend, too
  2. The events between all legends are synced

This page can be then used as a playground to see why exactly the existing event listeners that we have in the chart and in the legend at not enough (if that is the case).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think thats needed. This was a demo page to show the usecase of emission of exit events.
This doesn't affect other charts currently as it is not being consumed in this project, but externally, we just needed a way to catch that event.

If needed I can make a small POC sandbox with two charts that are fully event synced, but I don't think that is needed for this PR.

// 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: <div style={{ width: 12, height: 12, backgroundColor: "#1f77b4", borderRadius: 2 }} />,
visible: true,
},
{
id: "series-b",
name: "Series B",
marker: <div style={{ width: 12, height: 12, backgroundColor: "#ff7f0e", borderRadius: 2 }} />,
visible: true,
},
{
id: "series-c",
name: "Series C",
marker: <div style={{ width: 12, height: 12, backgroundColor: "#2ca02c", borderRadius: 2 }} />,
visible: true,
},
];

export default function LegendEventsDemo() {
// State for tracking which item is highlighted in each legend
const [highlightedItemWithExit, setHighlightedItemWithExit] = useState<string | null>(null);
const [highlightedItemWithoutExit, setHighlightedItemWithoutExit] = useState<string | null>(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 (
<Page
title="Legend Events"
subtitle="Demonstrates the difference between having onLegendItemHighlightExit and not having it"
>
<SpaceBetween direction="vertical" size="l">
{/* Legend WITH exit handler */}
<Box>
<SpaceBetween direction="vertical" size="m">
<SpaceBetween direction="horizontal" size="s" alignItems="center">
<Box variant="h2">Legend WITH onClearHighlight (Controls Both)</Box>
<Badge color={highlightedItemWithExit ? "blue" : "grey"}>
{highlightedItemWithExit ? `Controlling: ${highlightedItemWithExit}` : "Not Controlling"}
</Badge>
</SpaceBetween>

<CoreLegend
items={legendItemsWithExit}
title="Master Legend (Has Exit Handler)"
ariaLabel="Legend with exit handler that controls both legends"
onItemHighlight={handleLegendItemHighlightWithExit}
onClearHighlight={handleLegendItemHighlightExitWithExit}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the existing onClearHighlight. Did you mean to showcase this or the new onLegendItemHighlightExit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you look at CoreChart, you will see that it passes onLegendItemHighlightExit and onLegendItemHighlight to CoreLegend through onItemHighlight and onClearHighlight.

I think that the usage of onLegendItemHighlight on chart turning into onItemHighlight was done that way because if the prop is on legend, there is no need to add legend substring into the variable.

So current flow, before my PR:

Chart gets a prop called onLegendItemHighlight and passes it as onItemHighlight to the tooltip.

I simply added another prop for onLegendItemHighlightExit and passed it the same way.

So to answer your question, I am testing the new feature. If it's confusing I can refactor the props and rename them to keep their naming across both the chart and legend.

/>
</SpaceBetween>
</Box>

{/* Legend WITHOUT exit handler */}
<Box>
<SpaceBetween direction="vertical" size="m">
<SpaceBetween direction="horizontal" size="s" alignItems="center">
<Box variant="h2">Legend WITHOUT onClearHighlight (Controls Both)</Box>
<Badge color={highlightedItemWithoutExit ? "red" : "grey"}>
{highlightedItemWithoutExit ? `Stuck Controlling: ${highlightedItemWithoutExit}` : "Not Controlling"}
</Badge>
</SpaceBetween>

<CoreLegend
items={legendItemsWithoutExit}
title="Broken Legend (No Exit Handler)"
ariaLabel="Legend without exit handler that controls both legends"
onItemHighlight={handleLegendItemHighlightWithoutExit}
// Note: No onClearHighlight prop - this demonstrates the issue
/>
</SpaceBetween>
</Box>

<Alert type="success">
<strong>Expected Behavior:</strong>
<br /><strong>Top Legend (With Exit Handler):</strong> When you hover and then move away, both legends clear
their highlights properly - demonstrating proper bidirectional control.
<br /><strong>Bottom Legend (Without Exit Handler):</strong> 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.
<br />
<br />
This shows how the exit handler is essential for proper cross-component communication and state management.
</Alert>
</SpaceBetween>
</Page>
);
}
9 changes: 9 additions & 0 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions src/core/__tests__/chart-core-legend-events.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions src/core/chart-core.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function InternalCoreChart({
keyboardNavigation = true,
onHighlight,
onLegendItemHighlight,
onLegendItemHighlightExit,
onClearHighlight,
onVisibleItemsChange,
visibleItems,
Expand Down Expand Up @@ -88,6 +89,7 @@ export function InternalCoreChart({
api: api,
i18nStrings,
onItemHighlight: onLegendItemHighlight,
onItemHighlightExit: onLegendItemHighlightExit,
getLegendTooltipContent: rest.getLegendTooltipContent,
};
const containerProps = {
Expand Down
7 changes: 6 additions & 1 deletion src/core/components/core-legend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export function ChartLegend({
isSecondary,
i18nStrings,
onItemHighlight,
onItemHighlightExit,
getLegendTooltipContent,
horizontalAlignment = "start",
}: {
Expand All @@ -30,6 +31,7 @@ export function ChartLegend({
horizontalAlignment?: "start" | "center" | "end";
i18nStrings?: CoreI18nStrings;
onItemHighlight?: NonCancelableEventHandler<CoreChartProps.LegendItemHighlightDetail>;
onItemHighlightExit?: NonCancelableEventHandler;
getLegendTooltipContent?: CoreChartProps.GetLegendTooltipContent;
}) {
const i18n = useInternalI18n("[charts]");
Expand Down Expand Up @@ -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 });
Expand Down
4 changes: 4 additions & 0 deletions src/core/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ export interface CoreChartProps
* Called when a legend item is highlighted.
*/
onLegendItemHighlight?: NonCancelableEventHandler<CoreChartProps.LegendItemHighlightDetail>;
/**
* Called when a legend item highlight is cleared.
*/
onLegendItemHighlightExit?: NonCancelableEventHandler;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the same be added to the CoreLegendProps?

Also, how is that different from the existing onClearHighlight?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the same be added to the CoreLegendProps?
Nice catch, I see that onLegendItemHighlight is also not there, so I'll add both of those.

Also, how is that different from the existing onClearHighlight?
This occurs after we move the mouse away from the legend and is emitted by legend, onClearHighlight is called once we move the mouse away from the series in the graph.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HrvojeHemen, would it make sense to call the onClearHighlight also when the legend item highlight is lost?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is CoreLegend onItemHighlight and onClearHighlight are different than the new onLegendItemHighlight and onLegendItemHighlightExit?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HrvojeHemen, would it make sense to call the onClearHighlight also when the legend item highlight is lost?

I don't want to couple the events, if needed the user can call it themselves.
We should support both useCases where someone wants to also clearHighlight and doesn't, calling it on legendItemHighlightExit would stop this from being backwards compatible as existing graphs would start getting existing events emitted from a different source.

How is CoreLegend onItemHighlight and onClearHighlight are different than the new onLegendItemHighlight and onLegendItemHighlightExit?

Explained in another comment, https://github.com/cloudscape-design/chart-components/pull/131/files#r2598547502

/**
* Called when series/points visibility changes due to user interaction with legend or filter.
*/
Expand Down
Loading