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..08e0dd74 100644 --- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx +++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx @@ -3,6 +3,12 @@ import { range } from "lodash"; +import { CartesianChart } from "../../lib/components"; +import { dateFormatter } from "../common/formatters"; +import { useChartSettings } from "../common/page-settings"; +import { Page, PageSection } from "../common/templates"; +import pseudoRandom from "../utils/pseudo-random"; + const addDays = (date: Date, days: number) => { const result = new Date(date); result.setDate(result.getDate() + days); @@ -15,12 +21,6 @@ const subYears = (date: Date, years: number) => { return result; }; -import { CartesianChart } from "../../lib/components"; -import { dateFormatter } from "../common/formatters"; -import { useChartSettings } from "../common/page-settings"; -import { Page, PageSection } from "../common/templates"; -import pseudoRandom from "../utils/pseudo-random"; - export default function () { return ( { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; +}; + +// A fixed start date keeps the rendered chart, and the visual regression snapshots, stable. +const seriesStart = new Date("2025-01-01T00:00:00Z"); + +const zoomSeriesData = range(0, 100).map((i) => ({ + x: addDays(seriesStart, 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 }, +]; + +// Pin the axis to the data range, as the other cartesian pages do. Without explicit bounds Highcharts +// derives the range from the data and pads it by 1% at each end, leaving a visible gap between the +// plot edges and the start and end of the series. +const zoomXAxis = { + title: "Time", + type: "datetime", + valueFormatter: dateFormatter, + min: zoomSeriesData[0].x, + max: zoomSeriesData[zoomSeriesData.length - 1].x, +} as const; + +export default function () { + return ( + + + + + + ); +} + +function UncontrolledZoom() { + const { chartProps } = useChartSettings(); + return ( + + ); +} diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index c4af9741..e6444ca1 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -21,8 +21,72 @@ exports[`definition for cartesian-chart matches the snapshot > cartesian-chart 1 "detailType": "{ visibleSeries: Array; }", "name": "onVisibleSeriesChange", }, + { + "cancelable": false, + "description": "A callback function, triggered when the zoomed range changes as a result of user interaction with the chart +or the zoom controls. The detail's \`zoomRange\` is \`null\` when the zoom is reset to the full data range.", + "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 which the tooltip is suppressed and clicks on the chart set the start and end of +the range to zoom into. Requires zooming to be enabled with the \`zoom\` property.", + "name": "enterZoomMode", + "parameters": [], + "returnType": "void", + }, + { + "description": "Exits zoom mode, discarding the range being selected. Any range the chart is already zoomed into is kept.", + "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 +206,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 +246,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 +281,119 @@ 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)", + }, + { + "name": "zoomModeExitedAnnouncementText", + "optional": true, + "type": "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)", + }, + { + "name": "zoomResetAnnouncementText", + "optional": true, + "type": "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 +827,74 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]", }, + { + "description": "Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging +across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and +end with a click, Enter, or Space. In zoom mode the tooltip is suppressed, Escape or the "Exit zoom" +button cancels the selection, and the "Reset" button restores the full data range once zoomed. + +Supported options: +* \`enabled\` (optional, boolean) - Enables zooming. Defaults to \`false\`. +* \`hideButtons\` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom +controls, that use the \`enterZoomMode\`, \`exitZoomMode\`, and \`resetZoom\` methods of the component's ref.", + "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 zoomed range of the x-axis. By default, the range is managed by the component. When using this property, +manage state updates with \`onZoomRangeChange\`, and use \`null\` to show the full data range. + +Supported options: +* \`x\` (optional, object) - The zoomed x-axis range, as \`startValue\` and \`endValue\`. For datetime axes the +values are timestamps in milliseconds.", + "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..b5921da3 --- /dev/null +++ b/src/cartesian-chart/__tests__/cartesian-chart-zoom.test.tsx @@ -0,0 +1,726 @@ +// 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"; + +// Every test here renders a real chart, which takes ~2s in jsdom, and the zoom interactions re-render +// it repeatedly. That leaves too little headroom under the 5s default when the suite runs in parallel. +const TEST_TIMEOUT = 15_000; + +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 }; +} + +// Dispatches a keydown from an element inside the chart. The core keydown handler calls +// target.closest, which a Document target would not satisfy. +function pressChartKey(key: string) { + const chartElement = getChart().getElement(); + const target = chartElement.querySelector('[role="application"]') ?? chartElement; + act(() => { + target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })); + }); +} + +// Drives a keyboard zoom over the default data points, from the first point to two steps along. +function zoomViaKeyboard() { + act(() => getChart().findZoomButton()!.click()); + pressChartKey("ArrowRight"); + pressChartKey("Enter"); + pressChartKey("ArrowRight"); + pressChartKey("Enter"); +} + +// Moves the pointer to a chart-relative x position over the plot, which drives the zoom cursor. +function movePointerTo(chartX: number) { + const chart = getCurrentChart(); + act(() => { + chart.container.dispatchEvent( + new MouseEvent("mousemove", { bubbles: true, clientX: chartX, clientY: chart.plotTop + 5 }), + ); + }); +} + +// 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 items = getPlotLinesAndBands(); + 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 }; +} + +function getPlotLinesAndBands() { + const xAxis = getCurrentChart().xAxis[0] as unknown as { + plotLinesAndBands: { + id?: string; + options?: { from?: number; to?: number; value?: number; color?: string }; + }[]; + }; + return xAxis.plotLinesAndBands ?? []; +} + +// Reads the dividers drawn at the edges of the native drag-to-zoom selection. +function getDragBoundaries() { + const items = getPlotLinesAndBands(); + return { + startLine: items.find((i) => i.id === "awsui-zoom-drag-start"), + endLine: items.find((i) => i.id === "awsui-zoom-drag-end"), + }; +} + +// Simulates a frame of the native drag-to-zoom by asking the pointer for the selection marker +// rectangle it would draw, which is what the component listens to. +function dragSelectionFrame({ chartX }: { chartX: number }) { + const pointer = getCurrentChart().pointer as unknown as { + getSelectionMarkerAttrs(chartX: number, chartY: number): { attrs: { x?: number; width?: number } }; + }; + const chart = getCurrentChart(); + return pointer.getSelectionMarkerAttrs(chartX, chart.plotTop + 1).attrs; +} + +const onZoomRangeChange = vi.fn(); + +afterEach(() => { + onZoomRangeChange.mockReset(); +}); + +describe("CartesianChart: zoom", { timeout: TEST_TIMEOUT }, () => { + 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); + }); + + // 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 keeps 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. The affordance stays on screen so the + // range already in view remains visible while a narrower one is selected inside it. + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findExitZoomButton()).not.toBe(null); + expect(getChart().findZoomButton()).toBe(null); + expect(getChart().findResetZoomButton()).toBe(null); + expect(getZoomRangeOverlays().band).toBeDefined(); + }); + + 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("draws boundary dividers at the edges of a drag-to-zoom selection", async () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + const chart = getCurrentChart(); + // No drag in progress: no dividers. + expect(getDragBoundaries().startLine).toBeUndefined(); + expect(getDragBoundaries().endLine).toBeUndefined(); + + // Highcharts derives the selection rectangle from where the pointer went down, so seed that and + // then run a drag frame; the dividers follow the resulting rectangle's edges. + Object.assign(chart, { mouseDownX: chart.plotLeft + 10, mouseDownY: chart.plotTop + 1 }); + let attrs!: { x?: number; width?: number }; + act(() => { + attrs = dragSelectionFrame({ chartX: chart.plotLeft + 50 }); + }); + expect(attrs.x).toEqual(expect.any(Number)); + + await vi.waitFor(() => expect(getDragBoundaries().startLine).toBeDefined()); + const { startLine, endLine } = getDragBoundaries(); + expect(endLine).toBeDefined(); + // The dividers map back to the axis values under the rectangle's left and right edges. + const toValue = (pixelX: number) => chart.xAxis[0].toValue(pixelX - chart.plotLeft, true); + expect(startLine!.options!.value).toBeCloseTo(toValue(attrs.x!), 5); + expect(endLine!.options!.value).toBeCloseTo(toValue(attrs.x! + attrs.width!), 5); + // Both use the same divider styling as the click/keyboard selection lines. + expect(startLine!.options!.color).toBe(endLine!.options!.color); + + // Releasing the drag clears them again. + act(() => { + document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); + }); + expect(getDragBoundaries().startLine).toBeUndefined(); + expect(getDragBoundaries().endLine).toBeUndefined(); + }); + + test("snaps a click to the nearest data point", () => { + // No min/max here, so Highcharts applies its default axis padding and the plot extends slightly + // beyond the first and last points — the strip the keyboard cursor can never reach. + renderCartesianChart({ + ...defaultProps, + xAxis: { title: "X", type: "linear" as const }, + zoom: { enabled: true }, + onZoomRangeChange, + }); + const chart = getCurrentChart(); + act(() => getChart().findZoomButton()!.click()); + + // Move the pointer into that leading padding: the raw value there is below the first point (x=0). + const leadingEdgeX = chart.plotLeft + 1; + expect(chart.xAxis[0].toValue(leadingEdgeX, false)).toBeLessThan(0); + // Committing with the keyboard uses wherever the pointer left the cursor, so this asserts on the + // position the pointer produced. + movePointerTo(leadingEdgeX); + pressChartKey("Enter"); + // Then move between two points, nearer x=2 than x=3, and commit. + movePointerTo(chart.xAxis[0].toPixels(2.4, false)); + pressChartKey("Enter"); + + // Both ends land on real data points, so a pointer selection is expressible by the keyboard too. + expect(onZoomRangeChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 0, endValue: 2 } } } }), + ); + }); + + test("hides the zoom controls when no series with data points are visible", () => { + // Zooming needs points to select between, so the controls are not offered in the no-data state. + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, visibleSeries: [] }); + expect(getChart().findZoomButton()).toBe(null); + // The ref method is guarded too, since it bypasses the buttons entirely. + act(() => ref.current!.enterZoomMode()); + expect(getChart().findExitZoomButton()).toBe(null); + }); + + test("does not offer zooming for threshold-only charts", () => { + // Thresholds span the whole axis and contribute no points of their own. + renderCartesianChart({ + ...defaultProps, + series: [{ type: "y-threshold", name: "SLA limit", value: 20 }], + zoom: { enabled: true }, + }); + expect(getChart().findZoomButton()).toBe(null); + }); + + test("leaves zoom mode when the last visible series is hidden mid-selection", () => { + const { rerender } = renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + visibleSeries: ["Requests"], + }); + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findExitZoomButton()).not.toBe(null); + + // Hiding everything mid-selection leaves the cursor with nothing to land on, so zoom mode ends + // rather than stranding the user in a selection they cannot complete. + rerender({ ...defaultProps, zoom: { enabled: true }, visibleSeries: [] }); + expect(getChart().findExitZoomButton()).toBe(null); + }); + + test("announces when the built-in buttons are hidden", async () => { + // A consumer using hideButtons with its own controls still needs the announcements. + renderCartesianChart({ ...defaultProps, zoom: { enabled: true, hideButtons: true } }); + act(() => ref.current!.enterZoomMode()); + // The live region applies its text after an internal delay that can exceed waitFor's default + // timeout, so allow longer here. + await vi.waitFor( + () => + expect([...document.querySelectorAll("[aria-live]")].map((n) => n.textContent).join("")).toContain("Zoom mode"), + { timeout: 5000 }, + ); + }); + + test("leaves the selection states after completing a selection in controlled mode", () => { + // The consumer owns the extremes and may ignore the event or re-apply the same range, in which + // case the zoomRange effect does not run. The selection is over regardless, so the chart must + // not stay in zoom mode with the tooltip suppressed and clicks still setting range points. + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange }); + act(() => getChart().findZoomButton()!.click()); + pressChartKey("Enter"); + pressChartKey("ArrowRight"); + pressChartKey("Enter"); + + expect(getChart().findExitZoomButton()).toBe(null); + expect(getChart().findZoomButton()).not.toBe(null); + // The range is still only reported — applying it remains the consumer's job. + expect(onZoomRangeChange).toHaveBeenLastCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 0, endValue: 1 } } } }), + ); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + }); + + 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", { timeout: TEST_TIMEOUT }, () => { + 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. + pressChartKey("ArrowRight"); + pressChartKey("Enter"); + // Move the cursor right to x=3 and set the end point → zoom applies. + pressChartKey("ArrowRight"); + pressChartKey("ArrowRight"); + pressChartKey("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()); + pressChartKey("ArrowRight"); // cursor at x=1 + pressChartKey(" "); // set start + pressChartKey("ArrowRight"); // cursor at x=2 + pressChartKey(" "); // 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()); + pressChartKey("ArrowRight"); + pressChartKey("Enter"); // start point set, now selecting + pressChartKey("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("announces a range selected by dragging, in both controlled and uncontrolled mode", async () => { + const announcement = () => + [...document.querySelectorAll("[aria-live]")].map((n) => n.textContent?.trim()).join(" "); + + // Uncontrolled: Highcharts applies the extremes and we observe them. + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getCurrentChart().xAxis[0].setExtremes(1, 3, true, false, { trigger: "zoom" })); + await vi.waitFor(() => expect(announcement()).toContain("Zoomed from 1 to 3"), { timeout: 5000 }); + + // Controlled: the extremes are the consumer's to apply, but the drag still needs confirming — + // this path previously fired the event without announcing anything. + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, zoomRange: null, onZoomRangeChange }); + act(() => getCurrentChart().xAxis[0].setExtremes(1, 2, true, false, { trigger: "zoom" })); + await vi.waitFor(() => expect(announcement()).toContain("Zoomed from 1 to 2"), { timeout: 5000 }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 2 } } } }), + ); + }); + + test("ignores zoom keys dispatched outside the chart", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true }, onZoomRangeChange }); + act(() => getChart().findZoomButton()!.click()); + + // A key press that did not originate inside the chart must not drive the zoom cursor: another + // chart in zoom mode, or a dialog opened over this one, would otherwise move this chart's cursor. + const outside = document.createElement("button"); + document.body.appendChild(outside); + act(() => { + outside.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + outside.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + outside.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + outside.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + // Still in zoom mode, with nothing selected and no zoom applied. + expect(getChart().findExitZoomButton()).not.toBe(null); + expect(onZoomRangeChange).not.toHaveBeenCalled(); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + + // Escape from outside must not exit zoom mode either. + act(() => { + outside.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + expect(getChart().findExitZoomButton()).not.toBe(null); + + outside.remove(); + }); + + test("announces exiting zoom mode and resetting the zoom", async () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + // The live region is rendered in a portal outside the chart, and its text is applied after a + // short delay, so it is searched for from the document root and awaited. + const announcement = () => [...document.querySelectorAll("[aria-live]")].map((n) => n.textContent?.trim()).join(""); + + // Leaving zoom mode is a state change, so it is announced rather than left silent. + act(() => getChart().findZoomButton()!.click()); + act(() => getChart().findExitZoomButton()!.click()); + await vi.waitFor(() => expect(announcement()).toBe("Zoom mode cancelled")); + + // So is returning to the full data range. + zoomViaKeyboard(); + act(() => getChart().findResetZoomButton()!.click()); + await vi.waitFor(() => expect(announcement()).toBe("Zoom reset. Showing the full data range.")); + }); + + test("moves focus to the Exit zoom button when zoom mode is entered", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + // The activated button is replaced by "Exit zoom", so focus lands there. Leaving focus on the plot + // would make the exit unreachable: tabbing forward walks into the chart's own data points. + expect(document.activeElement).toBe(getChart().findExitZoomButton()!.getElement()); + + // The keys must still drive the cursor from there, rather than only working on the plot. + pressChartKey("ArrowRight"); + pressChartKey("Enter"); + pressChartKey("ArrowRight"); + pressChartKey("Enter"); + expect(getXExtremes()).toEqual({ min: 1, max: 2 }); + }); + + test("moves focus to the Zoom button when Reset is activated", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + zoomViaKeyboard(); + // Applying the zoom lands focus on the newly shown Reset button. + expect(document.activeElement).toBe(getChart().findResetZoomButton()!.getElement()); + + // Reset removes that button, so focus moves to Zoom, which takes its place — rather than being + // dropped to the body, which would send keyboard users back to the top of the page. + act(() => getChart().findResetZoomButton()!.click()); + expect(document.activeElement).toBe(getChart().findZoomButton()!.getElement()); + }); + + test("renders the zoom cursor buttons in zoom mode only", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + expect(getChart().findZoomCursorPreviousButton()).toBe(null); + expect(getChart().findZoomCursorNextButton()).toBe(null); + + act(() => getChart().findZoomButton()!.click()); + expect(getChart().findZoomCursorPreviousButton()).not.toBe(null); + expect(getChart().findZoomCursorNextButton()).not.toBe(null); + + act(() => getChart().findExitZoomButton()!.click()); + expect(getChart().findZoomCursorPreviousButton()).toBe(null); + expect(getChart().findZoomCursorNextButton()).toBe(null); + }); + + test("zoom cursor buttons move the cursor and are disabled at the ends of the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + // The cursor starts on the first point, so it cannot move any further towards the start. + expect(getChart().findZoomCursorPreviousButton()!.getElement()).toHaveProperty("disabled", true); + + const next = () => getChart().findZoomCursorNextButton()!.getElement(); + // Move the cursor to x=1 with the button, then set the start of the range. + act(() => next().click()); + pressChartKey("Enter"); + // Move the cursor to x=2 with the button, then set the end of the range, applying the zoom. + act(() => next().click()); + pressChartKey("Enter"); + expect(getXExtremes()).toEqual({ min: 1, max: 2 }); + }); + + test("zoom cursor buttons step back towards the start of the range", () => { + renderCartesianChart({ ...defaultProps, zoom: { enabled: true } }); + act(() => getChart().findZoomButton()!.click()); + // Move to the last point, where the cursor cannot move any further towards the end. + for (let i = 0; i < 4; i++) { + act(() => getChart().findZoomCursorNextButton()!.getElement().click()); + } + expect(getChart().findZoomCursorNextButton()!.getElement()).toHaveProperty("disabled", true); + + // Step back to x=3 and zoom from there to the last point. + act(() => getChart().findZoomCursorPreviousButton()!.getElement().click()); + pressChartKey("Enter"); + act(() => getChart().findZoomCursorNextButton()!.getElement().click()); + pressChartKey("Enter"); + expect(getXExtremes()).toEqual({ min: 3, max: 4 }); + }); + + test("a range selected by dragging is reported but not applied when the range is controlled", () => { + renderCartesianChart({ + ...defaultProps, + zoom: { enabled: true }, + zoomRange: null, + onZoomRangeChange, + }); + // Dragging across the plot makes Highcharts apply the extremes itself. The consumer owns the range + // here and ignores the event, so the chart must stay at the full range it was given. + act(() => { + getCurrentChart().xAxis[0].setExtremes(1, 3, true, false, { trigger: "zoom" }); + }); + expect(onZoomRangeChange).toHaveBeenCalledWith( + expect.objectContaining({ detail: { zoomRange: { x: { startValue: 1, endValue: 3 } } } }), + ); + expect(getXExtremes()).toEqual({ min: 0, max: 4 }); + }); +}); diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index aaa50670..1433a833 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -1,19 +1,32 @@ // 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 type Highcharts from "highcharts"; 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 { + colorBackgroundButtonNormalActive, + colorBackgroundItemSelected, + colorBorderItemSelected, +} 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/zoom-cursor-buttons/direction-button"; +import PortalOverlay from "../internal/components/zoom-cursor-buttons/portal-overlay"; import { fireNonCancelableEvent } from "../internal/events"; +import { getChartSeries, getSeriesData } from "../internal/utils/highcharts"; import { castArray, SomeRequired } from "../internal/utils/utils"; import { transformCartesianSeries } from "./chart-series-cartesian"; import { CartesianChartProps, NonErrorBarSeriesOptions } from "./interfaces"; +import styles from "./styles.css.js"; import testClasses from "./test-classes/styles.css.js"; interface InternalCartesianChartProps extends InternalBaseComponentProps, CartesianChartProps { @@ -21,19 +34,283 @@ 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"; +// Boundary lines drawn at the edges of the native drag-to-zoom selection, so dragging shows the same +// dividers as a click/keyboard selection. +const ZOOM_DRAG_START_LINE_ID = "awsui-zoom-drag-start"; +const ZOOM_DRAG_END_LINE_ID = "awsui-zoom-drag-end"; + +// Shared style for the vertical zoom divider lines: the range boundary lines shown while the chart +// is zoomed and the selection anchor line shown while selecting. Both use the same dark-blue, 1px, +// solid style so every divider/selection edge matches exactly across the design. +const ZOOM_DIVIDER_COLOR = colorBorderItemSelected; +const ZOOM_DIVIDER_WIDTH = 1; + +// The states a chart with zooming enabled moves through: +// "idle" — not zoomed, showing the "Zoom" button. +// "zoomMode" — showing the "Exit zoom" button, with the tooltip suppressed. A vertical cursor follows +// the pointer and the arrow keys, waiting for the start of the range (click / Enter / Space). +// "selecting" — the start of the range is set, and the highlight band spans from it to the cursor, +// waiting for the end of the range (click / Enter / Space) to apply the zoom. +// "zoomed" — showing the "Reset" button, and the "Zoom" button next to it, so a narrower range can be +// selected without resetting first. Doing so returns to "zoomMode", and cancelling from there +// (Escape / "Exit zoom") comes back here with the zoomed range intact. +// +// The pointer and the keyboard drive the same cursor, so selecting a range never requires dragging, +// satisfying WCAG 2.5.7 (Dragging movements). Whether a zoom is applied is tracked separately by +// `zoomedExtremes`, since zoom mode can be entered on top of an existing zoom. +type ZoomModeState = "idle" | "zoomMode" | "selecting" | "zoomed"; + +// Fill for the in-progress zoom selection band: the "active" step of the selected-item background +// family, one shade stronger than the zoomed-range tint, so the range being selected reads as the more +// prominent of the two whenever both are on screen at once. +const ZOOM_SELECTION_FILL = colorBackgroundButtonNormalActive; + +// Draws (or redraws) the zoom selection highlight band between two x-axis values. The band has no +// border of its own: the vertical divider lines mark its edges. It is drawn above the plot content, so +// the stylesheet gives it a partial fill opacity to keep the series and grid lines underneath visible. +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, + className: styles["zoom-selection-band"], + zIndex: 4, + }); +} + +// Draws (or redraws) the vertical zoom cursor line at the given x-axis value. The cursor is the +// selection line that both the mouse and the keyboard move around while in zoom mode. It uses the +// shared zoom divider style so the selection line matches the range boundary dividers exactly. +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: ZOOM_DIVIDER_COLOR, + width: ZOOM_DIVIDER_WIDTH, + 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; + toValue(pixel: number, paneCoordinates?: boolean): number; +} + +// 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 use the shared zoom divider style 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: ZOOM_DIVIDER_COLOR, width: ZOOM_DIVIDER_WIDTH, zIndex: 3 }); + } +} + +// Draws (or redraws) a vertical divider at each edge of the native drag-to-zoom selection. Highcharts +// renders that selection as a filled rectangle with no border, so without these the drag would be the +// only way of selecting a range that has no lines marking where it starts and ends. +// +// The edges come from the marker rectangle Highcharts computed for this drag frame rather than from +// the raw pointer position, so the dividers line up with the fill they bound instead of drifting from +// it once Highcharts clamps the marker to the plot area. +function drawDragBoundaries(xAxis: ZoomOverlayAxis, startX: number, endX: number, plotLeft: number): void { + clearDragBoundaries(xAxis); + for (const [id, pixelX] of [ + [ZOOM_DRAG_START_LINE_ID, startX], + [ZOOM_DRAG_END_LINE_ID, endX], + ] as const) { + // Plot lines are positioned by axis value, so convert from the marker's pixel edges. `toValue` + // expects a value relative to the plot area, hence subtracting plotLeft. + const value = xAxis.toValue(pixelX - plotLeft, true); + // Above the selection marker (zIndex 7) so the dividers stay visible on top of its fill. + xAxis.addPlotLine({ id, value, color: ZOOM_DIVIDER_COLOR, width: ZOOM_DIVIDER_WIDTH, zIndex: 8 }); + } +} + +// Removes the drag-selection dividers. Safe to call when they are not present. +function clearDragBoundaries(xAxis: ZoomOverlayAxis): void { + xAxis.removePlotLine(ZOOM_DRAG_START_LINE_ID); + xAxis.removePlotLine(ZOOM_DRAG_END_LINE_ID); +} + +// 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`. Used to snap the zoom cursor onto a data +// point when it is driven by the pointer, which reports a continuous position anywhere in the plot, +// including the axis padding before the first point and after the last. The keyboard moves between +// data points, so without this the pointer could select a range the keyboard cannot express. +function nearestXValue(values: number[], target: number): number { + if (values.length === 0) { + return target; + } + return values.reduce((nearest, x) => (Math.abs(x - target) < Math.abs(nearest - target) ? x : nearest)); +} + +// 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 data points. +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); +} + +// Applies a controlled zoom range to the given x-axis, and returns the extremes that were set. A +// missing range means the full data range, which Highcharts expresses as undefined extremes. +function applyControlledZoomRange( + xAxis: Pick, + zoomRange: undefined | null | CartesianChartProps.ZoomRange, +): { min: undefined | number; max: undefined | number } { + const x = zoomRange?.x; + const extremes = x + ? { min: Math.min(x.startValue, x.endValue), max: Math.max(x.startValue, x.endValue) } + : { min: undefined, max: undefined }; + xAxis.setExtremes(extremes.min, extremes.max); + return extremes; +} + +// Returns the x values of all visible data points that are within the axis extremes, sorted. The zoom +// cursor moves between these, so it only ever lands on a point the user can see. +function getVisibleXValues(chart: Highcharts.Chart): number[] { + const xValues = new Set(); + for (const series of getChartSeries(chart)) { + if (series.visible) { + for (const point of getSeriesData(series)) { + xValues.add(point.x); + } + } + } + const { min, max } = chart.xAxis[0].getExtremes(); + return Array.from(xValues) + .filter((x) => (min === undefined || x >= min) && (max === undefined || x <= max)) + .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); + // Ref to the zoom button, so focus can land there when "Reset" is activated and unmounts. Without + // this, focus falls back to the body and keyboard users are returned to the top of the page. + const zoomButtonRef = useRef<{ focus(): void } | null>(null); + // Ref to the exit button, so focus lands on it when zoom mode is entered — it replaces the button + // that was just activated, and is otherwise not reachable by tabbing forward from the plot. + const exitZoomButtonRef = useRef<{ focus(): void } | null>(null); + // Set when the zoom is reset by its button, so the effect below knows to move focus to "Zoom". + const focusZoomButtonRef = useRef(false); + // 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 direction buttons re-position, and mirrored to a ref for the event handlers. + const [cursorX, setCursorX] = useState(null); + const cursorXRef = useRef(null); + cursorXRef.current = cursorX; + // Invisible element the direction buttons anchor to; positioned at the cursor line. It lives + // inside the Highcharts container, which React does not manage, so it is created imperatively + // rather than rendered: a React-owned node moved out of its rendered parent breaks unmounting. + const cursorTrackRef = useRef(null); + if (!cursorTrackRef.current && typeof document !== "undefined") { + const track = document.createElement("div"); + track.setAttribute("aria-hidden", "true"); + track.className = styles["zoom-cursor-track"]; + cursorTrackRef.current = track; + } + // Detach the track element when the component goes away, since React will not do it for us. + useEffect(() => { + const track = cursorTrackRef.current; + return () => track?.remove(); + }, []); + // The direction buttons are shown whenever we are in zoom mode with a placed cursor. + const inZoomSelection = (zoomMode === "zoomMode" || zoomMode === "selecting") && cursorX !== null; + // First and last point the cursor can reach, captured when zoom mode is entered. The direction that + // cannot move any further is disabled, rather than silently doing nothing. + const [cursorRange, setCursorRange] = useState(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 +322,538 @@ 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}`), + zoomModeExitedAnnouncementText: i18nStrings?.zoomModeExitedAnnouncementText ?? "Zoom mode cancelled", + zoomResetAnnouncementText: i18nStrings?.zoomResetAnnouncementText ?? "Zoom reset. Showing the full data range.", + }), + [i18nStrings], + ); + + const zoomEnabled = !!props.zoom?.enabled; + // Providing the zoomRange property puts the zoomed range under the consumer's control: the chart + // then reports the ranges the user selects, but only zooms when the property changes. + const isZoomRangeControlled = props.zoomRange !== undefined; + + // Zooming needs data points to select between, so the controls are hidden when every series is + // hidden — the chart is then showing its no-data state, and entering zoom mode would give a + // cursor with nothing to land on. Threshold series are excluded: they span the whole axis and + // define no points of their own, so a chart showing only thresholds is not zoomable either. + const hasZoomableData = props.series.some( + (s) => s.type !== "x-threshold" && s.type !== "y-threshold" && visibleSeriesState.includes(getOptionsId(s)), + ); + const zoomControlsAvailable = zoomEnabled && hasZoomableData; + + // 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 }); + } else { + // In controlled mode the extremes are the consumer's to apply, but the selection is over + // either way: leave the selection states, so the tooltip returns and the chart stops + // treating clicks as range points. The settled state depends on whether a range is + // currently applied — the consumer may ignore the event, or re-apply the same range, in + // which case the zoomRange effect would not run and nothing else would move us out. + setZoomMode(zoomedExtremesRef.current ? "zoomed" : "idle"); + } + 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); + // Returning to the full range is a change to what the chart shows, so it is announced rather + // than left silent. + setLiveAnnouncement(i18n.zoomResetAnnouncementText); + fireNonCancelableEvent(props.onZoomRangeChange, { zoomRange: null }); + }, [i18n, props.onZoomRangeChange, props.zoomRange]); + + // Resetting from the button removes that button, so focus is moved to "Zoom", which replaces it in + // the same position. Requested here and performed by the effect below, once the button has rendered. + const resetZoomFromButton = useCallback(() => { + focusZoomButtonRef.current = true; + resetZoom(); + }, [resetZoom]); + + // 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 (props.zoom?.hideButtons) { + prevZoomModeRef.current = zoomMode; + return; + } + if (prevZoomModeRef.current !== "zoomed" && zoomMode === "zoomed") { + resetButtonRef.current?.focus(); + } + // Entering zoom mode: land on "Exit zoom", the control that just replaced the button the user + // activated. Leaving focus on the chart plot instead makes the exit unreachable by Tab, since + // the plot wrapper precedes the chart's own focusable data points in the DOM — tabbing forward + // walks into the series rather than out to the button. The keydown listener is scoped to an + // ancestor of this button, so the arrow keys still reach it from here. + if (prevZoomModeRef.current !== "zoomMode" && zoomMode === "zoomMode") { + exitZoomButtonRef.current?.focus(); + } + // Leaving the zoomed state via the "Reset" button: that button is gone, so focus moves to the + // "Zoom" button now occupying its place, rather than being dropped to the document. + if (prevZoomModeRef.current === "zoomed" && zoomMode === "idle" && focusZoomButtonRef.current) { + zoomButtonRef.current?.focus(); + } + focusZoomButtonRef.current = false; + prevZoomModeRef.current = zoomMode; + }, [zoomMode, props.zoom?.hideButtons]); + + // Controlled zoom range: the consumer owns the range, so the chart follows the zoomRange property. + useEffect(() => { + if (chartReady && apiRef.current && isZoomRangeControlled) { + const { min, max } = applyControlledZoomRange(apiRef.current.chart.xAxis[0], props.zoomRange); + setZoomMode(min === undefined ? "idle" : "zoomed"); + setZoomedExtremes(min === undefined || max === undefined ? null : { min, max }); + } + }, [chartReady, isZoomRangeControlled, props.zoomRange]); + + // Keep the element the direction buttons are anchored to 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; + } + // toPixels returns a left-to-right offset, so it is applied as a physical inset: an inline inset + // would be measured from the opposite edge in right-to-left rendering. + cursorTrackRef.current.style.left = `${chart.xAxis[0].toPixels(cursorX, false)}px`; + cursorTrackRef.current.style.top = `${chart.plotTop + chart.plotHeight}px`; + }, [cursorX, inZoomSelection]); + + const enterZoomMode = useCallback(() => { + const chart = apiRef.current?.chart; + const visibleXValues = chart ? getVisibleXValues(chart) : []; + // Nothing to select between: entering zoom mode would show a cursor with nowhere to land. This + // also guards the ref method, which consumers can call regardless of what the chart is showing. + if (visibleXValues.length === 0) { + return; + } + // Start the cursor on the first visible point so it has an immediate, visible anchor without the + // user having to hover the chart first. + const initialX = visibleXValues[0] ?? chart?.xAxis[0].getExtremes().min ?? null; + setZoomMode("zoomMode"); + setZoomAnchor(null); + setCursorX(initialX); + setCursorRange( + visibleXValues.length > 0 + ? { first: visibleXValues[0], last: visibleXValues[visibleXValues.length - 1] } + : null, + ); + 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); + // Leaving zoom mode is announced, so it is clear the selection was abandoned. Clearing the + // announcement instead would leave the exit silent. + setLiveAnnouncement(i18n.zoomModeExitedAnnouncementText); + }, [i18n]); + + // Moves the zoom cursor to an absolute x value, shared by the pointer, the arrow keys, and the + // direction 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 towards the inline start (-1) or end (+1), driven by the + // arrow keys and the direction buttons. + const stepCursor = useCallback( + (direction: -1 | 1) => { + const chart = apiRef.current?.chart; + if (!chart || cursorXRef.current === null) { + return; + } + const next = stepXValue(getVisibleXValues(chart), 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], + ); + + // True while a range is being selected inside the chart (zoom mode entered, with or without a + // start point placed yet), as opposed to dragging or the settled idle/zoomed states. + const isSelectingZoom = zoomMode === "zoomMode" || zoomMode === "selecting"; + + // Series can be hidden while a range is being selected, leaving the cursor with nothing to land on + // and the chart in its no-data state. Leave zoom mode in that case, rather than keeping a selection + // the user can no longer complete. Any range already applied is preserved. + useEffect(() => { + if (isSelectingZoom && !hasZoomableData) { + exitZoomMode(); + } + }, [isSelectingZoom, hasZoomableData, exitZoomMode]); + + // 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: ZOOM_DIVIDER_COLOR, + width: ZOOM_DIVIDER_WIDTH, + 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). + // The affordance stays visible while zoom mode is re-entered on top of an existing zoom: the + // selection band is the stronger shade of the same tint, so it reads as a range being picked + // inside the range already in view. + useEffect(() => { + const xAxis = apiRef.current?.chart.xAxis[0]; + if (!xAxis) { + return; + } + if (zoomedExtremes) { + drawZoomRangeBoundaries(xAxis, zoomedExtremes.min, zoomedExtremes.max); + } else { + clearZoomRangeBoundaries(xAxis); + } + }, [zoomedExtremes, chartReady]); + + // Drag-to-zoom: mirror the selection dividers onto the native drag selection. Highcharts draws + // that selection as a bare filled rectangle, so we track the drag ourselves and draw a vertical + // line at each edge, matching what a click/keyboard selection shows. Active whenever zoom is + // enabled and no in-chart selection is in progress, since dragging works outside zoom mode too. + useEffect(() => { + if (!zoomEnabled || !chartReady || !apiRef.current || isSelectingZoom) { + return; + } + const chart = apiRef.current.chart; + const highcharts = apiRef.current.highcharts; + const xAxis = chart.xAxis[0]; + + // Highcharts fires "getSelectionMarkerAttrs" once per drag frame with the rectangle it is about + // to give the selection marker, so the drag threshold and the clamping to the plot area are + // already applied by the time we see it. A plain click never reaches this event, so it never + // flashes a pair of dividers. + // + // Listeners run before the default handler that fills in `attrs`, so the rectangle is only + // readable once the event has finished dispatching — hence reading it back on a microtask. + const pointer = chart.pointer; + let disposed = false; + const removeDragListener = highcharts.addEvent(pointer, "getSelectionMarkerAttrs", (e: unknown) => { + const { attrs } = e as { attrs?: { x?: number; width?: number } }; + Promise.resolve().then(() => { + if (disposed || typeof attrs?.x !== "number" || typeof attrs?.width !== "number") { + return; + } + drawDragBoundaries(xAxis, attrs.x, attrs.x + attrs.width, chart.plotLeft); + }); + }); + + // The selection marker is destroyed when the drag ends, whether or not a zoom was applied. + const removeDropListener = highcharts.addEvent(chart, "selection", () => clearDragBoundaries(xAxis)); + const onMouseUp = () => clearDragBoundaries(xAxis); + // The release can land outside the chart, so this is bound at the document level — but the + // chart's own document, so it still fires when rendered inside an iframe. + const ownerDocument = chart.container.ownerDocument; + ownerDocument.addEventListener("mouseup", onMouseUp); + + return () => { + disposed = true; + ownerDocument.removeEventListener("mouseup", onMouseUp); + // Highcharts may already have destroyed the chart by the time this runs (it nulls out the + // pointer and the axes), and reaching into the remains to detach listeners or clear plot + // lines throws. Nothing needs cleaning up in that case: the chart took the overlays with it. + if (!chart.pointer) { + return; + } + removeDragListener(); + removeDropListener(); + clearDragBoundaries(xAxis); + }; + }, [zoomEnabled, chartReady, isSelectingZoom]); + + // 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; + + // Show a grabbing cursor while a range is being selected, matching the affordance the chart uses + // for dragging elsewhere. The chart sets the cursor imperatively as the pointer moves, so this is + // re-applied on each move rather than left to the stylesheet. + const applyZoomModeCursor = () => { + container.style.cursor = zoomAnchorRef.current === null ? "grab" : "grabbing"; + }; + + // Converts a pointer position to the x value of the nearest data point. The pointer reports a + // continuous position, so without snapping it could place the cursor between points, or in the + // axis padding outside the data altogether — neither of which the keyboard can reach. Both + // inputs therefore land on the same set of positions. + const pointerToXValue = (chartX: number) => nearestXValue(getVisibleXValues(chart), xAxis.toValue(chartX, false)); + + // 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; + } + // The chart tracks the pointer to highlight the nearest group, which draws a cursor line of + // its own and sets the cursor style. Disabling the tooltip does not stop it, so clear the + // highlight here: otherwise it trails the zoom cursor as a second, grey line snapped to the + // nearest data point, and the pointer keeps the "pointer" style used to indicate a tooltip. + apiRef.current?.clearChartHighlight(); + applyZoomModeCursor(); + moveCursorTo(pointerToXValue(normalized.chartX)); + }; + + // Click: set the start or end point at the nearest data point. + 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 = pointerToXValue(chartX); + moveCursorTo(value); + commitPoint(value); + }; + + // Keyboard: arrows move the cursor, Enter/Space set a point, Escape cancels zoom mode. + // Scoped to the chart container rather than the document, so a second chart in zoom mode, or a + // dialog opened over this one, does not receive these keys. Propagation is stopped for the keys + // we consume: the chart's own keyboard navigation handles the same keys, and would otherwise + // move the focused point while the zoom cursor moves. + const onKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + e.preventDefault(); + e.stopPropagation(); + stepCursor(1); + break; + case "ArrowLeft": + e.preventDefault(); + e.stopPropagation(); + stepCursor(-1); + break; + case "Enter": + case " ": + e.preventDefault(); + e.stopPropagation(); + commitPoint(); + break; + case "Escape": + e.preventDefault(); + e.stopPropagation(); + exitZoomMode(); + break; + } + }; + + // The element the keydown listener is attached to. It has to cover three things that are + // siblings rather than nested: the Highcharts container, the `role="application"` element used + // for the chart's own keyboard navigation, and the zoom controls (which render into the chart's + // footer slot). Focus can be on any of them while a range is being selected, so the listener is + // attached to the nearest ancestor holding them all. + const findKeyboardScope = (): HTMLElement => { + let element = container.parentElement; + let applicationScope: null | HTMLElement = null; + while (element) { + const hasApplication = !!element.querySelector('[role="application"]'); + const hasZoomControls = !!element.querySelector(`.${styles["zoom-controls"]}`); + if (hasApplication && hasZoomControls) { + return element; + } + // Remember the smallest scope covering keyboard navigation, in case the zoom controls are + // hidden and there is nothing wider to find. + if (hasApplication && !applicationScope) { + applicationScope = element; + } + element = element.parentElement; + } + return applicationScope ?? container; + }; + const keyboardScope = findKeyboardScope(); + + // Entering zoom mode replaces the "Zoom" button with "Exit zoom", so the element that was + // clicked is unmounted and focus falls back to the body. Make the scope focusable and focus it, + // so the scoped listener below receives the keys. The tabindex is removed on cleanup, leaving + // the chart's own focus handling untouched outside zoom mode. + const hadTabIndex = keyboardScope.hasAttribute("tabindex"); + if (!hadTabIndex) { + keyboardScope.setAttribute("tabindex", "-1"); + } + // Keep the scope out of the focus ring visually: it is focused programmatically, and the zoom + // cursor is the visible indication of where the interaction is. + keyboardScope.style.outline = "none"; + // Prefer the "Exit zoom" button, which lives inside this scope, so the keys still reach the + // listener while the user has a real, visible, tabbable control to act on. Tabbing forward from + // the plot wrapper walks into the chart's own data points instead of reaching the button, which + // would leave the exit unreachable. Falls back to the scope when the buttons are hidden. + if (!keyboardScope.contains(document.activeElement)) { + keyboardScope.focus({ preventScroll: true }); + } + + // 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); + // Capture phase, so the keys are handled before the chart's own navigation listener on the + // application element inside this scope. + keyboardScope.addEventListener("keydown", onKeyDown, true); + applyZoomModeCursor(); + + return () => { + clearTimeout(timeoutId); + container.removeEventListener("click", onClick); + container.removeEventListener("mousemove", onMouseMove); + keyboardScope.removeEventListener("keydown", onKeyDown, true); + if (!hadTabIndex) { + keyboardScope.removeAttribute("tabindex"); + } + keyboardScope.style.outline = ""; + // Hand the cursor back to the chart, which sets it as the pointer moves over the series. + container.style.cursor = ""; + }; + }, [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 +872,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 +885,204 @@ 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). The live region is + // rendered separately, below, so announcements are made whether or not the built-in buttons are + // shown: a consumer using `hideButtons` with its own controls still needs them. + const zoomModeButton = + zoomControlsAvailable && !props.zoom?.hideButtons ? ( +
+ {/* 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; + + // The tooltip is suppressed while a range is being selected: it would sit under the pointer and + // compete with the selection. + const effectiveTooltip = isSelectingZoom ? { ...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} - /> + <> + {/* Announcements are tied to zooming being enabled, not to the built-in buttons being shown, + so screen reader users get them when the consumer supplies its own controls too. */} + {zoomEnabled && } + { + apiRef.current = api; + setChartReady(true); + // Attach the cursor-tracking element to the chart container, so the direction buttons can be + // positioned relative to the plot through the portal overlay. The element is created and + // removed imperatively (see the effect above) rather than rendered by React: moving a + // React-owned node into a container React does not manage makes React unmount it from a + // parent that no longer holds it, which throws. + 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" }, + // Match the drag-to-zoom marker to the click/keyboard selection band, so both + // ways of selecting a range look the same. Highcharts would otherwise use its + // own highlight color here. + selectionMarkerFill: ZOOM_SELECTION_FILL, + 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") { + return; + } + const zoomed = !!(e.userMin || e.userMax); + // Dragging across the plot makes Highcharts apply the extremes itself. In + // controlled mode the range belongs to the consumer, so the drag is reported + // and then undone, leaving the zoomRange property to drive the chart. + if (isZoomRangeControlled) { + // Announce the range the drag selected, as the uncontrolled path does. The + // extremes are the consumer's to apply, but the selection itself is + // something the user just did and needs confirming either way. + setLiveAnnouncement( + zoomed + ? i18n.zoomRangeChangeAnnouncementText(formatXValue(e.min), formatXValue(e.max)) + : i18n.zoomResetAnnouncementText, + ); + fireNonCancelableEvent(props.onZoomRangeChange, { + zoomRange: zoomed ? { x: { startValue: e.min, endValue: e.max } } : null, + }); + applyControlledZoomRange(this, props.zoomRange); + return; + } + 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); + // Highcharts also reports a drag that resets the range (a click-sized drag, + // or its own reset). Announce that, rather than leaving it silent. + setLiveAnnouncement(i18n.zoomResetAnnouncementText); + 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} + /> + {/* Zero-size element the buttons overlay is anchored to, kept in sync with the cursor line. */} + {/* The cursor-track element is not rendered here: it is created imperatively and appended + into the Highcharts container, which React does not manage. See the ref declaration. */} + {/* Pointer and touch alternative for moving the zoom cursor, for users who cannot drag. */} + + {inZoomSelection && ( + <> + stepCursor(-1)} + /> + = cursorRange.last} + onClick={() => stepCursor(1)} + /> + + )} + + ); }, ); diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 87bc0210..6b13c216 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -109,6 +109,35 @@ export interface CartesianChartProps */ sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[]; + /** + * Zoom settings, allowing the users to zoom into a range of the x-axis. Zooming is possible by dragging + * across the chart plot, or by entering zoom mode with the "Zoom" button and selecting the range start and + * end with a click, Enter, or Space. In zoom mode the tooltip is suppressed, Escape or the "Exit zoom" + * button cancels the selection, and the "Reset" button restores the full data range once zoomed. + * + * Supported options: + * * `enabled` (optional, boolean) - Enables zooming. Defaults to `false`. + * * `hideButtons` (optional, boolean) - Hides the built-in zoom buttons. Use it when providing custom + * controls, that use the `enterZoomMode`, `exitZoomMode`, and `resetZoom` methods of the component's ref. + */ + zoom?: CartesianChartProps.ZoomOptions; + + /** + * The zoomed range of the x-axis. By default, the range is managed by the component. When using this property, + * manage state updates with `onZoomRangeChange`, and use `null` to show the full data range. + * + * Supported options: + * * `x` (optional, object) - The zoomed x-axis range, as `startValue` and `endValue`. For datetime axes the + * values are timestamps in milliseconds. + */ + zoomRange?: CartesianChartProps.ZoomRange | null; + + /** + * A callback function, triggered when the zoomed range changes as a result of user interaction with the chart + * or the zoom controls. The detail's `zoomRange` is `null` when the zoom is reset to the full data range. + */ + onZoomRangeChange?: NonCancelableEventHandler; + /** * 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`. @@ -132,6 +161,19 @@ export namespace CartesianChartProps { * Use this when implementing clear-filter actions in no-match states. */ showAllSeries(): void; + /** + * Enters zoom mode, in which the tooltip is suppressed and clicks on the chart set the start and end of + * the range to zoom into. Requires zooming to be enabled with the `zoom` property. + */ + enterZoomMode(): void; + /** + * Exits zoom mode, discarding the range being selected. Any range the chart is already zoomed into is kept. + */ + exitZoomMode(): void; + /** + * Resets the zoom to show the full data range. + */ + resetZoom(): void; } export type SeriesOptions = @@ -219,6 +261,21 @@ export namespace CartesianChartProps { export type FilterOptions = CoreTypes.BaseFilterOptions; export type NoDataOptions = CoreTypes.BaseNoDataOptions; + + export interface ZoomOptions { + enabled?: boolean; + hideButtons?: boolean; + } + + // The range is nested under the axis it applies to, leaving room for a "y" range should zooming + // along the y-axis be supported later, without a breaking change to the property shape. + export interface ZoomRange { + x?: { startValue: number; endValue: number }; + } + + export interface ZoomChangeDetail { + zoomRange: ZoomRange | null; + } } // Internal types diff --git a/src/cartesian-chart/styles.scss b/src/cartesian-chart/styles.scss new file mode 100644 index 00000000..3dd5fec1 --- /dev/null +++ b/src/cartesian-chart/styles.scss @@ -0,0 +1,34 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use "../../node_modules/@cloudscape-design/design-tokens/index.scss" as cs; + +// The zoom controls are rendered over the chart plot, in its top inline-end corner. +.zoom-controls { + position: absolute; + inset-block-start: 0; + inset-inline-end: cs.$space-static-xs; + // Above the plot so the buttons stay clickable over the rendered series. + z-index: 1; +} + +// The band marking the range being selected is drawn above the plot content, so it is only partially +// opaque, keeping the series and grid lines underneath visible. The same applies to the marker +// Highcharts renders while dragging across the plot, which is filled with the same color. +.zoom-selection-band, +// stylelint-disable-next-line selector-class-pattern +:global(.highcharts-selection-marker) { + fill-opacity: 0.5; +} + +// Zero-size anchor for the zoom cursor buttons overlay. It is positioned imperatively, following the +// cursor line, and is never visible itself. +.zoom-cursor-track { + position: absolute; + inline-size: 1px; + block-size: 1px; + pointer-events: none; + opacity: 0; +} 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 9c910817..5b26416b 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -160,6 +160,22 @@ 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". + * * `zoomCursorPreviousButtonAriaLabel` (optional, string) - Accessible label for the button that moves the zoom cursor to the previous data point. + * * `zoomCursorNextButtonAriaLabel` (optional, string) - Accessible label for the button that moves the zoom cursor to the next data point. + * * `zoomModeEnteredAnnouncementText` (optional, function) - Screen reader announcement when zoom mode is entered. Receives the formatted cursor value. + * * `zoomCursorPositionAnnouncementText` (optional, function) - Screen reader announcement when the zoom cursor moves. Receives the formatted cursor value. + * * `zoomStartPointAnnouncementText` (optional, function) - Screen reader announcement when the start of the range is set. Receives the formatted start value. + * * `zoomRangeChangeAnnouncementText` (optional, function) - Screen reader announcement when the zoom range changes. Receives the formatted start and end values. + * * `zoomModeExitedAnnouncementText` (optional, string) - Screen reader announcement when zoom mode is exited without zooming. + * * `zoomResetAnnouncementText` (optional, string) - Screen reader announcement when the zoom is reset to the full data range. + * * `zoomSelectionAnnouncementText` (optional, function) - Screen reader announcement while the range is being selected. Receives the formatted start and end values. */ i18nStrings?: CartesianI18nStrings; } @@ -187,6 +203,38 @@ 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 when zoom mode is exited without zooming. @defaultValue "Zoom mode cancelled" */ + zoomModeExitedAnnouncementText?: string; + /** Screen reader announcement when the zoom is reset to the full data range. @defaultValue "Zoom reset. Showing the full data range." */ + zoomResetAnnouncementText?: 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/zoom-cursor-buttons/direction-button.tsx b/src/internal/components/zoom-cursor-buttons/direction-button.tsx new file mode 100644 index 00000000..e055deaf --- /dev/null +++ b/src/internal/components/zoom-cursor-buttons/direction-button.tsx @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import clsx from "clsx"; + +import Icon, { IconProps } from "@cloudscape-design/components/icon"; + +import styles from "./styles.css.js"; +import testUtilsStyles from "./test-classes/styles.css.js"; + +// Adapted from the drag handle direction buttons in @cloudscape-design/components +// (src/internal/components/drag-handle-wrapper). That component is not exported from the package, so +// the parts needed to move the zoom cursor are reproduced here, reduced to the inline directions and +// without the drag-handle behaviours (pointer tracking, tooltips, viewport-edge repositioning). +export type Direction = "inline-start" | "inline-end"; + +// The icon component flips the left/right icons in right-to-left rendering, so each logical direction +// maps to a single icon name. +const DIRECTION_ICONS: Record = { + "inline-start": "arrow-left", + "inline-end": "arrow-right", +}; + +interface DirectionButtonProps { + direction: Direction; + ariaLabel: string; + disabled?: boolean; + onClick: React.MouseEventHandler; +} + +export default function DirectionButton({ direction, ariaLabel, disabled, onClick }: DirectionButtonProps) { + return ( + + ); +} diff --git a/src/internal/components/zoom-cursor-buttons/portal-overlay.tsx b/src/internal/components/zoom-cursor-buttons/portal-overlay.tsx new file mode 100644 index 00000000..5660e921 --- /dev/null +++ b/src/internal/components/zoom-cursor-buttons/portal-overlay.tsx @@ -0,0 +1,91 @@ +// 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"; + +// Adapted from @cloudscape-design/components (src/internal/components/drag-handle-wrapper), which does +// not export it. Renders its children in a portal, kept aligned with the tracked element, so the zoom +// cursor buttons are not clipped by the chart's own overflow. +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 = () => { + // Read the document from the tracked element rather than the global, so positioning stays + // correct when the chart is rendered in another document (an iframe, or a test harness). + const ownerDocument = ref.current?.ownerDocument ?? document; + if (track.current && ref.current && ownerDocument.body.contains(ref.current)) { + const isRtl = getIsRtl(ref.current); + const { insetInlineStart, insetBlockStart, inlineSize, blockSize } = getLogicalBoundingClientRect( + track.current, + ); + const newX = (insetInlineStart + getScrollInlineStart(ownerDocument.documentElement)) * (isRtl ? -1 : 1); + const newY = insetBlockStart + ownerDocument.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/zoom-cursor-buttons/styles.scss b/src/internal/components/zoom-cursor-buttons/styles.scss new file mode 100644 index 00000000..cf3828f6 --- /dev/null +++ b/src/internal/components/zoom-cursor-buttons/styles.scss @@ -0,0 +1,112 @@ +/* + 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 cs; +@use "@cloudscape-design/component-toolkit/internal/focus-visible" as focus-visible; + +$direction-button-size: cs.$space-static-xl; +// Half of the gap between the two buttons: each is offset by this much from the tracked position. +$direction-button-gap: cs.$space-static-xxs; + +.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; +} + +.direction-button { + position: absolute; + box-sizing: border-box; + // Flex-centered rather than inline-block: the icon is a glyph on the text baseline, which in a + // round, fixed-size button leaves it visibly off-centre. + display: flex; + align-items: center; + justify-content: center; + border-width: 0; + cursor: pointer; + + // This skips the browser waiting for a double-tap interaction before activating. + touch-action: manipulation; + + inline-size: $direction-button-size; + block-size: $direction-button-size; + // The flex centering above places the icon, so no padding is needed to position it. + padding-block: 0; + padding-inline: 0; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + + // The direction button color family is not part of the public design tokens, so the closest + // published tokens stand in for it: the layout toggle shares this button's dark, circular styling. + background-color: cs.$color-background-layout-toggle-default; + color: cs.$color-text-button-primary-default; + box-shadow: cs.$shadow-card; + + &:hover { + background-color: cs.$color-background-layout-toggle-hover; + } + + &:active { + background-color: cs.$color-background-layout-toggle-active; + } + + @include focus-visible.when-visible { + @include styles.focus-highlight(2px); + // focus-highlight sets `position: relative` for its ::before ring. These buttons are positioned + // absolutely against the cursor track, so without restoring that they jump out of place the + // moment they take focus. + position: absolute; + } +} + +// Wrapper around the icon glyph. `display: flex` collapses its line box, so the glyph is centered by +// the button's flex alignment rather than sitting on a baseline. Matches the upstream drag handle in +// taking itself out of pointer events, so a click always targets the button. +.direction-button-icon { + display: flex; + pointer-events: none; +} + +.direction-button-disabled { + cursor: default; + background-color: cs.$color-background-button-normal-disabled; + color: cs.$color-text-button-normal-disabled; + // The disabled background is white, and these buttons float over the chart plot, so without a border + // the button would have no visible shape at all. + border: cs.$border-width-button solid cs.$color-border-button-normal-disabled; + + &:hover, + &:active { + background-color: cs.$color-background-button-normal-disabled; + } +} + +// The buttons sit side by side, centered on the tracked position, with a gap between them so they read +// as two separate controls. +.direction-button-inline-start { + inset-inline-end: $direction-button-gap; +} + +.direction-button-inline-end { + inset-inline-start: $direction-button-gap; +} diff --git a/src/internal/components/zoom-cursor-buttons/test-classes/styles.scss b/src/internal/components/zoom-cursor-buttons/test-classes/styles.scss new file mode 100644 index 00000000..890f64bc --- /dev/null +++ b/src/internal/components/zoom-cursor-buttons/test-classes/styles.scss @@ -0,0 +1,10 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.direction-button, +.direction-button-inline-start, +.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..2cb3e7c4 100644 --- a/src/test-utils/dom/cartesian-chart/index.ts +++ b/src/test-utils/dom/cartesian-chart/index.ts @@ -1,12 +1,14 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ElementWrapper } from "@cloudscape-design/test-utils-core/dom"; +import { ButtonWrapper } from "@cloudscape-design/components/test-utils/dom"; +import { createWrapper, ElementWrapper } from "@cloudscape-design/test-utils-core/dom"; import BaseChartWrapper from "../internal/base"; import { CartesianChartTooltipWrapper } from "./tooltip"; import testClasses from "../../../cartesian-chart/test-classes/styles.selectors.js"; +import zoomCursorClasses from "../../../internal/components/zoom-cursor-buttons/test-classes/styles.selectors.js"; export default class CartesianChartWrapper extends BaseChartWrapper { static rootSelector: string = testClasses.root; @@ -24,4 +26,50 @@ 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); + } + + /** + * Finds the button that moves the zoom cursor to the previous data point. + * Visible while a zoom range is being selected. + */ + public findZoomCursorPreviousButton(): null | ElementWrapper { + return findZoomCursorButton(zoomCursorClasses["direction-button-inline-start"]); + } + + /** + * Finds the button that moves the zoom cursor to the next data point. + * Visible while a zoom range is being selected. + */ + public findZoomCursorNextButton(): null | ElementWrapper { + return findZoomCursorButton(zoomCursorClasses["direction-button-inline-end"]); + } +} + +// The zoom cursor buttons are rendered in a portal, so they are not descendants of the chart and are +// searched for from the document root instead. +function findZoomCursorButton(className: string): null | ElementWrapper { + return createWrapper().findByClassName(className); }