diff --git a/.gitignore b/.gitignore
index 88d9d0ec..2fee74d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,5 @@ dist
/src/test-utils/selectors/**/*.ts
!/src/test-utils/selectors/index.ts
.DS_STORE
+.idea
+.vscode
diff --git a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx
index a2532ef2..e393f255 100644
--- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx
+++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx
@@ -1,8 +1,12 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+import { useState } from "react";
import { range } from "lodash";
+import Button from "@cloudscape-design/components/button";
+import SpaceBetween from "@cloudscape-design/components/space-between";
+
const addDays = (date: Date, days: number) => {
const result = new Date(date);
result.setDate(result.getDate() + days);
@@ -15,7 +19,7 @@ const subYears = (date: Date, years: number) => {
return result;
};
-import { CartesianChart } from "../../lib/components";
+import { CartesianChart, CartesianChartProps } from "../../lib/components";
import { dateFormatter } from "../common/formatters";
import { useChartSettings } from "../common/page-settings";
import { Page, PageSection } from "../common/templates";
@@ -77,6 +81,12 @@ export default function () {
+
+
+
+
+
+
);
}
@@ -688,3 +698,73 @@ function CategoryDatetime() {
/>
);
}
+
+const zoomSeriesData = range(0, 100).map((i) => ({
+ x: addDays(new Date(), i).getTime(),
+ y: Math.floor((pseudoRandom() + i / 50) * 100),
+}));
+
+const zoomSeries: CartesianChartProps.SeriesOptions[] = [
+ { type: "area", name: "Requests", data: zoomSeriesData },
+ {
+ type: "spline",
+ name: "Avg latency",
+ data: zoomSeriesData.map((d) => ({ x: d.x, y: d.y * 0.6 + Math.floor(pseudoRandom() * 20) })),
+ },
+ { type: "y-threshold", name: "SLA limit", value: 150 },
+];
+
+// Uncontrolled zoom: two accessible interaction methods are available out of the box —
+// (1) drag-to-zoom with the mouse, and (2) the "Zoom" button that enters a unified zoom mode.
+// In zoom mode a vertical cursor is moved with the mouse OR the Left/Right arrow keys (and UAP
+// direction buttons for touch); a single click / Enter / Space sets the start point, and a second
+// click / Enter / Space sets the end point to zoom — a single-pointer, no-modifier interaction that
+// satisfies WCAG 2.5.7 (Dragging Movements). Escape or "Exit zoom" cancels. The cursor position is
+// announced to screen readers as it moves. Zoom state is managed internally; a "Reset" button
+// appears once zoomed. While zoomed, a persistent affordance marks the active range: a vertical
+// boundary line at each edge with a subtle band tint between them. The "Zoom" button stays visible
+// alongside "Reset" while zoomed, so a narrower range can be selected without resetting first.
+function DatetimeLinearWithZoom() {
+ const { chartProps } = useChartSettings();
+ return (
+
+ );
+}
+
+// Controlled zoom: the consumer owns the zoom range via `zoomRange` + `onZoomRangeChange`.
+// Here we hide the built-in buttons and drive zoom entirely through custom UI and ref methods.
+function DatetimeLinearWithControlledZoom() {
+ const { chartProps } = useChartSettings();
+ const [zoomRange, setZoomRange] = useState(null);
+ const start = zoomSeriesData[20].x;
+ const end = zoomSeriesData[60].x;
+ return (
+
+
+
+
+
+ setZoomRange(detail.zoomRange)}
+ series={zoomSeries}
+ xAxis={{ title: "Time", type: "datetime", valueFormatter: dateFormatter }}
+ yAxis={{ title: "Count", type: "linear" }}
+ />
+
+ );
+}
diff --git a/pages/common/use-highcharts.ts b/pages/common/use-highcharts.ts
index 2f0bb0dd..cfc16bdd 100644
--- a/pages/common/use-highcharts.ts
+++ b/pages/common/use-highcharts.ts
@@ -12,7 +12,13 @@ export function useHighcharts({
solidgauge = false,
boost = false,
heatmap = false,
-}: { more?: boolean; xrange?: boolean; solidgauge?: boolean; boost?: boolean; heatmap?: boolean } = {}) {
+}: {
+ more?: boolean;
+ xrange?: boolean;
+ solidgauge?: boolean;
+ boost?: boolean;
+ heatmap?: boolean;
+} = {}) {
const [highcharts, setHighcharts] = useState(null);
useEffect(() => {
diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap
index 4b6863fc..36f812eb 100644
--- a/src/__tests__/__snapshots__/documenter.test.ts.snap
+++ b/src/__tests__/__snapshots__/documenter.test.ts.snap
@@ -21,8 +21,71 @@ exports[`definition for cartesian-chart matches the snapshot > cartesian-chart 1
"detailType": "{ visibleSeries: Array; }",
"name": "onVisibleSeriesChange",
},
+ {
+ "cancelable": false,
+ "description": "Called when the zoom range changes due to user interaction.
+In controlled mode, update \`zoomRange\` in this handler.",
+ "detailInlineType": {
+ "name": "CartesianChartProps.ZoomChangeDetail",
+ "properties": [
+ {
+ "inlineType": {
+ "name": "CartesianChartProps.ZoomRange",
+ "properties": [
+ {
+ "inlineType": {
+ "name": "{ startValue: number; endValue: number; }",
+ "properties": [
+ {
+ "name": "endValue",
+ "optional": false,
+ "type": "number",
+ },
+ {
+ "name": "startValue",
+ "optional": false,
+ "type": "number",
+ },
+ ],
+ "type": "object",
+ },
+ "name": "x",
+ "optional": true,
+ "type": "{ startValue: number; endValue: number; }",
+ },
+ ],
+ "type": "object",
+ },
+ "name": "zoomRange",
+ "optional": false,
+ "type": "CartesianChartProps.ZoomRange | null",
+ },
+ ],
+ "type": "object",
+ },
+ "detailType": "CartesianChartProps.ZoomChangeDetail",
+ "name": "onZoomRangeChange",
+ },
],
"functions": [
+ {
+ "description": "Enters zoom mode. In zoom mode, popovers are disabled and clicks on the chart set zoom range points.",
+ "name": "enterZoomMode",
+ "parameters": [],
+ "returnType": "void",
+ },
+ {
+ "description": "Exits zoom mode without applying zoom. Returns to the idle state.",
+ "name": "exitZoomMode",
+ "parameters": [],
+ "returnType": "void",
+ },
+ {
+ "description": "Resets the zoom to show the full data range.",
+ "name": "resetZoom",
+ "parameters": [],
+ "returnType": "void",
+ },
{
"description": "Controls series visibility and works with both controlled and uncontrolled visibility modes.",
"name": "setVisibleSeries",
@@ -142,11 +205,31 @@ Supported Highcharts versions: 12.",
"optional": true,
"type": "string",
},
+ {
+ "name": "enterZoomModeButtonAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "name": "enterZoomModeButtonText",
+ "optional": true,
+ "type": "string",
+ },
{
"name": "errorText",
"optional": true,
"type": "string",
},
+ {
+ "name": "exitZoomModeButtonAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "name": "exitZoomModeButtonText",
+ "optional": true,
+ "type": "string",
+ },
{
"name": "legendAriaLabel",
"optional": true,
@@ -162,6 +245,16 @@ Supported Highcharts versions: 12.",
"optional": true,
"type": "string",
},
+ {
+ "name": "resetZoomButtonAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "name": "resetZoomButtonText",
+ "optional": true,
+ "type": "string",
+ },
{
"name": "seriesFilterLabel",
"optional": true,
@@ -187,6 +280,109 @@ Supported Highcharts versions: 12.",
"optional": true,
"type": "string",
},
+ {
+ "name": "zoomControlsAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "name": "zoomCursorNextButtonAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "inlineType": {
+ "name": "(value: string) => string",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "string",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "zoomCursorPositionAnnouncementText",
+ "optional": true,
+ "type": "((value: string) => string)",
+ },
+ {
+ "name": "zoomCursorPreviousButtonAriaLabel",
+ "optional": true,
+ "type": "string",
+ },
+ {
+ "inlineType": {
+ "name": "(value: string) => string",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "string",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "zoomModeEnteredAnnouncementText",
+ "optional": true,
+ "type": "((value: string) => string)",
+ },
+ {
+ "inlineType": {
+ "name": "(startValue: string, endValue: string) => string",
+ "parameters": [
+ {
+ "name": "startValue",
+ "type": "string",
+ },
+ {
+ "name": "endValue",
+ "type": "string",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "zoomRangeChangeAnnouncementText",
+ "optional": true,
+ "type": "((startValue: string, endValue: string) => string)",
+ },
+ {
+ "inlineType": {
+ "name": "(startValue: string, endValue: string) => string",
+ "parameters": [
+ {
+ "name": "startValue",
+ "type": "string",
+ },
+ {
+ "name": "endValue",
+ "type": "string",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "zoomSelectionAnnouncementText",
+ "optional": true,
+ "type": "((startValue: string, endValue: string) => string)",
+ },
+ {
+ "inlineType": {
+ "name": "(value: string) => string",
+ "parameters": [
+ {
+ "name": "value",
+ "type": "string",
+ },
+ ],
+ "returnType": "string",
+ "type": "function",
+ },
+ "name": "zoomStartPointAnnouncementText",
+ "optional": true,
+ "type": "((value: string) => string)",
+ },
],
"type": "object",
},
@@ -620,6 +816,78 @@ applies to the tooltip points values.",
"optional": true,
"type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]",
},
+ {
+ "description": "Enables zoom functionality on the chart. When enabled, users can zoom into a range
+on the x-axis using three interaction methods:
+
+1. **Drag-to-zoom** — Click and drag on the chart plot area to select a range.
+2. **Zoom mode (click-based)** — A "Zoom" button appears in the top-right corner of the chart.
+ Clicking it enters zoom mode where popovers are disabled. The first click on the chart sets
+ the start point, and the second click sets the end point, zooming to that range. Direction
+ buttons appear at the selection line for fine-tuning. Press Escape or click "Exit zoom" to cancel.
+3. **Keyboard zoom** — While focused on a data point, hold Shift and press Left/Right arrow keys
+ to extend a selection range. Release Shift or press Enter to zoom. Press Escape to cancel.
+
+After zooming, a "Reset" button appears to restore the full data range.
+
+Supported options:
+* \`enabled\` (optional, boolean) — Enables zoom functionality.
+* \`hideButtons\` (optional, boolean) — Hides the built-in zoom control buttons (Zoom, Exit zoom, Reset).
+ Use this when providing custom zoom UI via ref methods (\`enterZoomMode\`, \`exitZoomMode\`, \`resetZoom\`).",
+ "inlineType": {
+ "name": "CartesianChartProps.ZoomOptions",
+ "properties": [
+ {
+ "name": "enabled",
+ "optional": true,
+ "type": "boolean",
+ },
+ {
+ "name": "hideButtons",
+ "optional": true,
+ "type": "boolean",
+ },
+ ],
+ "type": "object",
+ },
+ "name": "zoom",
+ "optional": true,
+ "type": "CartesianChartProps.ZoomOptions",
+ },
+ {
+ "description": "The current zoom range. When provided, the component operates in controlled mode.
+Pass \`null\` to reset to full range. When omitted, zoom state is managed internally.",
+ "inlineType": {
+ "name": "CartesianChartProps.ZoomRange",
+ "properties": [
+ {
+ "inlineType": {
+ "name": "{ startValue: number; endValue: number; }",
+ "properties": [
+ {
+ "name": "endValue",
+ "optional": false,
+ "type": "number",
+ },
+ {
+ "name": "startValue",
+ "optional": false,
+ "type": "number",
+ },
+ ],
+ "type": "object",
+ },
+ "name": "x",
+ "optional": true,
+ "type": "{ startValue: number; endValue: number; }",
+ },
+ ],
+ "type": "object",
+ },
+ "name": "zoomRange",
+ "optional": true,
+ "type": "CartesianChartProps.ZoomRange | null",
+ },
],
"regions": [
{
diff --git a/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx
new file mode 100644
index 00000000..f9c1cb8a
--- /dev/null
+++ b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx
@@ -0,0 +1,425 @@
+// 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 { afterEach, describe, expect, test, vi } from "vitest";
+
+import "@cloudscape-design/components/test-utils/dom";
+import { CartesianChartProps } from "../../../lib/components/cartesian-chart";
+import { getChart, ref, renderCartesianChart } from "./common";
+
+const series: CartesianChartProps.SeriesOptions[] = [
+ {
+ type: "line",
+ name: "Requests",
+ data: [
+ { x: 0, y: 10 },
+ { x: 1, y: 20 },
+ { x: 2, y: 30 },
+ { x: 3, y: 25 },
+ { x: 4, y: 40 },
+ ],
+ },
+];
+
+const defaultProps = {
+ highcharts,
+ series,
+ xAxis: { title: "X", type: "linear" as const, min: 0, max: 4 },
+ yAxis: { title: "Y", type: "linear" as const },
+};
+
+function getCurrentChart() {
+ // Target the most recently rendered chart: highcharts.charts accumulates entries across tests
+ // (disposed charts remain as holes), so the last defined entry is the one under test.
+ return [...highcharts.charts].reverse().find((c) => c)!;
+}
+
+function getXExtremes() {
+ const { min, max } = getCurrentChart().xAxis[0].getExtremes();
+ return { min, max };
+}
+
+// Reads the persistent zoom-range affordance drawn on the x-axis: the two boundary plot lines and
+// the band tint between them. Returns their ids and the band's from/to so tests can assert on them.
+function getZoomRangeOverlays() {
+ const xAxis = getCurrentChart().xAxis[0] as unknown as {
+ plotLinesAndBands: { id?: string; options?: { from?: number; to?: number } }[];
+ };
+ const items = xAxis.plotLinesAndBands ?? [];
+ const startLine = items.find((i) => i.id === "awsui-zoom-range-start");
+ const endLine = items.find((i) => i.id === "awsui-zoom-range-end");
+ const band = items.find((i) => i.id === "awsui-zoom-range");
+ return { startLine, endLine, band };
+}
+
+const onZoomRangeChange = vi.fn();
+
+afterEach(() => {
+ onZoomRangeChange.mockReset();
+});
+
+describe("CartesianChart: zoom", () => {
+ test("does not render zoom controls when zoom is not enabled", () => {
+ renderCartesianChart(defaultProps);
+ expect(getChart().findZoomButton()).toBe(null);
+ expect(getChart().findExitZoomButton()).toBe(null);
+ expect(getChart().findResetZoomButton()).toBe(null);
+ });
+
+ test("renders the Zoom button in idle state when zoom is enabled", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ expect(getChart().findZoomButton()).not.toBe(null);
+ expect(getChart().findExitZoomButton()).toBe(null);
+ expect(getChart().findResetZoomButton()).toBe(null);
+ });
+
+ test("does not render built-in buttons when hideButtons is set", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true, hideButtons: true } });
+ expect(getChart().findZoomButton()).toBe(null);
+ expect(getChart().findExitZoomButton()).toBe(null);
+ expect(getChart().findResetZoomButton()).toBe(null);
+ });
+
+ test("clicking Zoom enters zoom mode and shows the Exit zoom button", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ act(() => getChart().findZoomButton()!.click());
+ expect(getChart().findZoomButton()).toBe(null);
+ expect(getChart().findExitZoomButton()).not.toBe(null);
+ });
+
+ test("clicking Exit zoom returns to idle state", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ act(() => getChart().findZoomButton()!.click());
+ act(() => getChart().findExitZoomButton()!.click());
+ expect(getChart().findExitZoomButton()).toBe(null);
+ expect(getChart().findZoomButton()).not.toBe(null);
+ });
+
+ test("ref.enterZoomMode / exitZoomMode toggle zoom mode", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ act(() => ref.current!.enterZoomMode());
+ expect(getChart().findExitZoomButton()).not.toBe(null);
+ act(() => ref.current!.exitZoomMode());
+ expect(getChart().findZoomButton()).not.toBe(null);
+ });
+
+ test("ref.resetZoom clears the extremes and fires onZoomRangeChange with null", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ // Programmatically zoom via the chart, then reset.
+ act(() => {
+ const chart = highcharts.charts.find((c) => c)!;
+ chart.xAxis[0].setExtremes(1, 3);
+ });
+ act(() => ref.current!.resetZoom());
+ const { min, max } = getXExtremes();
+ expect(min).toBe(0);
+ expect(max).toBe(4);
+ expect(onZoomRangeChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { zoomRange: null } }));
+ });
+
+ test("controlled zoomRange applies extremes to the chart", () => {
+ const { rerender } = renderCartesianChart({
+ ...defaultProps,
+ zoom: { enabled: true },
+ zoomRange: null,
+ onZoomRangeChange,
+ });
+ rerender({
+ ...defaultProps,
+ zoom: { enabled: true },
+ zoomRange: { x: { startValue: 1, endValue: 3 } },
+ onZoomRangeChange,
+ });
+ const { min, max } = getXExtremes();
+ expect(min).toBe(1);
+ expect(max).toBe(3);
+ // In zoomed state the Reset button is shown.
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ });
+
+ // Dispatches a keydown from a real element inside the chart (the core handler calls
+ // target.closest, so a Document target would be rejected).
+ function pressChartKey(key: string) {
+ const chartEl = getChart().getElement();
+ const target = chartEl.querySelector('[role="application"]') ?? chartEl;
+ act(() => target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
+ }
+
+ // Drives a full keyboard zoom to (startValue, endValue) over the default data points (x=0..4),
+ // reaching the "zoomed" state through the real interaction (which sets extremes + the affordance).
+ function keyboardZoomTo(startValue: number, endValue: number) {
+ act(() => getChart().findZoomButton()!.click());
+ // Cursor starts at the first visible data point (x=0 initially). Step to the start point.
+ for (let i = 0; i < startValue; i++) {
+ pressChartKey("ArrowRight");
+ }
+ pressChartKey("Enter");
+ for (let i = 0; i < endValue - startValue; i++) {
+ pressChartKey("ArrowRight");
+ }
+ pressChartKey("Enter");
+ }
+
+ test("keeps the Zoom button visible alongside Reset while zoomed", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ keyboardZoomTo(1, 3);
+ // Both controls are available in the zoomed state.
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ expect(getChart().findZoomButton()).not.toBe(null);
+ expect(getChart().findExitZoomButton()).toBe(null);
+ });
+
+ test("clicking Zoom while zoomed re-enters zoom mode and suppresses the range affordance", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ keyboardZoomTo(1, 3);
+ // Affordance present in the settled zoomed state.
+ expect(getZoomRangeOverlays().band).toBeDefined();
+ // Re-enter zoom mode: Zoom is replaced by Exit zoom, and the persistent affordance is hidden
+ // so the new selection reads like a first-time zoom.
+ act(() => getChart().findZoomButton()!.click());
+ expect(getChart().findExitZoomButton()).not.toBe(null);
+ expect(getChart().findZoomButton()).toBe(null);
+ expect(getChart().findResetZoomButton()).toBe(null);
+ expect(getZoomRangeOverlays().band).toBeUndefined();
+ });
+
+ test("exiting a re-zoom returns to the zoomed state with the range intact", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ keyboardZoomTo(1, 3);
+ act(() => getChart().findZoomButton()!.click());
+ act(() => getChart().findExitZoomButton()!.click());
+ // Back to zoomed (not idle): Reset and Zoom shown, extremes preserved, affordance restored.
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ expect(getChart().findZoomButton()).not.toBe(null);
+ const { min, max } = getXExtremes();
+ expect(min).toBe(1);
+ expect(max).toBe(3);
+ expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 });
+ });
+
+ test("re-zooming to a narrower range updates the extremes and affordance", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ // First zoom to the range (1, 4) so the visible window contains points 1..4.
+ keyboardZoomTo(1, 4);
+ expect(getXExtremes()).toEqual({ min: 1, max: 4 });
+ // Re-enter zoom mode: the cursor starts at the first visible point (x=1) thanks to the
+ // in-view initial cursor. Select a narrower range (2, 3) within the current window.
+ act(() => getChart().findZoomButton()!.click());
+ pressChartKey("ArrowRight"); // x=2
+ pressChartKey("Enter"); // start
+ pressChartKey("ArrowRight"); // x=3
+ pressChartKey("Enter"); // end → zoom
+ expect(getXExtremes()).toEqual({ min: 2, max: 3 });
+ expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 2, to: 3 });
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ expect(getChart().findZoomButton()).not.toBe(null);
+ });
+
+ test("Escape during a re-zoom returns to the zoomed state without changing the range", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ keyboardZoomTo(1, 3);
+ onZoomRangeChange.mockClear();
+ act(() => getChart().findZoomButton()!.click());
+ pressChartKey("ArrowRight");
+ pressChartKey("Escape");
+ // Range unchanged and back in the zoomed state; no new zoom event fired by the cancel.
+ expect(getXExtremes()).toEqual({ min: 1, max: 3 });
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ expect(getChart().findZoomButton()).not.toBe(null);
+ expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 });
+ expect(onZoomRangeChange).not.toHaveBeenCalled();
+ });
+
+ test("controlled zoomRange=null resets the extremes", () => {
+ const { rerender } = renderCartesianChart({
+ ...defaultProps,
+ zoom: { enabled: true },
+ zoomRange: { x: { startValue: 1, endValue: 3 } },
+ onZoomRangeChange,
+ });
+ rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange });
+ const { min, max } = getXExtremes();
+ expect(min).toBe(0);
+ expect(max).toBe(4);
+ });
+
+ test("Escape exits zoom mode", () => {
+ const { wrapper } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ act(() => getChart().findZoomButton()!.click());
+ expect(getChart().findExitZoomButton()).not.toBe(null);
+ // Dispatch from a real element in the chart so the core keydown handler (which calls
+ // target.closest) receives a valid Element target.
+ const target = wrapper.getElement().querySelector('[role="application"]') ?? wrapper.getElement();
+ act(() => {
+ target.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ });
+ expect(getChart().findZoomButton()).not.toBe(null);
+ });
+
+ test("applies i18nStrings overrides to the zoom controls", () => {
+ renderCartesianChart({
+ ...defaultProps,
+ zoom: { enabled: true },
+ i18nStrings: {
+ enterZoomModeButtonText: "Vergrößern",
+ enterZoomModeButtonAriaLabel: "Zoom-Modus aktivieren",
+ },
+ });
+ const button = getChart().findZoomButton()!;
+ expect(button.getElement().textContent).toContain("Vergrößern");
+ expect(button.getElement()).toHaveAttribute("aria-label", "Zoom-Modus aktivieren");
+ });
+
+ test("draws the zoom-range boundary lines and band when zoomed via ref", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ // Not zoomed yet — no affordance.
+ expect(getZoomRangeOverlays().band).toBeUndefined();
+ act(() => ref.current!.enterZoomMode());
+ // Keyboard-select a range: x=1 to x=3.
+ const chartEl = getChart().getElement();
+ const target = chartEl.querySelector('[role="application"]') ?? chartEl;
+ const press = (key: string) =>
+ act(() => target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
+ press("ArrowRight");
+ press("Enter");
+ press("ArrowRight");
+ press("ArrowRight");
+ press("Enter");
+
+ const { startLine, endLine, band } = getZoomRangeOverlays();
+ expect(startLine).toBeDefined();
+ expect(endLine).toBeDefined();
+ expect(band?.options).toMatchObject({ from: 1, to: 3 });
+ });
+
+ test("clears the zoom-range affordance when zoom is reset", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ act(() => {
+ const chart = highcharts.charts.find((c) => c)!;
+ chart.xAxis[0].setExtremes(1, 3);
+ });
+ act(() => ref.current!.resetZoom());
+ const { startLine, endLine, band } = getZoomRangeOverlays();
+ expect(startLine).toBeUndefined();
+ expect(endLine).toBeUndefined();
+ expect(band).toBeUndefined();
+ });
+
+ test("controlled zoomRange draws the boundary affordance", () => {
+ const { rerender } = renderCartesianChart({
+ ...defaultProps,
+ zoom: { enabled: true },
+ zoomRange: null,
+ onZoomRangeChange,
+ });
+ expect(getZoomRangeOverlays().band).toBeUndefined();
+ rerender({
+ ...defaultProps,
+ zoom: { enabled: true },
+ zoomRange: { x: { startValue: 1, endValue: 3 } },
+ onZoomRangeChange,
+ });
+ expect(getZoomRangeOverlays().band?.options).toMatchObject({ from: 1, to: 3 });
+ // Resetting via controlled null clears the affordance.
+ rerender({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange });
+ expect(getZoomRangeOverlays().band).toBeUndefined();
+ });
+
+ test("exposes a labelled zoom controls region", () => {
+ const { wrapper } = renderCartesianChart({
+ ...defaultProps,
+ zoom: { enabled: true },
+ i18nStrings: { zoomControlsAriaLabel: "Custom zoom region" },
+ });
+ const el = wrapper.getElement().querySelector('[role="region"][aria-label="Custom zoom region"]');
+ expect(el).not.toBe(null);
+ });
+});
+
+describe("CartesianChart: zoom keyboard", () => {
+ // Dispatch keydown from a real element inside the chart so the core keydown handler (which calls
+ // target.closest) receives a valid Element target instead of the Document.
+ function pressKey(key: string) {
+ const chartEl = getChart().getElement();
+ const target = chartEl.querySelector('[role="application"]') ?? chartEl;
+ act(() => {
+ target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
+ });
+ }
+
+ test("arrow keys move the cursor and Enter sets start then end to zoom", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ // Enter zoom mode — cursor starts at the first data point (x=0).
+ act(() => getChart().findZoomButton()!.click());
+
+ // Move the cursor right to x=1 and set the start point.
+ pressKey("ArrowRight");
+ pressKey("Enter");
+ // Move the cursor right to x=3 and set the end point → zoom applies.
+ pressKey("ArrowRight");
+ pressKey("ArrowRight");
+ pressKey("Enter");
+
+ const { min, max } = getXExtremes();
+ expect(min).toBe(1);
+ expect(max).toBe(3);
+ expect(onZoomRangeChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }),
+ );
+ // After zooming, the Reset button is shown.
+ expect(getChart().findResetZoomButton()).not.toBe(null);
+ });
+
+ test("Space also sets zoom points", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ act(() => getChart().findZoomButton()!.click());
+ pressKey("ArrowRight"); // cursor at x=1
+ pressKey(" "); // set start
+ pressKey("ArrowRight"); // cursor at x=2
+ pressKey(" "); // set end → zoom
+ const { min, max } = getXExtremes();
+ expect(min).toBe(1);
+ expect(max).toBe(2);
+ });
+
+ test("Escape cancels an in-progress keyboard selection without zooming", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ act(() => getChart().findZoomButton()!.click());
+ pressKey("ArrowRight");
+ pressKey("Enter"); // start point set, now selecting
+ pressKey("Escape"); // cancel
+ const { min, max } = getXExtremes();
+ expect(min).toBe(0);
+ expect(max).toBe(4);
+ // Back to idle: the Zoom button is shown again.
+ expect(getChart().findZoomButton()).not.toBe(null);
+ expect(onZoomRangeChange).not.toHaveBeenCalled();
+ });
+
+ test("renders UAP direction buttons in zoom mode", () => {
+ const { wrapper } = renderCartesianChart({ ...defaultProps, zoom: { enabled: true } });
+ act(() => getChart().findZoomButton()!.click());
+ const prev = document.querySelector('button[aria-label="Move zoom cursor left"]');
+ const next = document.querySelector('button[aria-label="Move zoom cursor right"]');
+ expect(prev).not.toBe(null);
+ expect(next).not.toBe(null);
+ void wrapper;
+ });
+
+ test("direction buttons move the cursor", () => {
+ renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange });
+ act(() => getChart().findZoomButton()!.click());
+ const next = document.querySelector('button[aria-label="Move zoom cursor right"]') as HTMLButtonElement;
+ // Move cursor to x=1 via the button, set start via keyboard.
+ act(() => next.click());
+ pressKey("Enter");
+ // Move cursor to x=2 via the button, set end → zoom.
+ act(() => next.click());
+ pressKey("Enter");
+ const { min, max } = getXExtremes();
+ expect(min).toBe(1);
+ expect(max).toBe(2);
+ });
+});
diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx
index aaa50670..73f72d8c 100644
--- a/src/cartesian-chart/chart-cartesian-internal.tsx
+++ b/src/cartesian-chart/chart-cartesian-internal.tsx
@@ -1,14 +1,25 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import { forwardRef, useImperativeHandle, useRef, useState } from "react";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { useControllableState } from "@cloudscape-design/component-toolkit";
+import Button from "@cloudscape-design/components/button";
+import LiveRegion from "@cloudscape-design/components/live-region";
+import SpaceBetween from "@cloudscape-design/components/space-between";
+import {
+ colorBackgroundItemSelected,
+ colorBorderItemFocused,
+ colorBorderItemSelected,
+ colorChartsLineTick,
+} from "@cloudscape-design/design-tokens";
import { InternalCoreChart } from "../core/chart-core";
import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces";
import { getOptionsId, isXThreshold } from "../core/utils";
import { InternalBaseComponentProps } from "../internal/base-component/use-base-component";
+import DirectionButton from "../internal/components/drag-handle-wrapper/direction-button";
+import PortalOverlay from "../internal/components/drag-handle-wrapper/portal-overlay";
import { fireNonCancelableEvent } from "../internal/events";
import { castArray, SomeRequired } from "../internal/utils/utils";
import { transformCartesianSeries } from "./chart-series-cartesian";
@@ -21,19 +32,192 @@ interface InternalCartesianChartProps extends InternalBaseComponentProps, Cartes
legend: SomeRequired;
}
+const ZOOM_SELECTION_BAND_ID = "awsui-zoom-selection";
+const ZOOM_ANCHOR_LINE_ID = "awsui-zoom-anchor";
+const ZOOM_CURSOR_LINE_ID = "awsui-zoom-cursor";
+// Overlays that mark the boundaries of the currently zoomed range: two vertical lines at the
+// zoomed min/max plus a subtle band tint between them, shown while the chart is zoomed.
+const ZOOM_RANGE_BAND_ID = "awsui-zoom-range";
+const ZOOM_RANGE_START_LINE_ID = "awsui-zoom-range-start";
+const ZOOM_RANGE_END_LINE_ID = "awsui-zoom-range-end";
+
+// Zoom mode state machine:
+// "idle" — normal (unzoomed) chart, "Zoom" button visible.
+// "zoomMode" — zoom mode entered, "Exit zoom" button visible, popovers disabled. A vertical cursor
+// follows the mouse or arrow keys, waiting for the first point (click / Enter / Space).
+// "selecting" — first point set, highlight band spans from the anchor to the cursor, waiting for the
+// second point (click / Enter / Space) to complete the zoom.
+// "zoomed" — chart is zoomed, "Zoom" and "Reset" buttons visible. The "Zoom" button stays available
+// so users can refine the current zoom by selecting a new range without resetting first; doing so
+// re-enters "zoomMode" and exiting (Escape / "Exit zoom") returns to "zoomed" with the range intact.
+// Both the mouse and the keyboard drive the same cursor, so the two interaction methods are unified
+// (matching the approved design) and each is a WCAG-compliant single-pointer / keyboard equivalent.
+// Whether the chart is actually zoomed is tracked separately by `zoomedExtremes`, since zoom mode can
+// now be entered on top of an existing zoom.
+type ZoomModeState = "idle" | "zoomMode" | "selecting" | "zoomed";
+
+// Fill for the keyboard/click zoom selection band. This matches the fill Highcharts applies to its
+// native drag-to-zoom selection marker (highlightColor80 at 0.25 opacity), so keyboard and
+// click-based selection look identical to standard mouse dragging.
+const ZOOM_SELECTION_FILL = "rgba(51, 78, 255, 0.25)";
+
+// Draws (or redraws) the zoom selection highlight band between two x-axis values. It intentionally
+// mirrors the native Highcharts drag-selection marker: a translucent fill with no border.
+function drawSelectionBand(
+ xAxis: { removePlotBand(id: string): void; addPlotBand(options: object): void },
+ from: number,
+ to: number,
+): void {
+ xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID);
+ xAxis.addPlotBand({
+ id: ZOOM_SELECTION_BAND_ID,
+ from: Math.min(from, to),
+ to: Math.max(from, to),
+ color: ZOOM_SELECTION_FILL,
+ zIndex: 4,
+ });
+}
+
+// Draws (or redraws) the vertical zoom cursor line at the given x-axis value. The cursor is the
+// shared focus indicator that both the mouse and the keyboard move around while in zoom mode. It
+// reuses the same look as the standard chart hover cursor (see Styles.cursorLine).
+function drawCursorLine(
+ xAxis: { removePlotLine(id: string): void; addPlotLine(options: object): void },
+ value: number,
+): void {
+ xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID);
+ xAxis.addPlotLine({
+ id: ZOOM_CURSOR_LINE_ID,
+ value,
+ color: colorChartsLineTick,
+ width: 1,
+ zIndex: 6,
+ });
+}
+
+// Type covering the subset of the Highcharts x-axis used to draw the zoom overlays.
+interface ZoomOverlayAxis {
+ removePlotBand(id: string): void;
+ addPlotBand(options: object): void;
+ removePlotLine(id: string): void;
+ addPlotLine(options: object): void;
+}
+
+// Fill for the zoom-range affordance band. This is the subtle selected-item background tint, laid
+// under the plot content between the two boundary lines so the zoomed region reads as "selected".
+const ZOOM_RANGE_FILL = colorBackgroundItemSelected;
+
+// Draws (or redraws) the persistent zoom-range affordance: a subtle band tint between the zoomed
+// min/max plus a vertical boundary line at each edge. Shown while the chart is zoomed to make the
+// active range explicit. Boundary lines reuse the selected-item border token to match the tint.
+function drawZoomRangeBoundaries(xAxis: ZoomOverlayAxis, min: number, max: number): void {
+ clearZoomRangeBoundaries(xAxis);
+ const from = Math.min(min, max);
+ const to = Math.max(min, max);
+ xAxis.addPlotBand({ id: ZOOM_RANGE_BAND_ID, from, to, color: ZOOM_RANGE_FILL, zIndex: 0 });
+ for (const [id, value] of [
+ [ZOOM_RANGE_START_LINE_ID, from],
+ [ZOOM_RANGE_END_LINE_ID, to],
+ ] as const) {
+ xAxis.addPlotLine({ id, value, color: colorBorderItemSelected, width: 1, zIndex: 3 });
+ }
+}
+
+// Removes the zoom-range affordance overlays. Safe to call when they are not present.
+function clearZoomRangeBoundaries(xAxis: ZoomOverlayAxis): void {
+ xAxis.removePlotBand(ZOOM_RANGE_BAND_ID);
+ xAxis.removePlotLine(ZOOM_RANGE_START_LINE_ID);
+ xAxis.removePlotLine(ZOOM_RANGE_END_LINE_ID);
+}
+
+// Returns the x value from `values` nearest to `target`, moved one step in `direction` when a step
+// is requested. Used to move the zoom cursor between real data points via arrow keys / UAP buttons.
+function stepXValue(values: number[], target: number, direction: -1 | 1): number {
+ if (values.length === 0) {
+ return target;
+ }
+ // Index of the value at or just past the target.
+ let idx = values.findIndex((x) => x >= target);
+ if (idx === -1) {
+ idx = values.length - 1;
+ }
+ // If the target sits exactly on a data point, step to the neighbour; otherwise snap to the value
+ // on the side we are moving toward.
+ if (values[idx] === target) {
+ idx += direction;
+ } else if (direction === -1) {
+ idx -= 1;
+ }
+ return values[Math.max(0, Math.min(values.length - 1, idx))];
+}
+
+// Highcharts' Pointer.normalize maps a DOM mouse event to chart-relative coordinates. It is not
+// part of the public typings, so we access it through a narrow structural type.
+interface PointerWithNormalize {
+ normalize?: (e: MouseEvent) => { chartX: number; chartY: number };
+}
+
+function normalizePointerEvent(
+ chart: { pointer: unknown },
+ e: MouseEvent,
+): { chartX: number; chartY: number } | undefined {
+ return (chart.pointer as PointerWithNormalize).normalize?.(e);
+}
+
+function getChartXValues(chart: {
+ series: readonly { visible: boolean; points?: readonly { x: number }[] }[];
+}): number[] {
+ const xSet = new Set();
+ for (const s of chart.series) {
+ if (!s.visible) {
+ continue;
+ }
+ for (const p of s.points ?? []) {
+ xSet.add(p.x);
+ }
+ }
+ return Array.from(xSet).sort((a, b) => a - b);
+}
+
export const InternalCartesianChart = forwardRef(
({ tooltip, ...props }: InternalCartesianChartProps, ref: React.Ref) => {
const apiRef = useRef(null);
+ const [chartReady, setChartReady] = useState(false);
+ const [zoomMode, setZoomMode] = useState("idle");
+ const [zoomAnchor, setZoomAnchor] = useState(null);
+ // Extremes of the currently applied zoom, used to draw the persistent boundary affordance
+ // (two vertical lines + band tint). Null whenever the chart is not zoomed. Mirrored to a ref so
+ // event handlers and callbacks can read the latest value without being re-created each change.
+ const [zoomedExtremes, setZoomedExtremes] = useState<{ min: number; max: number } | null>(null);
+ const zoomedExtremesRef = useRef<{ min: number; max: number } | null>(null);
+ zoomedExtremesRef.current = zoomedExtremes;
+ const zoomAnchorRef = useRef(null);
+ zoomAnchorRef.current = zoomAnchor;
+
+ const [liveAnnouncement, setLiveAnnouncement] = useState("");
+
+ // Ref to the reset button so we can move focus to it after a zoom is applied, keeping
+ // keyboard users oriented on the newly available control.
+ const resetButtonRef = useRef<{ focus(): void } | null>(null);
+ // Tracks the previous zoom-mode state so focus is only moved on the transition into "zoomed".
+ const prevZoomModeRef = useRef("idle");
+
+ // Position of the vertical zoom cursor (x-axis value). Shared by mouse and keyboard. Rendered as
+ // React state so the UAP direction buttons re-position, and mirrored to a ref for event handlers.
+ const [cursorX, setCursorX] = useState(null);
+ const cursorXRef = useRef(null);
+ cursorXRef.current = cursorX;
+ // Invisible element the UAP direction buttons anchor to; positioned at the cursor line.
+ const cursorTrackRef = useRef(null);
+ // The direction buttons are shown whenever we are in zoom mode with a placed cursor.
+ const inZoomSelection = (zoomMode === "zoomMode" || zoomMode === "selecting") && cursorX !== null;
- // When visibleSeries and onVisibleSeriesChange are provided - the series visibility can be controlled from the outside.
- // Otherwise - the component handles series visibility using its internal state.
useControllableState(props.visibleSeries, props.onVisibleSeriesChange, undefined, {
componentName: "CartesianChart",
propertyName: "visibleSeries",
changeHandlerName: "onVisibleSeriesChange",
});
const allSeriesIds = props.series.map((s) => getOptionsId(s));
- // We keep local visible series state to compute threshold series data, that depends on series visibility.
const [visibleSeriesLocal, setVisibleSeriesLocal] = useState(props.visibleSeries ?? allSeriesIds);
const visibleSeriesState = props.visibleSeries ?? visibleSeriesLocal;
const onVisibleSeriesChange: CoreChartProps["onVisibleItemsChange"] = ({ detail: { items } }) => {
@@ -45,17 +229,352 @@ export const InternalCartesianChart = forwardRef(
}
};
- // We convert cartesian tooltip options to the core chart's getTooltipContent callback,
- // ensuring no internal types are exposed to the consumer-defined render functions.
+ // i18n with defaults. Memoized so its identity is stable across renders, keeping the zoom
+ // callbacks (which depend on the announcement formatters) from being recreated each render.
+ const i18nStrings = props.i18nStrings;
+ const i18n = useMemo(
+ () => ({
+ enterZoomModeButtonText: i18nStrings?.enterZoomModeButtonText ?? "Zoom",
+ enterZoomModeButtonAriaLabel: i18nStrings?.enterZoomModeButtonAriaLabel ?? "Enter zoom mode",
+ exitZoomModeButtonText: i18nStrings?.exitZoomModeButtonText ?? "Exit zoom",
+ exitZoomModeButtonAriaLabel: i18nStrings?.exitZoomModeButtonAriaLabel ?? "Exit zoom mode",
+ resetZoomButtonText: i18nStrings?.resetZoomButtonText ?? "Reset",
+ resetZoomButtonAriaLabel: i18nStrings?.resetZoomButtonAriaLabel ?? "Reset zoom to show full data range",
+ zoomControlsAriaLabel: i18nStrings?.zoomControlsAriaLabel ?? "Chart zoom controls",
+ zoomCursorPreviousButtonAriaLabel: i18nStrings?.zoomCursorPreviousButtonAriaLabel ?? "Move zoom cursor left",
+ zoomCursorNextButtonAriaLabel: i18nStrings?.zoomCursorNextButtonAriaLabel ?? "Move zoom cursor right",
+ zoomModeEnteredAnnouncementText:
+ i18nStrings?.zoomModeEnteredAnnouncementText ??
+ ((value: string) => `Zoom mode. Cursor at ${value}. Use arrow keys to move, Enter to set the start point.`),
+ zoomCursorPositionAnnouncementText:
+ i18nStrings?.zoomCursorPositionAnnouncementText ?? ((value: string) => value),
+ zoomStartPointAnnouncementText:
+ i18nStrings?.zoomStartPointAnnouncementText ??
+ ((value: string) => `Start point set at ${value}. Move the cursor and set the end point to zoom.`),
+ zoomRangeChangeAnnouncementText:
+ i18nStrings?.zoomRangeChangeAnnouncementText ??
+ ((startValue: string, endValue: string) => `Zoomed from ${startValue} to ${endValue}`),
+ zoomSelectionAnnouncementText:
+ i18nStrings?.zoomSelectionAnnouncementText ??
+ ((startValue: string, endValue: string) => `Selecting zoom range from ${startValue} to ${endValue}`),
+ }),
+ [i18nStrings],
+ );
+
+ const zoomEnabled = !!props.zoom?.enabled;
+
+ // Formats an x-axis value for screen reader announcements, using the axis value formatter when provided,
+ // then falling back to locale-aware datetime formatting, and finally to the raw string value.
+ const formatXValue = useCallback(
+ (value: number) => {
+ const xAxisOptions = castArray(props.xAxis)?.[0];
+ if (xAxisOptions?.valueFormatter) {
+ return xAxisOptions.valueFormatter(value);
+ }
+ if (xAxisOptions?.type === "datetime") {
+ return new Date(value).toLocaleString();
+ }
+ return String(value);
+ },
+ [props.xAxis],
+ );
+
+ const applyZoom = useCallback(
+ (startValue: number, endValue: number) => {
+ const min = Math.min(startValue, endValue);
+ const max = Math.max(startValue, endValue);
+ // In controlled mode the consumer owns the extremes: emit the event and let the
+ // zoomRange prop drive the chart. In uncontrolled mode we apply the extremes directly.
+ const xAxis = apiRef.current?.chart.xAxis[0];
+ // Remove the zoom-mode overlays before applying the zoom.
+ if (xAxis) {
+ xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID);
+ xAxis.removePlotLine(ZOOM_ANCHOR_LINE_ID);
+ xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID);
+ }
+ if (props.zoomRange === undefined) {
+ xAxis?.setExtremes(min, max);
+ setZoomMode("zoomed");
+ setZoomedExtremes({ min, max });
+ }
+ setZoomAnchor(null);
+ setCursorX(null);
+ setLiveAnnouncement(i18n.zoomRangeChangeAnnouncementText(formatXValue(min), formatXValue(max)));
+ fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: { x: { startValue: min, endValue: max } } });
+ },
+ [formatXValue, i18n, props.onZoomRangeChange, props.zoomRange],
+ );
+
+ const resetZoom = useCallback(() => {
+ if (props.zoomRange === undefined) {
+ apiRef.current?.chart.xAxis[0].setExtremes(undefined, undefined);
+ setZoomMode("idle");
+ setZoomedExtremes(null);
+ }
+ setZoomAnchor(null);
+ fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: null });
+ }, [props.onZoomRangeChange, props.zoomRange]);
+
+ // Focus management: when the chart transitions into the zoomed state, move focus to the
+ // "Reset" button so keyboard and screen reader users land on the control that just appeared.
+ useEffect(() => {
+ if (prevZoomModeRef.current !== "zoomed" && zoomMode === "zoomed" && !props.zoom?.hideButtons) {
+ resetButtonRef.current?.focus();
+ }
+ prevZoomModeRef.current = zoomMode;
+ }, [zoomMode, props.zoom?.hideButtons]);
+
+ // Controlled zoom range: apply external zoomRange prop to the chart.
+ useEffect(() => {
+ if (!chartReady || !apiRef.current) {
+ return;
+ }
+ const xAxis = apiRef.current.chart.xAxis[0];
+ if (props.zoomRange === undefined) {
+ // Uncontrolled mode — do nothing.
+ return;
+ }
+ if (props.zoomRange === null) {
+ // Reset to full range.
+ xAxis.setExtremes(undefined, undefined);
+ setZoomMode("idle");
+ setZoomedExtremes(null);
+ } else if (props.zoomRange.x) {
+ // Apply the controlled range.
+ const { startValue, endValue } = props.zoomRange.x;
+ xAxis.setExtremes(startValue, endValue);
+ setZoomMode("zoomed");
+ setZoomedExtremes({ min: Math.min(startValue, endValue), max: Math.max(startValue, endValue) });
+ }
+ }, [chartReady, props.zoomRange]);
+
+ // Keep the cursor-tracking element (and therefore the UAP direction buttons) aligned with the
+ // cursor line at the bottom of the plot area.
+ useEffect(() => {
+ const chart = apiRef.current?.chart;
+ if (!chart || !cursorTrackRef.current || cursorX === null || !inZoomSelection) {
+ return;
+ }
+ const pixelX = chart.xAxis[0].toPixels(cursorX, false);
+ cursorTrackRef.current.style.insetInlineStart = `${pixelX}px`;
+ cursorTrackRef.current.style.insetBlockStart = `${chart.plotTop + chart.plotHeight}px`;
+ }, [cursorX, inZoomSelection]);
+
+ const enterZoomMode = useCallback(() => {
+ const chart = apiRef.current?.chart;
+ // Start the cursor at the first data point so keyboard users have an immediate, visible anchor
+ // without needing to hover first. When re-entering zoom mode on an already-zoomed chart, only
+ // consider points within the current visible extremes so the cursor starts on-screen.
+ const xValues = chart ? getChartXValues(chart) : [];
+ const extremes = chart?.xAxis[0].getExtremes();
+ const visibleXValues =
+ extremes && extremes.min !== undefined && extremes.max !== undefined
+ ? xValues.filter((x) => x >= extremes.min && x <= extremes.max)
+ : xValues;
+ const initialX = visibleXValues[0] ?? extremes?.min ?? null;
+ setZoomMode("zoomMode");
+ setZoomAnchor(null);
+ setCursorX(initialX);
+ setLiveAnnouncement(initialX !== null ? i18n.zoomModeEnteredAnnouncementText(formatXValue(initialX)) : "");
+ }, [formatXValue, i18n]);
+
+ const exitZoomMode = useCallback(() => {
+ // Exiting zoom mode cancels the in-progress selection but preserves any zoom already applied:
+ // return to "zoomed" when extremes are still in effect, otherwise back to "idle".
+ setZoomMode(zoomedExtremesRef.current ? "zoomed" : "idle");
+ setZoomAnchor(null);
+ setCursorX(null);
+ setLiveAnnouncement("");
+ }, []);
+
+ // Moves the zoom cursor to an absolute x value (shared by mouse hover, arrow keys, and UAP
+ // buttons). Only updates state — the cursor line and selection band are drawn declaratively by an
+ // effect, so they survive Highcharts re-renders (e.g. when zoom mode toggles the tooltip).
+ const moveCursorTo = useCallback(
+ (value: number, options: { announce?: boolean } = {}) => {
+ setCursorX(value);
+ if (options.announce) {
+ setLiveAnnouncement(
+ zoomAnchorRef.current !== null
+ ? i18n.zoomSelectionAnnouncementText(
+ formatXValue(Math.min(zoomAnchorRef.current, value)),
+ formatXValue(Math.max(zoomAnchorRef.current, value)),
+ )
+ : i18n.zoomCursorPositionAnnouncementText(formatXValue(value)),
+ );
+ }
+ },
+ [formatXValue, i18n],
+ );
+
+ // Steps the zoom cursor one data point left (-1) or right (+1) via arrow keys / UAP buttons.
+ const stepCursor = useCallback(
+ (direction: -1 | 1) => {
+ const chart = apiRef.current?.chart;
+ if (!chart || cursorXRef.current === null) {
+ return;
+ }
+ const xValues = getChartXValues(chart);
+ const next = stepXValue(xValues, cursorXRef.current, direction);
+ moveCursorTo(next, { announce: true });
+ },
+ [moveCursorTo],
+ );
+
+ // Sets the current zoom point: the first call sets the anchor, the second applies the zoom.
+ // An explicit value can be passed (e.g. from a click) since state updates are not yet flushed.
+ const commitPoint = useCallback(
+ (explicitValue?: number) => {
+ const value = explicitValue ?? cursorXRef.current;
+ if (value === null) {
+ return;
+ }
+ if (zoomAnchorRef.current === null) {
+ // First point → set the anchor.
+ setZoomAnchor(value);
+ setZoomMode("selecting");
+ setLiveAnnouncement(i18n.zoomStartPointAnnouncementText(formatXValue(value)));
+ } else if (value !== zoomAnchorRef.current) {
+ // Second point → apply the zoom.
+ applyZoom(zoomAnchorRef.current, value);
+ }
+ },
+ [applyZoom, formatXValue, i18n],
+ );
+
+ // Declaratively draw the zoom-mode overlays (cursor line, anchor line, selection band) from
+ // state. Runs after every render so the overlays are re-applied whenever Highcharts updates the
+ // chart (which clears imperatively-added plot lines/bands).
+ useEffect(() => {
+ const xAxis = apiRef.current?.chart.xAxis[0];
+ if (!xAxis) {
+ return;
+ }
+ xAxis.removePlotLine(ZOOM_CURSOR_LINE_ID);
+ xAxis.removePlotLine(ZOOM_ANCHOR_LINE_ID);
+ xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID);
+ if (zoomMode !== "zoomMode" && zoomMode !== "selecting") {
+ return;
+ }
+ if (zoomAnchor !== null) {
+ xAxis.addPlotLine({
+ id: ZOOM_ANCHOR_LINE_ID,
+ value: zoomAnchor,
+ color: colorBorderItemFocused,
+ width: 2,
+ dashStyle: "Dash",
+ zIndex: 5,
+ });
+ }
+ if (cursorX !== null) {
+ drawCursorLine(xAxis, cursorX);
+ if (zoomAnchor !== null) {
+ drawSelectionBand(xAxis, zoomAnchor, cursorX);
+ }
+ }
+ }, [zoomMode, cursorX, zoomAnchor, chartReady]);
+
+ // Declaratively draw the persistent zoom-range affordance (boundary lines + band tint) from
+ // state. Like the cursor-overlay effect above, it runs after every render so the overlays are
+ // re-applied whenever Highcharts updates the chart (which clears imperatively-added lines/bands).
+ // While a new selection is in progress (zoom mode re-entered on top of an existing zoom) the
+ // affordance is suppressed so the selection cursor/band read the same as a first-time zoom; it is
+ // restored when the interaction ends (see exitZoomMode returning to "zoomed").
+ const isSelectingZoom = zoomMode === "zoomMode" || zoomMode === "selecting";
+ useEffect(() => {
+ const xAxis = apiRef.current?.chart.xAxis[0];
+ if (!xAxis) {
+ return;
+ }
+ if (zoomedExtremes && !isSelectingZoom) {
+ drawZoomRangeBoundaries(xAxis, zoomedExtremes.min, zoomedExtremes.max);
+ } else {
+ clearZoomRangeBoundaries(xAxis);
+ }
+ }, [zoomedExtremes, isSelectingZoom, chartReady]);
+
+ // Zoom mode: click handling and mouse tracking on the chart container.
+ useEffect(() => {
+ if (!zoomEnabled || !chartReady || !apiRef.current) {
+ return;
+ }
+ if (zoomMode !== "zoomMode" && zoomMode !== "selecting") {
+ return;
+ }
+
+ const chart = apiRef.current.chart;
+ const xAxis = chart.xAxis[0];
+ const container = chart.container;
+
+ const isInsidePlotX = (chartX: number) => chartX >= chart.plotLeft && chartX <= chart.plotLeft + chart.plotWidth;
+
+ // Mouse move: the cursor (and, while selecting, the highlight band) follows the pointer.
+ const onMouseMove = (e: MouseEvent) => {
+ const normalized = normalizePointerEvent(chart, e);
+ if (!normalized || !isInsidePlotX(normalized.chartX)) {
+ return;
+ }
+ moveCursorTo(xAxis.toValue(normalized.chartX, false));
+ };
+
+ // Click: set the start or end point at the pointer position.
+ const onClick = (e: MouseEvent) => {
+ const normalized = normalizePointerEvent(chart, e);
+ if (!normalized) {
+ return;
+ }
+ const { chartX, chartY } = normalized;
+ const insideY = chartY >= chart.plotTop && chartY <= chart.plotTop + chart.plotHeight;
+ if (!isInsidePlotX(chartX) || !insideY) {
+ return;
+ }
+ const value = xAxis.toValue(chartX, false);
+ moveCursorTo(value);
+ commitPoint(value);
+ };
+
+ // Keyboard: arrows move the cursor, Enter/Space set a point, Escape cancels zoom mode.
+ const onKeyDown = (e: KeyboardEvent) => {
+ switch (e.key) {
+ case "ArrowRight":
+ e.preventDefault();
+ stepCursor(1);
+ break;
+ case "ArrowLeft":
+ e.preventDefault();
+ stepCursor(-1);
+ break;
+ case "Enter":
+ case " ":
+ e.preventDefault();
+ commitPoint();
+ break;
+ case "Escape":
+ e.preventDefault();
+ exitZoomMode();
+ break;
+ }
+ };
+
+ // Delay the click listener so the button click that entered zoom mode doesn't set a point.
+ const timeoutId = setTimeout(() => {
+ container.addEventListener("click", onClick);
+ }, 0);
+ container.addEventListener("mousemove", onMouseMove);
+ document.addEventListener("keydown", onKeyDown);
+
+ return () => {
+ clearTimeout(timeoutId);
+ container.removeEventListener("click", onClick);
+ container.removeEventListener("mousemove", onMouseMove);
+ document.removeEventListener("keydown", onKeyDown);
+ };
+ }, [zoomEnabled, chartReady, zoomMode, moveCursorTo, stepCursor, commitPoint, exitZoomMode]);
+
+ // Tooltip content transformation.
const getTooltipContent: CoreChartProps["getTooltipContent"] = () => {
- // We use point.series.userOptions to get the series options that were passed down to Highcharts,
- // assuming Highcharts makes no modifications for those. These options are not referentially equal
- // to the ones we get from the consumer due to the internal validation/transformation we run on them.
- // See: https://api.highcharts.com/class-reference/Highcharts.Chart#userOptions.
const transformItem = (item: CoreChartProps.TooltipContentItem): CartesianChartProps.TooltipPointItem => {
const userOptions = item.point.series.userOptions as NonErrorBarSeriesOptions;
- // Restore original threshold type from custom metadata, since transformCartesianSeries
- // replaces "x-threshold" and "y-threshold" with "line" for Highcharts compatibility.
const originalType = item.point.series.userOptions.custom?.awsui?.type;
const series = originalType
? ({ ...userOptions, type: originalType } as NonErrorBarSeriesOptions)
@@ -74,15 +593,10 @@ export const InternalCartesianChart = forwardRef(
};
const transformSeriesProps = (
props: CoreChartProps.TooltipPointProps,
- ): CartesianChartProps.TooltipPointRenderProps => ({
- item: transformItem(props.item),
- });
+ ): CartesianChartProps.TooltipPointRenderProps => ({ item: transformItem(props.item) });
const transformSlotProps = (
props: CoreChartProps.TooltipSlotProps,
- ): CartesianChartProps.TooltipSlotRenderProps => ({
- x: props.x,
- items: props.items.map(transformItem),
- });
+ ): CartesianChartProps.TooltipSlotRenderProps => ({ x: props.x, items: props.items.map(transformItem) });
return {
point: tooltip.point ? (coreProps) => tooltip.point!(transformSeriesProps(coreProps)) : undefined,
@@ -92,46 +606,183 @@ export const InternalCartesianChart = forwardRef(
};
};
- // Converting x-, and y-threshold series to Highcharts series and plot lines.
const { series, xPlotLines, yPlotLines } = transformCartesianSeries(props.series, visibleSeriesState);
- // Cartesian chart imperative API.
useImperativeHandle(ref, () => ({
setVisibleSeries: (visibleSeriesIds) => apiRef.current?.setItemsVisible(visibleSeriesIds),
showAllSeries: () => apiRef.current?.setItemsVisible(allSeriesIds),
+ enterZoomMode,
+ exitZoomMode,
+ resetZoom,
}));
+ // The zoom mode button rendered inside the chart plot area (top-right corner).
+ const zoomModeButton =
+ zoomEnabled && !props.zoom?.hideButtons ? (
+
+ {liveAnnouncement}
+ {/* Reset (shown while zoomed) and Zoom sit side by side; Reset leads so the Zoom button
+ keeps its position whether or not the chart is zoomed. During an active selection only
+ the "Exit zoom" button is shown. */}
+
+ {zoomMode === "zoomed" && (
+
+
+
+ )}
+ {(zoomMode === "idle" || zoomMode === "zoomed") && (
+
+
+
+ )}
+ {(zoomMode === "zoomMode" || zoomMode === "selecting") && (
+
+
+
+ )}
+
+
+ ) : null;
+
+ // Disable tooltip in zoom mode (popovers disabled).
+ const effectiveTooltip =
+ zoomMode === "zoomMode" || zoomMode === "selecting" ? { ...tooltip, enabled: false } : tooltip;
+
return (
- (apiRef.current = api)}
- options={{
- chart: {
- inverted: props.inverted,
- },
- plotOptions: {
- series: { stacking: props.stacking },
- },
- series,
- xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({
- ...xAxisProps,
- title: { text: xAxisProps.title },
- plotLines: xPlotLines,
- })),
- yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({
- ...yAxisProps,
- title: { text: yAxisProps.title },
- plotLines: yPlotLines,
- ...(index === 1 ? { opposite: true } : {}),
- })),
- }}
- sizeAxis={props.sizeAxis}
- tooltip={tooltip}
- getTooltipContent={getTooltipContent}
- visibleItems={props.visibleSeries}
- onVisibleItemsChange={onVisibleSeriesChange}
- className={testClasses.root}
- />
+ <>
+ {
+ apiRef.current = api;
+ setChartReady(true);
+ // Move the cursor-tracking element into the chart container so the UAP direction buttons
+ // can be positioned relative to the plot via the portal overlay.
+ if (cursorTrackRef.current && !api.chart.container.contains(cursorTrackRef.current)) {
+ api.chart.container.style.position = "relative";
+ api.chart.container.appendChild(cursorTrackRef.current);
+ }
+ }}
+ options={{
+ chart: {
+ inverted: props.inverted,
+ ...(zoomEnabled
+ ? {
+ zooming: { type: "x" },
+ resetZoomButton: { theme: { style: { display: "none" } } },
+ }
+ : {}),
+ },
+ plotOptions: { series: { stacking: props.stacking } },
+ accessibility: { enabled: true, keyboardNavigation: { enabled: true } },
+ series,
+ xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({
+ ...xAxisProps,
+ title: { text: xAxisProps.title },
+ plotLines: xPlotLines,
+ ...(zoomEnabled
+ ? {
+ events: {
+ afterSetExtremes(e: {
+ min: number;
+ max: number;
+ trigger?: string;
+ userMin?: number;
+ userMax?: number;
+ }) {
+ if (e.trigger === "zoom") {
+ const zoomed = !!(e.userMin || e.userMax);
+ if (zoomed) {
+ setZoomMode("zoomed");
+ setZoomedExtremes({ min: e.min, max: e.max });
+ setLiveAnnouncement(
+ i18n.zoomRangeChangeAnnouncementText(formatXValue(e.min), formatXValue(e.max)),
+ );
+ fireNonCancelableEvent(props.onZoomRangeChange, {
+ zoomRange: { x: { startValue: e.min, endValue: e.max } },
+ });
+ } else {
+ setZoomedExtremes(null);
+ fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: null });
+ }
+ }
+ },
+ },
+ }
+ : {}),
+ })),
+ yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({
+ ...yAxisProps,
+ title: { text: yAxisProps.title },
+ plotLines: yPlotLines,
+ ...(index === 1 ? { opposite: true } : {}),
+ })),
+ }}
+ sizeAxis={props.sizeAxis}
+ tooltip={effectiveTooltip}
+ getTooltipContent={getTooltipContent}
+ visibleItems={props.visibleSeries}
+ onVisibleItemsChange={onVisibleSeriesChange}
+ className={testClasses.root}
+ />
+ {/* Invisible element tracked by the UAP direction buttons overlay, positioned at the cursor. */}
+
+ {/* UAP direction buttons: keyboard/touch alternative for moving the zoom cursor. */}
+
+ stepCursor(-1)}
+ forcedPosition={null}
+ forcedIndex={0}
+ />
+ stepCursor(1)}
+ forcedPosition={null}
+ forcedIndex={1}
+ />
+
+ >
);
},
);
diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts
index e5203545..f8aeffbe 100644
--- a/src/cartesian-chart/interfaces.ts
+++ b/src/cartesian-chart/interfaces.ts
@@ -109,6 +109,27 @@ export interface CartesianChartProps
*/
sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[];
+ /**
+ * Enables zoom functionality on the chart. When enabled, users can zoom into a range
+ * on the x-axis using three interaction methods:
+ *
+ * 1. **Drag-to-zoom** — Click and drag on the chart plot area to select a range.
+ * 2. **Zoom mode (click-based)** — A "Zoom" button appears in the top-right corner of the chart.
+ * Clicking it enters zoom mode where popovers are disabled. The first click on the chart sets
+ * the start point, and the second click sets the end point, zooming to that range. Direction
+ * buttons appear at the selection line for fine-tuning. Press Escape or click "Exit zoom" to cancel.
+ * 3. **Keyboard zoom** — While focused on a data point, hold Shift and press Left/Right arrow keys
+ * to extend a selection range. Release Shift or press Enter to zoom. Press Escape to cancel.
+ *
+ * After zooming, a "Reset" button appears to restore the full data range.
+ *
+ * Supported options:
+ * * `enabled` (optional, boolean) — Enables zoom functionality.
+ * * `hideButtons` (optional, boolean) — Hides the built-in zoom control buttons (Zoom, Exit zoom, Reset).
+ * Use this when providing custom zoom UI via ref methods (`enterZoomMode`, `exitZoomMode`, `resetZoom`).
+ */
+ zoom?: CartesianChartProps.ZoomOptions;
+
/**
* Specifies which series to show using their IDs. By default, all series are visible and managed by the component.
* If a series doesn't have an ID, its name is used. When using this property, manage state updates with `onVisibleSeriesChange`.
@@ -119,6 +140,18 @@ export interface CartesianChartProps
* A callback function, triggered when series visibility changes as a result of user interaction with the legend or filter.
*/
onVisibleSeriesChange?: NonCancelableEventHandler<{ visibleSeries: string[] }>;
+
+ /**
+ * The current zoom range. When provided, the component operates in controlled mode.
+ * Pass `null` to reset to full range. When omitted, zoom state is managed internally.
+ */
+ zoomRange?: CartesianChartProps.ZoomRange | null;
+
+ /**
+ * Called when the zoom range changes due to user interaction.
+ * In controlled mode, update `zoomRange` in this handler.
+ */
+ onZoomRangeChange?: NonCancelableEventHandler;
}
export namespace CartesianChartProps {
@@ -132,6 +165,18 @@ export namespace CartesianChartProps {
* Use this when implementing clear-filter actions in no-match states.
*/
showAllSeries(): void;
+ /**
+ * Enters zoom mode. In zoom mode, popovers are disabled and clicks on the chart set zoom range points.
+ */
+ enterZoomMode(): void;
+ /**
+ * Exits zoom mode without applying zoom. Returns to the idle state.
+ */
+ exitZoomMode(): void;
+ /**
+ * Resets the zoom to show the full data range.
+ */
+ resetZoom(): void;
}
export type SeriesOptions =
@@ -219,6 +264,28 @@ export namespace CartesianChartProps {
export type FilterOptions = CoreTypes.BaseFilterOptions;
export type NoDataOptions = CoreTypes.BaseNoDataOptions;
+
+ export interface ZoomOptions {
+ /**
+ * Enables zoom functionality. @defaultValue false
+ */
+ enabled?: boolean;
+ /**
+ * When set to `true`, hides all built-in zoom control buttons (Zoom, Exit zoom, Reset).
+ * Use this when you want to provide your own zoom UI or control zoom programmatically via ref methods.
+ */
+ hideButtons?: boolean;
+ }
+
+ export interface ZoomRange {
+ /** Start value of the x-axis zoomed range. */
+ x?: { startValue: number; endValue: number };
+ }
+
+ export interface ZoomChangeDetail {
+ /** The new zoom range, or `null` when zoom is reset to full range. */
+ zoomRange: ZoomRange | null;
+ }
}
// Internal types
diff --git a/src/cartesian-chart/test-classes/styles.scss b/src/cartesian-chart/test-classes/styles.scss
index 5a54f6dc..a3ccc554 100644
--- a/src/cartesian-chart/test-classes/styles.scss
+++ b/src/cartesian-chart/test-classes/styles.scss
@@ -3,6 +3,9 @@
SPDX-License-Identifier: Apache-2.0
*/
-.root {
+.root,
+.zoom-button,
+.exit-zoom-button,
+.reset-zoom-button {
/* used in test-utils */
}
diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts
index e337f21b..0ed66e39 100644
--- a/src/core/interfaces.ts
+++ b/src/core/interfaces.ts
@@ -160,6 +160,15 @@ export interface WithCartesianI18nStrings {
* * `chartRoleDescription` (optional, string) - Accessible role description of the chart plot area, e.g. "interactive chart".
* * `xAxisRoleDescription` (optional, string) - Accessible role description of the x axis, e.g. "x axis".
* * `yAxisRoleDescription` (optional, string) - Accessible role description of the y axis, e.g. "y axis".
+ * * `enterZoomModeButtonText` (optional, string) - Visible label for the "Zoom" button that enters zoom mode.
+ * * `enterZoomModeButtonAriaLabel` (optional, string) - Accessible label for the "Zoom" button.
+ * * `exitZoomModeButtonText` (optional, string) - Visible label for the "Exit zoom" button that exits zoom mode without zooming.
+ * * `exitZoomModeButtonAriaLabel` (optional, string) - Accessible label for the "Exit zoom" button.
+ * * `resetZoomButtonText` (optional, string) - Visible label for the "Reset" button that resets zoom to full range.
+ * * `resetZoomButtonAriaLabel` (optional, string) - Accessible label for the "Reset" button.
+ * * `zoomControlsAriaLabel` (optional, string) - Accessible label for the zoom controls region, e.g. "Chart zoom controls".
+ * * `zoomRangeChangeAnnouncementText` (optional, function) - Screen reader announcement when the zoom range changes. Receives the formatted start and end values.
+ * * `zoomSelectionAnnouncementText` (optional, function) - Screen reader announcement while adjusting the keyboard zoom selection. Receives the formatted start and end values.
*/
i18nStrings?: CartesianI18nStrings;
}
@@ -187,6 +196,34 @@ export interface WithPieI18nStrings {
export interface CartesianI18nStrings extends BaseI18nStrings {
xAxisRoleDescription?: string;
yAxisRoleDescription?: string;
+ /** Visible label for the "Zoom" button that enters zoom mode. @defaultValue "Zoom" */
+ enterZoomModeButtonText?: string;
+ /** Accessible label for the "Zoom" button. @defaultValue "Enter zoom mode" */
+ enterZoomModeButtonAriaLabel?: string;
+ /** Visible label for the "Exit zoom" button that exits zoom mode without zooming. @defaultValue "Exit zoom" */
+ exitZoomModeButtonText?: string;
+ /** Accessible label for the "Exit zoom" button. @defaultValue "Exit zoom mode" */
+ exitZoomModeButtonAriaLabel?: string;
+ /** Visible label for the "Reset" button that resets zoom to full range. @defaultValue "Reset" */
+ resetZoomButtonText?: string;
+ /** Accessible label for the "Reset" button. @defaultValue "Reset zoom to show full data range" */
+ resetZoomButtonAriaLabel?: string;
+ /** Accessible label for the zoom controls region. @defaultValue "Chart zoom controls" */
+ zoomControlsAriaLabel?: string;
+ /** Accessible label for the button that moves the zoom cursor to the previous point. @defaultValue "Move zoom cursor left" */
+ zoomCursorPreviousButtonAriaLabel?: string;
+ /** Accessible label for the button that moves the zoom cursor to the next point. @defaultValue "Move zoom cursor right" */
+ zoomCursorNextButtonAriaLabel?: string;
+ /** Screen reader announcement when zoom mode is entered. Receives the formatted cursor value. @defaultValue (value) => \`Zoom mode. Cursor at ${value}. Use arrow keys to move, Enter to set the start point.\` */
+ zoomModeEnteredAnnouncementText?: (value: string) => string;
+ /** Screen reader announcement when the zoom cursor moves. Receives the formatted cursor value. @defaultValue (value) => value */
+ zoomCursorPositionAnnouncementText?: (value: string) => string;
+ /** Screen reader announcement when the zoom start point is set. Receives the formatted start value. @defaultValue (value) => \`Start point set at ${value}. Move the cursor and set the end point to zoom.\` */
+ zoomStartPointAnnouncementText?: (value: string) => string;
+ /** Screen reader announcement when the zoom range changes. Receives the formatted start and end values. @defaultValue (startValue, endValue) => \`Zoomed from ${startValue} to ${endValue}\` */
+ zoomRangeChangeAnnouncementText?: (startValue: string, endValue: string) => string;
+ /** Screen reader announcement while adjusting the keyboard zoom selection. Receives the formatted start and end values. @defaultValue (startValue, endValue) => \`Selecting zoom range from ${startValue} to ${endValue}\` */
+ zoomSelectionAnnouncementText?: (startValue: string, endValue: string) => string;
}
export interface PieI18nStrings extends BaseI18nStrings {
diff --git a/src/core/styles.scss b/src/core/styles.scss
index d9c5f23d..7ad9a1b6 100644
--- a/src/core/styles.scss
+++ b/src/core/styles.scss
@@ -131,3 +131,12 @@ $side-legend-max-inline-size: 30%;
// We hide the native focus outline to render a custom one around the chart plot instead.
outline: none;
}
+
+// stylelint-disable-next-line selector-class-pattern
+:global(.highcharts-navigator-mask-inside) {
+ cursor: grab;
+
+ &:active {
+ cursor: grabbing;
+ }
+}
diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle-wrapper/direction-button.tsx
new file mode 100644
index 00000000..8dc42ea6
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/direction-button.tsx
@@ -0,0 +1,91 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useRef } from "react";
+import clsx from "clsx";
+
+import Icon, { IconProps } from "@cloudscape-design/components/icon";
+
+import { Direction, DirectionState } from "./interfaces";
+
+import styles from "./styles.css.js";
+import testUtilsStyles from "./test-classes/styles.css.js";
+
+// Minimal Transition wrapper — just shows/hides without animation.
+function Transition({
+ in: isIn,
+ children,
+}: {
+ in: boolean;
+ children: (state: string, ref: React.Ref) => React.ReactNode;
+}) {
+ const ref = useRef(null);
+ if (!isIn) {
+ return <>{children("exited", ref)}>;
+ }
+ return <>{children("entered", ref)}>;
+}
+
+// Mapping from CSS logical property direction to icon name. The icon component
+// already flips the left/right icons automatically based on RTL, so we don't
+// need to do anything special.
+const ICON_LOGICAL_PROPERTY_MAP: Record = {
+ "block-start": "arrow-up",
+ "block-end": "arrow-down",
+ "inline-start": "arrow-left",
+ "inline-end": "arrow-right",
+};
+
+interface DirectionButtonProps {
+ direction: Direction;
+ state: DirectionState;
+ onClick: React.MouseEventHandler;
+ show: boolean;
+ forcedPosition: null | "top" | "bottom";
+ forcedIndex: number;
+ ariaLabel?: string;
+}
+
+export default function DirectionButton({
+ direction,
+ state,
+ show,
+ onClick,
+ forcedPosition,
+ forcedIndex,
+ ariaLabel,
+}: DirectionButtonProps) {
+ return (
+
+ {(transitionState, ref) => (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx
new file mode 100644
index 00000000..4509d3ec
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/index.tsx
@@ -0,0 +1,206 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+
+import { nodeContains } from "@cloudscape-design/component-toolkit/dom";
+import { getLogicalBoundingClientRect } from "@cloudscape-design/component-toolkit/internal";
+
+import DirectionButton from "./direction-button";
+import { Direction, DragHandleWrapperProps } from "./interfaces";
+import PortalOverlay from "./portal-overlay";
+
+import styles from "./styles.css.js";
+import testUtilsStyles from "./test-classes/styles.css.js";
+
+// The UAP buttons are forced to top/bottom position if the handle is close to the screen edge.
+const FORCED_POSITION_PROXIMITY_PX = 50;
+// Approximate UAP button size with margins to decide forced direction.
+const UAP_BUTTON_SIZE_PX = 40;
+const DIRECTIONS_ORDER: Direction[] = ["block-end", "block-start", "inline-end", "inline-start"];
+
+export default function DragHandleWrapper({
+ directions,
+ children,
+ onDirectionClick,
+ triggerMode = "focus",
+ initialShowButtons = false,
+ controlledShowButtons = false,
+ wrapperClassName,
+ hideButtonsOnDrag,
+ clickDragThreshold,
+}: DragHandleWrapperProps) {
+ const wrapperRef = useRef(null);
+ const dragHandleRef = useRef(null);
+ const [uncontrolledShowButtons, setUncontrolledShowButtons] = useState(initialShowButtons);
+
+ const isPointerDown = useRef(false);
+ const initialPointerPosition = useRef<{ x: number; y: number } | undefined>();
+ const didPointerDrag = useRef(false);
+
+ const isDisabled =
+ !directions["block-start"] && !directions["block-end"] && !directions["inline-start"] && !directions["inline-end"];
+
+ const onWrapperFocusIn: React.FocusEventHandler = (event) => {
+ if (document.body.dataset.awsuiFocusVisible && !nodeContains(wrapperRef.current, event.relatedTarget)) {
+ if (triggerMode === "focus") {
+ setUncontrolledShowButtons(true);
+ }
+ }
+ };
+
+ const onWrapperFocusOut: React.FocusEventHandler = (event) => {
+ if (document.hasFocus() && !nodeContains(wrapperRef.current, event.relatedTarget)) {
+ setUncontrolledShowButtons(false);
+ }
+ };
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ document.addEventListener(
+ "pointermove",
+ (event) => {
+ if (
+ isPointerDown.current &&
+ initialPointerPosition.current &&
+ (event.clientX > initialPointerPosition.current.x + clickDragThreshold ||
+ event.clientX < initialPointerPosition.current.x - clickDragThreshold ||
+ event.clientY > initialPointerPosition.current.y + clickDragThreshold ||
+ event.clientY < initialPointerPosition.current.y - clickDragThreshold)
+ ) {
+ didPointerDrag.current = true;
+ if (hideButtonsOnDrag) {
+ setUncontrolledShowButtons(false);
+ }
+ }
+ },
+ { signal: controller.signal },
+ );
+
+ const resetPointerDownState = () => {
+ isPointerDown.current = false;
+ initialPointerPosition.current = undefined;
+ };
+
+ document.addEventListener("pointercancel", resetPointerDownState, { signal: controller.signal });
+
+ document.addEventListener(
+ "pointerup",
+ () => {
+ if (isPointerDown.current && !didPointerDrag.current) {
+ setUncontrolledShowButtons(true);
+ }
+ resetPointerDownState();
+ },
+ { signal: controller.signal },
+ );
+
+ return () => controller.abort();
+ }, [clickDragThreshold, hideButtonsOnDrag]);
+
+ const onHandlePointerDown: React.PointerEventHandler = (event) => {
+ isPointerDown.current = true;
+ didPointerDrag.current = false;
+ initialPointerPosition.current = { x: event.clientX, y: event.clientY };
+ };
+
+ const onDragHandleKeyDown: React.KeyboardEventHandler = (event) => {
+ if (event.key === "Escape") {
+ setUncontrolledShowButtons(false);
+ } else if (triggerMode === "keyboard-activate" && (event.key === "Enter" || event.key === " ")) {
+ setUncontrolledShowButtons((prev) => !prev);
+ } else if (
+ event.key !== "Alt" &&
+ event.key !== "Control" &&
+ event.key !== "Meta" &&
+ event.key !== "Shift" &&
+ triggerMode === "focus"
+ ) {
+ setUncontrolledShowButtons(true);
+ }
+ };
+
+ const showButtons = triggerMode === "controlled" ? controlledShowButtons : uncontrolledShowButtons;
+
+ const [forcedPosition, setForcedPosition] = useState(null);
+ const directionsOrder = forcedPosition === "bottom" ? [...DIRECTIONS_ORDER].reverse() : DIRECTIONS_ORDER;
+ const visibleDirections = directionsOrder.filter((dir) => directions[dir]);
+
+ useEffect(() => {
+ if (!showButtons || !dragHandleRef.current) {
+ if (forcedPosition !== null) {
+ setForcedPosition(null);
+ }
+ return;
+ }
+
+ let frameId: number;
+
+ const checkPosition = () => {
+ if (!dragHandleRef.current) {
+ return;
+ }
+ const rect = getLogicalBoundingClientRect(dragHandleRef.current);
+ const conflicts = {
+ "block-start": rect.insetBlockStart < FORCED_POSITION_PROXIMITY_PX,
+ "block-end": window.innerHeight - rect.insetBlockEnd < FORCED_POSITION_PROXIMITY_PX,
+ "inline-start": rect.insetInlineStart < FORCED_POSITION_PROXIMITY_PX,
+ "inline-end": window.innerWidth - rect.insetInlineEnd < FORCED_POSITION_PROXIMITY_PX,
+ };
+ if (visibleDirections.some((direction) => conflicts[direction])) {
+ const hasEnoughSpaceAbove = rect.insetBlockStart > visibleDirections.length * UAP_BUTTON_SIZE_PX;
+ setForcedPosition(hasEnoughSpaceAbove ? "top" : "bottom");
+ } else {
+ setForcedPosition(null);
+ }
+ frameId = requestAnimationFrame(checkPosition);
+ };
+
+ frameId = requestAnimationFrame(checkPosition);
+
+ return () => {
+ cancelAnimationFrame(frameId);
+ };
+ }, [forcedPosition, showButtons, visibleDirections]);
+
+ return (
+ <>
+
+
+
+ {children}
+
+
+
+
+
+ {visibleDirections.map(
+ (direction, index) =>
+ directions[direction] && (
+ onDirectionClick?.(direction)}
+ forcedPosition={forcedPosition}
+ forcedIndex={index}
+ />
+ ),
+ )}
+
+ >
+ );
+}
diff --git a/src/internal/components/drag-handle-wrapper/interfaces.ts b/src/internal/components/drag-handle-wrapper/interfaces.ts
new file mode 100644
index 00000000..1a62004d
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/interfaces.ts
@@ -0,0 +1,18 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export type Direction = "block-start" | "block-end" | "inline-start" | "inline-end";
+export type DirectionState = "active" | "disabled";
+export type TriggerMode = "focus" | "keyboard-activate" | "controlled";
+
+export interface DragHandleWrapperProps {
+ directions: Partial>;
+ onDirectionClick?: (direction: Direction) => void;
+ children: React.ReactNode;
+ wrapperClassName?: string;
+ triggerMode?: TriggerMode;
+ initialShowButtons?: boolean;
+ controlledShowButtons?: boolean;
+ hideButtonsOnDrag: boolean;
+ clickDragThreshold: number;
+}
diff --git a/src/internal/components/drag-handle-wrapper/motion.scss b/src/internal/components/drag-handle-wrapper/motion.scss
new file mode 100644
index 00000000..d88f1bd5
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/motion.scss
@@ -0,0 +1,17 @@
+/*
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ SPDX-License-Identifier: Apache-2.0
+*/
+
+// Simplified motion — no animations for now.
+.direction-button-wrapper {
+ &-motion-entering,
+ &-motion-entered {
+ opacity: 1;
+ }
+
+ &-motion-exiting,
+ &-motion-exited {
+ opacity: 0;
+ }
+}
diff --git a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx
new file mode 100644
index 00000000..1b7c0fcc
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx
@@ -0,0 +1,85 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { useEffect, useLayoutEffect, useRef, useState } from "react";
+import clsx from "clsx";
+
+import {
+ getIsRtl,
+ getLogicalBoundingClientRect,
+ getScrollInlineStart,
+ Portal,
+} from "@cloudscape-design/component-toolkit/internal";
+
+import styles from "./styles.css.js";
+
+export default function PortalOverlay({
+ track,
+ isDisabled,
+ children,
+}: {
+ track: React.RefObject;
+ isDisabled: boolean;
+ children: React.ReactNode;
+}) {
+ const ref = useRef(null);
+ const [container, setContainer] = useState(null);
+
+ useLayoutEffect(() => {
+ if (track.current) {
+ const newContainer = track.current.ownerDocument.createElement("div");
+ track.current.ownerDocument.body.appendChild(newContainer);
+ setContainer(newContainer);
+ return () => newContainer.remove();
+ }
+ }, [track]);
+
+ useEffect(() => {
+ if (track.current === null || isDisabled) {
+ return;
+ }
+
+ let cleanedUp = false;
+ let lastX: number | undefined;
+ let lastY: number | undefined;
+ let lastInlineSize: number | undefined;
+ let lastBlockSize: number | undefined;
+ const updateElement = () => {
+ if (track.current && ref.current && document.body.contains(ref.current)) {
+ const isRtl = getIsRtl(ref.current);
+ const { insetInlineStart, insetBlockStart, inlineSize, blockSize } = getLogicalBoundingClientRect(
+ track.current,
+ );
+ const newX = (insetInlineStart + getScrollInlineStart(document.documentElement)) * (isRtl ? -1 : 1);
+ const newY = insetBlockStart + document.documentElement.scrollTop;
+ if (lastX !== newX || lastY !== newY) {
+ ref.current.style.translate = `${newX}px ${newY}px`;
+ lastX = newX;
+ lastY = newY;
+ }
+ if (lastInlineSize !== inlineSize || lastBlockSize !== blockSize) {
+ ref.current.style.width = `${inlineSize}px`;
+ ref.current.style.height = `${blockSize}px`;
+ lastInlineSize = inlineSize;
+ lastBlockSize = blockSize;
+ }
+ }
+ if (!cleanedUp) {
+ requestAnimationFrame(updateElement);
+ }
+ };
+ updateElement();
+
+ return () => {
+ cleanedUp = true;
+ };
+ }, [isDisabled, track]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss
new file mode 100644
index 00000000..ac0ac282
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/styles.scss
@@ -0,0 +1,141 @@
+/*
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ SPDX-License-Identifier: Apache-2.0
+*/
+
+@use "../../styles" as styles;
+@use "../../../../node_modules/@cloudscape-design/design-tokens/index.scss" as awsui;
+@use "./motion";
+
+$direction-button-wrapper-size: calc(#{awsui.$space-static-xl} + 2 * #{awsui.$space-static-xxs});
+$direction-button-size: awsui.$space-static-xl;
+$direction-button-offset: awsui.$space-static-xxs;
+
+.contents {
+ display: contents;
+}
+
+.portal-overlay {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+
+ // Since the overlay takes up the exact width/height of the element below it, this prevents
+ // any clicks on this element from occluding clicks on the element below.
+ pointer-events: none;
+
+ // Similar to the expandToViewport dropdown, this needs to be higher than modal's z-index.
+ z-index: 7000;
+}
+
+.portal-overlay-disabled {
+ display: none;
+}
+
+.portal-overlay-contents {
+ pointer-events: auto;
+}
+
+.drag-handle {
+ position: relative;
+ display: inline-flex;
+}
+
+.direction-button-wrapper {
+ position: absolute;
+ block-size: $direction-button-size;
+ inline-size: $direction-button-size;
+ padding-block: $direction-button-offset;
+ padding-inline: $direction-button-offset;
+}
+
+.direction-button-wrapper-hidden {
+ display: none;
+}
+
+.direction-button-wrapper-block-start {
+ inset-block-start: calc(-1 * $direction-button-wrapper-size);
+ inset-inline-start: calc(50% - $direction-button-wrapper-size / 2);
+}
+
+.direction-button-wrapper-block-end {
+ inset-block-end: calc(-1 * $direction-button-wrapper-size);
+ inset-inline-start: calc(50% - $direction-button-wrapper-size / 2);
+}
+
+.direction-button-wrapper-inline-start {
+ inset-inline-start: calc(-1 * $direction-button-wrapper-size);
+ inset-block-start: calc(50% - $direction-button-wrapper-size / 2);
+}
+
+.direction-button-wrapper-inline-end {
+ inset-inline-end: calc(-1 * $direction-button-wrapper-size);
+ inset-block-start: calc(50% - $direction-button-wrapper-size / 2);
+}
+
+.direction-button-wrapper-forced {
+ inset-inline-start: calc(50% - $direction-button-wrapper-size / 2);
+}
+.direction-button-wrapper-forced-top-0 {
+ inset-block-start: calc(-1 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-top-1 {
+ inset-block-start: calc(-2 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-top-2 {
+ inset-block-start: calc(-3 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-top-3 {
+ inset-block-start: calc(-4 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-bottom-0 {
+ inset-block-start: calc(1 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-bottom-1 {
+ inset-block-start: calc(2 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-bottom-2 {
+ inset-block-start: calc(3 * $direction-button-wrapper-size);
+}
+.direction-button-wrapper-forced-bottom-3 {
+ inset-block-start: calc(4 * $direction-button-wrapper-size);
+}
+
+.direction-button {
+ position: absolute;
+ border-width: 0;
+ cursor: pointer;
+ display: inline-block;
+ box-sizing: border-box;
+
+ // This skips the browser waiting for a double-tap interaction before activating.
+ // False positive - this isn't supported in Safari Desktop but is supported on iOS.
+ // stylelint-disable-next-line plugin/no-unsupported-browser-features
+ touch-action: manipulation;
+
+ inline-size: $direction-button-size;
+ block-size: $direction-button-size;
+ padding-block: awsui.$space-static-xxs;
+ padding-inline: awsui.$space-static-xxs;
+ border-start-start-radius: 50%;
+ border-start-end-radius: 50%;
+ border-end-start-radius: 50%;
+ border-end-end-radius: 50%;
+ background-color: awsui.$color-background-layout-toggle-default;
+ color: awsui.$color-text-button-primary-default;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
+
+ &:not(.direction-button-disabled):hover {
+ background-color: awsui.$color-background-layout-toggle-hover;
+ }
+
+ &:not(.direction-button-disabled):active {
+ background-color: awsui.$color-background-layout-toggle-active;
+ }
+}
+
+.direction-button-disabled {
+ cursor: default;
+ background-color: awsui.$color-background-button-normal-disabled;
+ color: awsui.$color-text-button-normal-disabled;
+}
diff --git a/src/internal/components/drag-handle-wrapper/test-classes/styles.scss b/src/internal/components/drag-handle-wrapper/test-classes/styles.scss
new file mode 100644
index 00000000..b5ea159c
--- /dev/null
+++ b/src/internal/components/drag-handle-wrapper/test-classes/styles.scss
@@ -0,0 +1,32 @@
+/*
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ SPDX-License-Identifier: Apache-2.0
+*/
+
+.root {
+ /* used in test-utils */
+}
+
+.direction-button {
+ /* used in test-utils */
+}
+
+.direction-button-visible {
+ /* used in test-utils */
+}
+
+.direction-button-block-start {
+ /* used in test-utils */
+}
+
+.direction-button-block-end {
+ /* used in test-utils */
+}
+
+.direction-button-inline-start {
+ /* used in test-utils */
+}
+
+.direction-button-inline-end {
+ /* used in test-utils */
+}
diff --git a/src/test-utils/dom/cartesian-chart/index.ts b/src/test-utils/dom/cartesian-chart/index.ts
index da825494..6472d66b 100644
--- a/src/test-utils/dom/cartesian-chart/index.ts
+++ b/src/test-utils/dom/cartesian-chart/index.ts
@@ -1,6 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
+import { ButtonWrapper } from "@cloudscape-design/components/test-utils/dom";
import { ElementWrapper } from "@cloudscape-design/test-utils-core/dom";
import BaseChartWrapper from "../internal/base";
@@ -24,4 +25,28 @@ export default class CartesianChartWrapper extends BaseChartWrapper {
public findSeries(): Array {
return this.findAllByClassName("highcharts-series");
}
+
+ /**
+ * Finds the "Zoom" button that enters zoom mode.
+ * Visible when zoom is enabled and the chart is in idle state (not zoomed, not in zoom mode).
+ */
+ public findZoomButton(): null | ButtonWrapper {
+ return this.findComponent(`.${testClasses["zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper);
+ }
+
+ /**
+ * Finds the "Exit zoom" button that exits zoom mode without applying zoom.
+ * Visible when the chart is in zoom mode (waiting for start/end point selection).
+ */
+ public findExitZoomButton(): null | ButtonWrapper {
+ return this.findComponent(`.${testClasses["exit-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper);
+ }
+
+ /**
+ * Finds the "Reset" button that resets zoom to show the full data range.
+ * Visible when the chart is zoomed in.
+ */
+ public findResetZoomButton(): null | ButtonWrapper {
+ return this.findComponent(`.${testClasses["reset-zoom-button"]} .${ButtonWrapper.rootSelector}`, ButtonWrapper);
+ }
}
diff --git a/vite.config.unit.mjs b/vite.config.unit.mjs
index a52b3367..25196d36 100644
--- a/vite.config.unit.mjs
+++ b/vite.config.unit.mjs
@@ -20,6 +20,9 @@ export default defineConfig({
include: ["./src/**/__tests__/**/*.test.{ts,tsx}"],
environment: "jsdom",
setupFiles: ["./src/__tests__/setup.ts"],
+ // Highcharts-heavy tests (e.g. cartesian-chart zoom) render real charts and, under parallel
+ // load on slower CI runners, exceed the 5s default. Raise the ceiling to avoid flaky timeouts.
+ testTimeout: 15000,
coverage: {
enabled: true,
provider: "v8",