From 3d0b5ea2d1db4cb5e6339a1eacfdfce909696cb8 Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Tue, 5 May 2026 11:13:31 +0200 Subject: [PATCH 1/3] feat: Zoom and navigator --- .../axes-and-thresholds.page.tsx | 36 +++++ pages/common/page-settings.tsx | 1 + pages/common/use-highcharts.ts | 15 +- .../__snapshots__/documenter.test.ts.snap | 118 ++++++++++++++++ .../chart-cartesian-internal.tsx | 131 +++++++++++++++++- src/cartesian-chart/interfaces.ts | 46 ++++++ src/core/interfaces.ts | 14 ++ src/core/styles.scss | 21 +++ 8 files changed, 377 insertions(+), 5 deletions(-) diff --git a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx index a2532ef2..6be633b0 100644 --- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx +++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx @@ -77,6 +77,9 @@ export default function () { + + + ); } @@ -688,3 +691,36 @@ function CategoryDatetime() { /> ); } + +// Zoom + navigator demo: uses Highcharts built-in navigator with drag-to-zoom. +// Reset zoom button is rendered internally by CartesianChart. +function DatetimeLinearWithZoom() { + const { chartProps } = useChartSettings({ stock: true }); + + const now = new Date(); + const seriesData = range(0, 100).map((i) => ({ + x: addDays(now, i).getTime(), + y: Math.floor((pseudoRandom() + i / 50) * 100), + })); + + return ( + ({ x: d.x, y: d.y * 0.6 + Math.floor(pseudoRandom() * 20) })), + }, + { type: "y-threshold", name: "SLA limit", value: 150 }, + ]} + xAxis={{ title: "Time", type: "datetime", valueFormatter: dateFormatter }} + yAxis={{ title: "Count", type: "linear" }} + /> + ); +} diff --git a/pages/common/page-settings.tsx b/pages/common/page-settings.tsx index 0f3e6f6d..a2a4ee1e 100644 --- a/pages/common/page-settings.tsx +++ b/pages/common/page-settings.tsx @@ -82,6 +82,7 @@ export function useChartSettings(null); useEffect(() => { @@ -37,6 +45,9 @@ export function useHighcharts({ if (heatmap) { await import("highcharts/modules/heatmap"); } + if (stock) { + await import("highcharts/modules/stock"); + } if (isDevelopment) { await import("highcharts/modules/debugger"); @@ -46,7 +57,7 @@ export function useHighcharts({ }; load(); - }, [more, xrange, solidgauge, boost, heatmap]); + }, [more, xrange, solidgauge, boost, heatmap, stock]); return highcharts; } diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 4b6863fc..cfebfd83 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -21,6 +21,15 @@ exports[`definition for cartesian-chart matches the snapshot > cartesian-chart 1 "detailType": "{ visibleSeries: Array; }", "name": "onVisibleSeriesChange", }, + { + "cancelable": false, + "description": "Called when the visible zoom range changes due to user interaction (drag-to-zoom, +navigator handles, or reset zoom). + +The detail contains \`startValue\` and \`endValue\` (epoch timestamps for datetime axes), +or \`null\` when zoom is reset to the full range.", + "name": "onZoomChange", + }, ], "functions": [ { @@ -81,6 +90,40 @@ minimum width, the horizontal scrollbar is automatically added.", "optional": true, "type": "number", }, + { + "description": "Enables the built-in Highcharts navigator — a mini chart below the main chart that +provides drag handles for panning and zooming. Requires the Highcharts Stock module +(\`highcharts/modules/stock\`) to be loaded before passing the \`highcharts\` instance. + +Supported options: +* \`enabled\` (boolean) - Enables or disables the navigator. +* \`height\` (optional, number) - Height of the navigator area in pixels. Defaults to 40. +* \`ariaLabel\` (optional, string) - Accessible label for the navigator region.", + "inlineType": { + "name": "CartesianChartProps.NavigatorOptions", + "properties": [ + { + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "name": "enabled", + "optional": false, + "type": "boolean", + }, + { + "name": "height", + "optional": true, + "type": "number", + }, + ], + "type": "object", + }, + "name": "chartNavigator", + "optional": true, + "type": "CartesianChartProps.NavigatorOptions", + }, { "defaultValue": "true", "description": "When set to \`true\`, adds a visual emphasis on the zero baseline axis.", @@ -157,11 +200,42 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "navigatorAriaLabel", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(axisRangeDescription: string) => string", + "parameters": [ + { + "name": "axisRangeDescription", + "type": "string", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "navigatorChangeAnnouncementText", + "optional": true, + "type": "((axisRangeDescription: string) => string)", + }, + { + "name": "navigatorHandleAriaLabel", + "optional": true, + "type": "string", + }, { "name": "recoveryText", "optional": true, "type": "string", }, + { + "name": "resetZoomText", + "optional": true, + "type": "string", + }, { "name": "seriesFilterLabel", "optional": true, @@ -187,6 +261,21 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "zoomControlsAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "zoomLiveAnnouncementText", + "optional": true, + "type": "string", + }, + { + "name": "zoomResetLiveAnnouncementText", + "optional": true, + "type": "string", + }, ], "type": "object", }, @@ -620,6 +709,35 @@ applies to the tooltip points values.", "optional": true, "type": "CartesianChartProps.YAxisOptions | [CartesianChartProps.YAxisWithId, CartesianChartProps.YAxisWithId]", }, + { + "description": "Enables drag-to-zoom on the x-axis. When enabled, users can click and drag on the chart +to select a range, which zooms the chart to that range. Highcharts shows a "Reset zoom" +button to restore the original view. + +Supported options: +* \`type\` (optional, "x") - The axis to zoom on. Currently only "x" is supported.", + "inlineType": { + "name": "CartesianChartProps.ZoomOptions", + "properties": [ + { + "inlineType": { + "name": "x", + "type": "union", + "values": [ + "x", + ], + }, + "name": "type", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "zoom", + "optional": true, + "type": "CartesianChartProps.ZoomOptions", + }, ], "regions": [ { diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index aaa50670..e2737fc7 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -1,9 +1,10 @@ // 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, useRef, useState } from "react"; import { useControllableState } from "@cloudscape-design/component-toolkit"; +import LiveRegion from "@cloudscape-design/components/live-region"; import { InternalCoreChart } from "../core/chart-core"; import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces"; @@ -24,6 +25,9 @@ interface InternalCartesianChartProps extends InternalBaseComponentProps, Cartes export const InternalCartesianChart = forwardRef( ({ tooltip, ...props }: InternalCartesianChartProps, ref: React.Ref) => { const apiRef = useRef(null); + const [isZoomed, setIsZoomed] = useState(false); + const [liveAnnouncement, setLiveAnnouncement] = useState(""); + const resetButtonRef = useRef(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. @@ -33,7 +37,6 @@ export const InternalCartesianChart = forwardRef( 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,6 +48,44 @@ export const InternalCartesianChart = forwardRef( } }; + // i18n defaults for zoom and navigator. + const i18n = { + resetZoomText: props.i18nStrings?.resetZoomText ?? "Reset zoom", + zoomLiveAnnouncementText: + props.i18nStrings?.zoomLiveAnnouncementText ?? + "Chart zoomed in. Use the reset zoom button to restore the full range.", + zoomResetLiveAnnouncementText: + props.i18nStrings?.zoomResetLiveAnnouncementText ?? "Zoom reset. Showing all data.", + navigatorAriaLabel: + props.i18nStrings?.navigatorAriaLabel ?? "Use handles to adjust the time range displayed in the chart", + navigatorHandleAriaLabel: props.i18nStrings?.navigatorHandleAriaLabel ?? "Navigator handle", + navigatorChangeAnnouncementText: + props.i18nStrings?.navigatorChangeAnnouncementText ?? + ((axisRangeDescription: string) => `Range changed: ${axisRangeDescription}`), + zoomControlsAriaLabel: props.i18nStrings?.zoomControlsAriaLabel ?? "Chart zoom controls", + }; + + const resetZoom = useCallback(() => { + apiRef.current?.chart.xAxis[0].setExtremes(undefined, undefined); + setIsZoomed(false); + setLiveAnnouncement(i18n.zoomResetLiveAnnouncementText); + fireNonCancelableEvent(props.onZoomChange, null); + }, [i18n.zoomResetLiveAnnouncementText, props.onZoomChange]); + + const announceZoom = useCallback( + (zoomed: boolean) => { + setLiveAnnouncement(zoomed ? i18n.zoomLiveAnnouncementText : ""); + }, + [i18n.zoomLiveAnnouncementText], + ); + + // Focus the reset button when it appears after a zoom action. + useEffect(() => { + if (isZoomed && resetButtonRef.current) { + resetButtonRef.current.focus(); + } + }, [isZoomed]); + // We convert cartesian tooltip options to the core chart's getTooltipContent callback, // ensuring no internal types are exposed to the consumer-defined render functions. const getTooltipContent: CoreChartProps["getTooltipContent"] = () => { @@ -101,22 +142,105 @@ export const InternalCartesianChart = forwardRef( showAllSeries: () => apiRef.current?.setItemsVisible(allSeriesIds), })); + const zoomEnabled = !!props.zoom?.type; + const navigatorEnabled = !!props.chartNavigator?.enabled; + + // Navigator slot: reset zoom button + Cloudscape LiveRegion for zoom announcements. + const navigatorSlot = + zoomEnabled || navigatorEnabled ? ( +
+ {liveAnnouncement} + {zoomEnabled && isZoomed && ( +
+ +
+ )} +
+ ) : null; + + // Highcharts Stock options (navigator, scrollbar, rangeSelector) passed through the options object. + const stockOptions = navigatorEnabled + ? { + navigator: { + enabled: true, + height: props.chartNavigator!.height ?? 40, + adaptToUpdatedData: true, + accessibility: { enabled: true }, + }, + scrollbar: { enabled: false }, + rangeSelector: { enabled: false }, + lang: { + accessibility: { + navigator: { + groupLabel: i18n.navigatorAriaLabel, + handleLabel: i18n.navigatorHandleAriaLabel, + changeAnnouncement: i18n.navigatorChangeAnnouncementText("{axisRangeDescription}"), + }, + }, + }, + } + : { navigator: { enabled: false }, scrollbar: { enabled: false }, rangeSelector: { enabled: false } }; + return ( (apiRef.current = api)} options={{ chart: { inverted: props.inverted, + ...(zoomEnabled + ? { + zooming: { type: props.zoom!.type ?? "x" }, + // Hide the default Highcharts "Reset zoom" button — we render our own accessible one. + resetZoomButton: { theme: { style: { display: "none" } } }, + } + : {}), }, plotOptions: { series: { stacking: props.stacking }, }, - series, + accessibility: { + enabled: true, + keyboardNavigation: { enabled: true }, + }, + series: series.map((s) => ({ + ...s, + showInNavigator: navigatorEnabled, + })), xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ ...xAxisProps, title: { text: xAxisProps.title }, plotLines: xPlotLines, + ...(zoomEnabled || navigatorEnabled + ? { + events: { + afterSetExtremes(e: { + min: number; + max: number; + trigger?: string; + userMin?: number; + userMax?: number; + }) { + if (e.trigger === "navigator" || e.trigger === "zoom") { + const zoomed = !!(e.userMin || e.userMax); + setIsZoomed(zoomed); + announceZoom(zoomed); + if (zoomed) { + fireNonCancelableEvent(props.onZoomChange, { + startValue: e.min, + endValue: e.max, + }); + } else { + fireNonCancelableEvent(props.onZoomChange, null); + } + } + }, + }, + } + : {}), })), yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({ ...yAxisProps, @@ -124,6 +248,7 @@ export const InternalCartesianChart = forwardRef( plotLines: yPlotLines, ...(index === 1 ? { opposite: true } : {}), })), + ...stockOptions, }} sizeAxis={props.sizeAxis} tooltip={tooltip} diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 9a0d5cfa..67bbdcdf 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -109,6 +109,37 @@ export interface CartesianChartProps */ sizeAxis?: CartesianChartProps.SizeAxisOptions | readonly CartesianChartProps.SizeAxisOptions[]; + /** + * Enables drag-to-zoom on the x-axis. When enabled, users can click and drag on the chart + * to select a range, which zooms the chart to that range. Highcharts shows a "Reset zoom" + * button to restore the original view. + * + * Supported options: + * * `type` (optional, "x") - The axis to zoom on. Currently only "x" is supported. + */ + zoom?: CartesianChartProps.ZoomOptions; + + /** + * Enables the built-in Highcharts navigator — a mini chart below the main chart that + * provides drag handles for panning and zooming. Requires the Highcharts Stock module + * (`highcharts/modules/stock`) to be loaded before passing the `highcharts` instance. + * + * Supported options: + * * `enabled` (boolean) - Enables or disables the navigator. + * * `height` (optional, number) - Height of the navigator area in pixels. Defaults to 40. + * * `ariaLabel` (optional, string) - Accessible label for the navigator region. + */ + chartNavigator?: CartesianChartProps.NavigatorOptions; + + /** + * Called when the visible zoom range changes due to user interaction (drag-to-zoom, + * navigator handles, or reset zoom). + * + * The detail contains `startValue` and `endValue` (epoch timestamps for datetime axes), + * or `null` when zoom is reset to the full range. + */ + onZoomChange?: 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`. @@ -217,6 +248,21 @@ export namespace CartesianChartProps { export type FilterOptions = CoreTypes.BaseFilterOptions; export type NoDataOptions = CoreTypes.BaseNoDataOptions; + + export interface ZoomOptions { + type?: "x"; + } + + export interface NavigatorOptions { + enabled: boolean; + height?: number; + ariaLabel?: string; + } + + export interface ZoomChangeDetail { + startValue: number; + endValue: number; + } } // Internal types diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index df13ce71..68253ff0 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -160,6 +160,13 @@ 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". + * * `resetZoomText` (optional, string) - Label for the reset zoom button, e.g. "Reset zoom". + * * `zoomLiveAnnouncementText` (optional, string) - Screen reader announcement when the chart is zoomed, e.g. "Chart zoomed in. Use the reset zoom button to restore the full range.". + * * `zoomResetLiveAnnouncementText` (optional, string) - Screen reader announcement when zoom is reset, e.g. "Zoom reset. Showing all data.". + * * `navigatorAriaLabel` (optional, string) - Accessible label for the navigator region, e.g. "Use handles to adjust the time range". + * * `navigatorHandleAriaLabel` (optional, string) - Accessible label for navigator drag handles, e.g. "Navigator handle". + * * `navigatorChangeAnnouncementText` (optional, function) - Screen reader announcement when the navigator range changes. Receives `axisRangeDescription` (string) and returns the announcement text, e.g. `(range) => \`Range changed: ${range}\``. + * * `zoomControlsAriaLabel` (optional, string) - Accessible label for the zoom controls region, e.g. "Chart zoom controls". */ i18nStrings?: CartesianI18nStrings; } @@ -187,6 +194,13 @@ export interface WithPieI18nStrings { export interface CartesianI18nStrings extends BaseI18nStrings { xAxisRoleDescription?: string; yAxisRoleDescription?: string; + resetZoomText?: string; + zoomLiveAnnouncementText?: string; + zoomResetLiveAnnouncementText?: string; + navigatorAriaLabel?: string; + navigatorHandleAriaLabel?: string; + navigatorChangeAnnouncementText?: (axisRangeDescription: string) => string; + zoomControlsAriaLabel?: string; } export interface PieI18nStrings extends BaseI18nStrings { diff --git a/src/core/styles.scss b/src/core/styles.scss index d9c5f23d..a90b35fd 100644 --- a/src/core/styles.scss +++ b/src/core/styles.scss @@ -131,3 +131,24 @@ $side-legend-max-inline-size: 30%; // We hide the native focus outline to render a custom one around the chart plot instead. outline: none; } + +// Highcharts built-in navigator accessibility: focus rings on handles and outline. +// The navigator handles are SVG elements that receive keyboard focus via the Highcharts +// accessibility module. We add visible focus indicators for WCAG 2.4.7 compliance. +// stylelint-disable-next-line selector-class-pattern +:global(.highcharts-navigator-handle) { + &:focus { + outline: 2px solid cs.$color-border-item-focused; + outline-offset: 2px; + border-radius: cs.$border-radius-control-default-focus-ring; + } +} + +// stylelint-disable-next-line selector-class-pattern +:global(.highcharts-navigator-mask-inside) { + cursor: grab; + + &:active { + cursor: grabbing; + } +} From 322954462f71660608d555549f3ce3aa26c2528c Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Wed, 20 May 2026 16:15:11 +0200 Subject: [PATCH 2/3] feat: Keyboard controls for charts zoom --- .gitignore | 2 + .../axes-and-thresholds.page.tsx | 6 +- pages/common/page-settings.tsx | 1 - pages/common/use-highcharts.ts | 7 +- .../__snapshots__/documenter.test.ts.snap | 65 +-- .../chart-cartesian-internal.tsx | 491 +++++++++++++----- src/cartesian-chart/interfaces.ts | 23 +- src/core/interfaces.ts | 6 - src/core/styles.scss | 12 - .../drag-handle-wrapper/direction-button.tsx | 86 +++ .../components/drag-handle-wrapper/index.tsx | 206 ++++++++ .../drag-handle-wrapper/interfaces.ts | 18 + .../drag-handle-wrapper/motion.scss | 17 + .../drag-handle-wrapper/portal-overlay.tsx | 85 +++ .../drag-handle-wrapper/styles.scss | 141 +++++ .../test-classes/styles.scss | 32 ++ 16 files changed, 956 insertions(+), 242 deletions(-) create mode 100644 src/internal/components/drag-handle-wrapper/direction-button.tsx create mode 100644 src/internal/components/drag-handle-wrapper/index.tsx create mode 100644 src/internal/components/drag-handle-wrapper/interfaces.ts create mode 100644 src/internal/components/drag-handle-wrapper/motion.scss create mode 100644 src/internal/components/drag-handle-wrapper/portal-overlay.tsx create mode 100644 src/internal/components/drag-handle-wrapper/styles.scss create mode 100644 src/internal/components/drag-handle-wrapper/test-classes/styles.scss 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 6be633b0..0cf75c6f 100644 --- a/pages/01-cartesian-chart/axes-and-thresholds.page.tsx +++ b/pages/01-cartesian-chart/axes-and-thresholds.page.tsx @@ -692,10 +692,9 @@ function CategoryDatetime() { ); } -// Zoom + navigator demo: uses Highcharts built-in navigator with drag-to-zoom. -// Reset zoom button is rendered internally by CartesianChart. +// Zoom demo: drag-to-zoom on x-axis + keyboard zoom (Shift+Arrow to select, Enter/release to zoom). function DatetimeLinearWithZoom() { - const { chartProps } = useChartSettings({ stock: true }); + const { chartProps } = useChartSettings(); const now = new Date(); const seriesData = range(0, 100).map((i) => ({ @@ -709,7 +708,6 @@ function DatetimeLinearWithZoom() { chartHeight={400} legend={{ enabled: true }} zoom={{ type: "x" }} - chartNavigator={{ enabled: true, height: 50, ariaLabel: "Drag handles to adjust the visible time range" }} series={[ { type: "area", name: "Requests", data: seriesData }, { diff --git a/pages/common/page-settings.tsx b/pages/common/page-settings.tsx index a2a4ee1e..0f3e6f6d 100644 --- a/pages/common/page-settings.tsx +++ b/pages/common/page-settings.tsx @@ -82,7 +82,6 @@ export function useChartSettings(null); @@ -45,9 +43,6 @@ export function useHighcharts({ if (heatmap) { await import("highcharts/modules/heatmap"); } - if (stock) { - await import("highcharts/modules/stock"); - } if (isDevelopment) { await import("highcharts/modules/debugger"); @@ -57,7 +52,7 @@ export function useHighcharts({ }; load(); - }, [more, xrange, solidgauge, boost, heatmap, stock]); + }, [more, xrange, solidgauge, boost, heatmap]); return highcharts; } diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index cfebfd83..88b5c292 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -90,40 +90,6 @@ minimum width, the horizontal scrollbar is automatically added.", "optional": true, "type": "number", }, - { - "description": "Enables the built-in Highcharts navigator — a mini chart below the main chart that -provides drag handles for panning and zooming. Requires the Highcharts Stock module -(\`highcharts/modules/stock\`) to be loaded before passing the \`highcharts\` instance. - -Supported options: -* \`enabled\` (boolean) - Enables or disables the navigator. -* \`height\` (optional, number) - Height of the navigator area in pixels. Defaults to 40. -* \`ariaLabel\` (optional, string) - Accessible label for the navigator region.", - "inlineType": { - "name": "CartesianChartProps.NavigatorOptions", - "properties": [ - { - "name": "ariaLabel", - "optional": true, - "type": "string", - }, - { - "name": "enabled", - "optional": false, - "type": "boolean", - }, - { - "name": "height", - "optional": true, - "type": "number", - }, - ], - "type": "object", - }, - "name": "chartNavigator", - "optional": true, - "type": "CartesianChartProps.NavigatorOptions", - }, { "defaultValue": "true", "description": "When set to \`true\`, adds a visual emphasis on the zero baseline axis.", @@ -200,32 +166,6 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, - { - "name": "navigatorAriaLabel", - "optional": true, - "type": "string", - }, - { - "inlineType": { - "name": "(axisRangeDescription: string) => string", - "parameters": [ - { - "name": "axisRangeDescription", - "type": "string", - }, - ], - "returnType": "string", - "type": "function", - }, - "name": "navigatorChangeAnnouncementText", - "optional": true, - "type": "((axisRangeDescription: string) => string)", - }, - { - "name": "navigatorHandleAriaLabel", - "optional": true, - "type": "string", - }, { "name": "recoveryText", "optional": true, @@ -719,6 +659,11 @@ Supported options: "inlineType": { "name": "CartesianChartProps.ZoomOptions", "properties": [ + { + "name": "hideResetButton", + "optional": true, + "type": "boolean", + }, { "inlineType": { "name": "x", diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index e2737fc7..53b7e267 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -10,6 +10,8 @@ import { InternalCoreChart } from "../core/chart-core"; import { CoreChartProps, ErrorBarSeriesOptions } from "../core/interfaces"; import { getOptionsId, isXThreshold } from "../core/utils"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; +import DirectionButton from "../internal/components/drag-handle-wrapper/direction-button"; +import PortalOverlay from "../internal/components/drag-handle-wrapper/portal-overlay"; import { fireNonCancelableEvent } from "../internal/events"; import { castArray, SomeRequired } from "../internal/utils/utils"; import { transformCartesianSeries } from "./chart-series-cartesian"; @@ -22,15 +24,39 @@ interface InternalCartesianChartProps extends InternalBaseComponentProps, Cartes legend: SomeRequired; } +const ZOOM_SELECTION_BAND_ID = "awsui-zoom-selection"; + +function getChartXValues(chart: { + series: readonly { visible: boolean; points?: readonly { x: number }[] }[]; +}): number[] { + const xSet = new Set(); + for (const s of chart.series) { + if (!s.visible) { + continue; + } + for (const p of s.points ?? []) { + xSet.add(p.x); + } + } + return Array.from(xSet).sort((a, b) => a - b); +} + export const InternalCartesianChart = forwardRef( ({ tooltip, ...props }: InternalCartesianChartProps, ref: React.Ref) => { const apiRef = useRef(null); + const [chartReady, setChartReady] = useState(false); const [isZoomed, setIsZoomed] = useState(false); + const [selectionActive, setSelectionActive] = useState(false); const [liveAnnouncement, setLiveAnnouncement] = useState(""); const resetButtonRef = useRef(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. + // Track the currently focused/highlighted point x-value for keyboard zoom anchor. + const highlightedXRef = useRef(null); + const [highlightedX, setHighlightedX] = useState(null); + + // Keyboard zoom selection state. + const zoomSelectionRef = useRef<{ anchor: number; end: number } | null>(null); + useControllableState(props.visibleSeries, props.onVisibleSeriesChange, undefined, { componentName: "CartesianChart", propertyName: "visibleSeries", @@ -48,7 +74,6 @@ export const InternalCartesianChart = forwardRef( } }; - // i18n defaults for zoom and navigator. const i18n = { resetZoomText: props.i18nStrings?.resetZoomText ?? "Reset zoom", zoomLiveAnnouncementText: @@ -56,15 +81,23 @@ export const InternalCartesianChart = forwardRef( "Chart zoomed in. Use the reset zoom button to restore the full range.", zoomResetLiveAnnouncementText: props.i18nStrings?.zoomResetLiveAnnouncementText ?? "Zoom reset. Showing all data.", - navigatorAriaLabel: - props.i18nStrings?.navigatorAriaLabel ?? "Use handles to adjust the time range displayed in the chart", - navigatorHandleAriaLabel: props.i18nStrings?.navigatorHandleAriaLabel ?? "Navigator handle", - navigatorChangeAnnouncementText: - props.i18nStrings?.navigatorChangeAnnouncementText ?? - ((axisRangeDescription: string) => `Range changed: ${axisRangeDescription}`), zoomControlsAriaLabel: props.i18nStrings?.zoomControlsAriaLabel ?? "Chart zoom controls", + zoomSelectionAnnouncementText: + "Hold Shift and press Left or Right arrow to select a zoom range. Press Enter to zoom, Escape to cancel.", }; + const applyZoom = useCallback( + (startValue: number, endValue: number) => { + const min = Math.min(startValue, endValue); + const max = Math.max(startValue, endValue); + apiRef.current?.chart.xAxis[0].setExtremes(min, max); + setIsZoomed(true); + setLiveAnnouncement(i18n.zoomLiveAnnouncementText); + fireNonCancelableEvent(props.onZoomChange, { startValue: min, endValue: max }); + }, + [i18n.zoomLiveAnnouncementText, props.onZoomChange], + ); + const resetZoom = useCallback(() => { apiRef.current?.chart.xAxis[0].setExtremes(undefined, undefined); setIsZoomed(false); @@ -72,31 +105,135 @@ export const InternalCartesianChart = forwardRef( fireNonCancelableEvent(props.onZoomChange, null); }, [i18n.zoomResetLiveAnnouncementText, props.onZoomChange]); - const announceZoom = useCallback( - (zoomed: boolean) => { - setLiveAnnouncement(zoomed ? i18n.zoomLiveAnnouncementText : ""); - }, - [i18n.zoomLiveAnnouncementText], - ); - - // Focus the reset button when it appears after a zoom action. + // Focus the reset button when it appears. useEffect(() => { if (isZoomed && resetButtonRef.current) { resetButtonRef.current.focus(); } }, [isZoomed]); - // We convert cartesian tooltip options to the core chart's getTooltipContent callback, - // ensuring no internal types are exposed to the consumer-defined render functions. + // Helper to update the selection band from the current ref state. + const updateSelectionBandFromRef = useCallback(() => { + if (!zoomSelectionRef.current || !apiRef.current) { + return; + } + const chart = apiRef.current.chart; + const xAxis = chart.xAxis[0]; + xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID); + xAxis.addPlotBand({ + id: ZOOM_SELECTION_BAND_ID, + from: Math.min(zoomSelectionRef.current.anchor, zoomSelectionRef.current.end), + to: Math.max(zoomSelectionRef.current.anchor, zoomSelectionRef.current.end), + color: "rgba(51, 122, 183, 0.2)", + borderColor: "rgba(51, 122, 183, 0.8)", + borderWidth: 1, + zIndex: 4, + }); + }, []); + + // Helper to get all x values from the chart (for direction button handlers). + const getAllXValues = useCallback( + (chart: { series: readonly { visible: boolean; points?: readonly { x: number }[] }[] }) => { + return getChartXValues(chart); + }, + [], + ); + + // Keyboard zoom: Shift+Arrow to select range, Enter/Shift-release to confirm, Escape to cancel. + const zoomEnabled = !!props.zoom?.type; + useEffect(() => { + if (!zoomEnabled || !chartReady || !apiRef.current) { + return; + } + const chart = apiRef.current.chart; + // Find the application element — it's the keyboard focus target for chart navigation. + // Walk up from chart.container until we find an ancestor that contains [role="application"]. + let ancestor: HTMLElement | null = chart.container; + let appEl: HTMLElement | null = null; + for (let i = 0; i < 10 && ancestor && !appEl; i++) { + ancestor = ancestor.parentElement; + appEl = ancestor?.querySelector('[role="application"]') ?? null; + } + if (!appEl) { + return; + } + // Keep a reference that won't go stale (the navigation system removes/re-appends the element). + const chartAppEl = appEl; + + function getXValues(): number[] { + return getChartXValues(chart); + } + + function clearSelectionBand() { + chart.xAxis[0].removePlotBand(ZOOM_SELECTION_BAND_ID); + zoomSelectionRef.current = null; + setSelectionActive(false); + } + + function handleKeyDown(event: KeyboardEvent) { + // Only handle events from THIS chart's application element. + if (event.target !== chartAppEl) { + return; + } + + if (event.shiftKey && (event.key === "ArrowRight" || event.key === "ArrowLeft")) { + // Start selection mode if not already active. + if (!zoomSelectionRef.current) { + const anchor = highlightedXRef.current ?? getXValues()[0]; + zoomSelectionRef.current = { anchor, end: anchor }; + setSelectionActive(true); + setLiveAnnouncement(i18n.zoomSelectionAnnouncementText); + } + // Do NOT stopPropagation — let the navigation handler move focus to the next point. + // The onHighlight callback will update the selection end. + return; + } + + if (event.key === "Enter" && zoomSelectionRef.current) { + event.preventDefault(); + event.stopImmediatePropagation(); + const { anchor, end } = zoomSelectionRef.current; + clearSelectionBand(); + if (anchor !== end) { + applyZoom(anchor, end); + } + return; + } + + if (event.key === "Escape" && zoomSelectionRef.current) { + event.preventDefault(); + event.stopImmediatePropagation(); + clearSelectionBand(); + setLiveAnnouncement(""); + return; + } + } + + function handleKeyUp(event: KeyboardEvent) { + if (event.key === "Shift" && zoomSelectionRef.current) { + const { anchor, end } = zoomSelectionRef.current; + clearSelectionBand(); + if (anchor !== end) { + applyZoom(anchor, end); + } + } + } + + // Listen on document in capture phase. This is the ONLY reliable way to intercept + // before the navigation handler, because the navigation system removes/re-appends + // the application element (invalidating any parent reference). + document.addEventListener("keydown", handleKeyDown, true); + document.addEventListener("keyup", handleKeyUp, true); + return () => { + document.removeEventListener("keydown", handleKeyDown, true); + document.removeEventListener("keyup", handleKeyUp, true); + }; + }, [zoomEnabled, chartReady, applyZoom, i18n.zoomSelectionAnnouncementText]); + + // 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) @@ -140,123 +277,207 @@ export const InternalCartesianChart = forwardRef( useImperativeHandle(ref, () => ({ setVisibleSeries: (visibleSeriesIds) => apiRef.current?.setItemsVisible(visibleSeriesIds), showAllSeries: () => apiRef.current?.setItemsVisible(allSeriesIds), + setZoomRange: (startValue: number, endValue: number) => applyZoom(startValue, endValue), + resetZoom, })); - const zoomEnabled = !!props.zoom?.type; - const navigatorEnabled = !!props.chartNavigator?.enabled; - - // Navigator slot: reset zoom button + Cloudscape LiveRegion for zoom announcements. - const navigatorSlot = - zoomEnabled || navigatorEnabled ? ( -
- {liveAnnouncement} - {zoomEnabled && isZoomed && ( -
- -
- )} -
- ) : null; - - // Highcharts Stock options (navigator, scrollbar, rangeSelector) passed through the options object. - const stockOptions = navigatorEnabled - ? { - navigator: { - enabled: true, - height: props.chartNavigator!.height ?? 40, - adaptToUpdatedData: true, - accessibility: { enabled: true }, - }, - scrollbar: { enabled: false }, - rangeSelector: { enabled: false }, - lang: { - accessibility: { - navigator: { - groupLabel: i18n.navigatorAriaLabel, - handleLabel: i18n.navigatorHandleAriaLabel, - changeAnnouncement: i18n.navigatorChangeAnnouncementText("{axisRangeDescription}"), - }, - }, - }, - } - : { navigator: { enabled: false }, scrollbar: { enabled: false }, rangeSelector: { enabled: false } }; + // Navigator slot: reset zoom button + live region + direction buttons via portal. + // We use a ref to a tracking element positioned at the highlighted point for the portal overlay. + const trackRef = useRef(null); + + // Update the tracking element position when selection is active. + useEffect(() => { + if (!selectionActive || !apiRef.current || highlightedX === null || !trackRef.current) { + return; + } + const chart = apiRef.current.chart; + const xAxis = chart.xAxis[0]; + const pixelX = xAxis.toPixels(highlightedX, false); + const plotBottom = chart.plotTop + chart.plotHeight; + trackRef.current.style.left = `${pixelX}px`; + trackRef.current.style.top = `${plotBottom}px`; + }, [selectionActive, highlightedX]); return ( - (apiRef.current = api)} - options={{ - chart: { - inverted: props.inverted, - ...(zoomEnabled - ? { - zooming: { type: props.zoom!.type ?? "x" }, - // Hide the default Highcharts "Reset zoom" button — we render our own accessible one. - resetZoomButton: { theme: { style: { display: "none" } } }, - } - : {}), - }, - plotOptions: { - series: { stacking: props.stacking }, - }, - accessibility: { - enabled: true, - keyboardNavigation: { enabled: true }, - }, - series: series.map((s) => ({ - ...s, - showInNavigator: navigatorEnabled, - })), - xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ - ...xAxisProps, - title: { text: xAxisProps.title }, - plotLines: xPlotLines, - ...(zoomEnabled || navigatorEnabled - ? { - events: { - afterSetExtremes(e: { - min: number; - max: number; - trigger?: string; - userMin?: number; - userMax?: number; - }) { - if (e.trigger === "navigator" || e.trigger === "zoom") { - const zoomed = !!(e.userMin || e.userMax); - setIsZoomed(zoomed); - announceZoom(zoomed); - if (zoomed) { - fireNonCancelableEvent(props.onZoomChange, { - startValue: e.min, - endValue: e.max, - }); - } else { - fireNonCancelableEvent(props.onZoomChange, null); + <> + {/* Invisible tracking element — gets moved into chart container via callback */} +
+ { + apiRef.current = api; + setChartReady(true); + // Inject the tracking element into the chart container for portal positioning. + if (trackRef.current && !api.chart.container.contains(trackRef.current)) { + api.chart.container.style.position = "relative"; + api.chart.container.appendChild(trackRef.current); + } + }} + options={{ + chart: { + inverted: props.inverted, + ...(zoomEnabled + ? { + zooming: { type: props.zoom!.type ?? "x" }, + resetZoomButton: { theme: { style: { display: "none" } } }, + } + : {}), + }, + plotOptions: { + series: { stacking: props.stacking }, + }, + accessibility: { + enabled: true, + keyboardNavigation: { enabled: true }, + }, + series, + xAxis: castArray(props.xAxis)?.map((xAxisProps) => ({ + ...xAxisProps, + title: { text: xAxisProps.title }, + plotLines: xPlotLines, + ...(zoomEnabled + ? { + events: { + afterSetExtremes(e: { + min: number; + max: number; + trigger?: string; + userMin?: number; + userMax?: number; + }) { + if (e.trigger === "zoom") { + const zoomed = !!(e.userMin || e.userMax); + setIsZoomed(zoomed); + if (zoomed) { + setLiveAnnouncement(i18n.zoomLiveAnnouncementText); + fireNonCancelableEvent(props.onZoomChange, { startValue: e.min, endValue: e.max }); + } else { + fireNonCancelableEvent(props.onZoomChange, null); + } } - } + }, }, - }, + } + : {}), + })), + 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} + onHighlight={({ detail }) => { + // Track the x-value of the currently highlighted point for keyboard zoom anchor. + if (detail.point) { + highlightedXRef.current = detail.point.x; + setHighlightedX(detail.point.x); + } else if (detail.group.length > 0) { + highlightedXRef.current = detail.group[0].x; + setHighlightedX(detail.group[0].x); + } + // If in zoom selection mode, extend the selection band to the new highlight position. + if (zoomSelectionRef.current && highlightedXRef.current !== null) { + zoomSelectionRef.current.end = highlightedXRef.current; + const chart = apiRef.current?.chart; + if (chart) { + const xAxis = chart.xAxis[0]; + xAxis.removePlotBand(ZOOM_SELECTION_BAND_ID); + xAxis.addPlotBand({ + id: ZOOM_SELECTION_BAND_ID, + from: Math.min(zoomSelectionRef.current.anchor, zoomSelectionRef.current.end), + to: Math.max(zoomSelectionRef.current.anchor, zoomSelectionRef.current.end), + color: "rgba(51, 122, 183, 0.2)", + borderColor: "rgba(51, 122, 183, 0.8)", + borderWidth: 1, + zIndex: 4, + }); + } + } + }} + className={testClasses.root} + /> + {/* Direction buttons rendered via portal overlay — positioned at the highlighted point */} + + { + if (!zoomSelectionRef.current || !apiRef.current) { + return; + } + const chart = apiRef.current.chart; + const xValues = getAllXValues(chart); + const idx = xValues.findIndex((x) => x >= zoomSelectionRef.current!.end); + if (idx > 0) { + const newX = xValues[idx - 1]; + zoomSelectionRef.current.end = newX; + updateSelectionBandFromRef(); + // Move chart highlight to the new point. + const group = chart.series + .filter((s) => s.visible) + .flatMap((s) => (s.points ?? []).filter((p) => p.x === newX)); + if (group.length > 0) { + apiRef.current.highlightChartGroup(group); } - : {}), - })), - yAxis: castArray(props.yAxis)?.map((yAxisProps, index) => ({ - ...yAxisProps, - title: { text: yAxisProps.title }, - plotLines: yPlotLines, - ...(index === 1 ? { opposite: true } : {}), - })), - ...stockOptions, - }} - sizeAxis={props.sizeAxis} - tooltip={tooltip} - getTooltipContent={getTooltipContent} - visibleItems={props.visibleSeries} - onVisibleItemsChange={onVisibleSeriesChange} - className={testClasses.root} - /> + } + }} + forcedPosition={null} + forcedIndex={0} + /> + { + if (!zoomSelectionRef.current || !apiRef.current) { + return; + } + const chart = apiRef.current.chart; + const xValues = getAllXValues(chart); + const idx = xValues.findIndex((x) => x >= zoomSelectionRef.current!.end); + if (idx < xValues.length - 1) { + const newX = xValues[idx + 1]; + zoomSelectionRef.current.end = newX; + updateSelectionBandFromRef(); + // Move chart highlight to the new point. + const group = chart.series + .filter((s) => s.visible) + .flatMap((s) => (s.points ?? []).filter((p) => p.x === newX)); + if (group.length > 0) { + apiRef.current.highlightChartGroup(group); + } + } + }} + forcedPosition={null} + forcedIndex={1} + /> + + {/* Zoom controls: live region + reset button */} + {zoomEnabled && ( +
+ {liveAnnouncement} + {isZoomed && !props.zoom?.hideResetButton && ( + + )} +
+ )} + ); }, ); diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 67bbdcdf..f2c27596 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -119,18 +119,6 @@ export interface CartesianChartProps */ zoom?: CartesianChartProps.ZoomOptions; - /** - * Enables the built-in Highcharts navigator — a mini chart below the main chart that - * provides drag handles for panning and zooming. Requires the Highcharts Stock module - * (`highcharts/modules/stock`) to be loaded before passing the `highcharts` instance. - * - * Supported options: - * * `enabled` (boolean) - Enables or disables the navigator. - * * `height` (optional, number) - Height of the navigator area in pixels. Defaults to 40. - * * `ariaLabel` (optional, string) - Accessible label for the navigator region. - */ - chartNavigator?: CartesianChartProps.NavigatorOptions; - /** * Called when the visible zoom range changes due to user interaction (drag-to-zoom, * navigator handles, or reset zoom). @@ -251,12 +239,11 @@ export namespace CartesianChartProps { export interface ZoomOptions { type?: "x"; - } - - export interface NavigatorOptions { - enabled: boolean; - height?: number; - ariaLabel?: string; + /** + * When set to `true`, hides the built-in reset zoom button. + * Use this when you want to provide your own reset UI or control zoom programmatically via `ref.resetZoom()`. + */ + hideResetButton?: boolean; } export interface ZoomChangeDetail { diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index 68253ff0..c6097c20 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -163,9 +163,6 @@ export interface WithCartesianI18nStrings { * * `resetZoomText` (optional, string) - Label for the reset zoom button, e.g. "Reset zoom". * * `zoomLiveAnnouncementText` (optional, string) - Screen reader announcement when the chart is zoomed, e.g. "Chart zoomed in. Use the reset zoom button to restore the full range.". * * `zoomResetLiveAnnouncementText` (optional, string) - Screen reader announcement when zoom is reset, e.g. "Zoom reset. Showing all data.". - * * `navigatorAriaLabel` (optional, string) - Accessible label for the navigator region, e.g. "Use handles to adjust the time range". - * * `navigatorHandleAriaLabel` (optional, string) - Accessible label for navigator drag handles, e.g. "Navigator handle". - * * `navigatorChangeAnnouncementText` (optional, function) - Screen reader announcement when the navigator range changes. Receives `axisRangeDescription` (string) and returns the announcement text, e.g. `(range) => \`Range changed: ${range}\``. * * `zoomControlsAriaLabel` (optional, string) - Accessible label for the zoom controls region, e.g. "Chart zoom controls". */ i18nStrings?: CartesianI18nStrings; @@ -197,9 +194,6 @@ export interface CartesianI18nStrings extends BaseI18nStrings { resetZoomText?: string; zoomLiveAnnouncementText?: string; zoomResetLiveAnnouncementText?: string; - navigatorAriaLabel?: string; - navigatorHandleAriaLabel?: string; - navigatorChangeAnnouncementText?: (axisRangeDescription: string) => string; zoomControlsAriaLabel?: string; } diff --git a/src/core/styles.scss b/src/core/styles.scss index a90b35fd..7ad9a1b6 100644 --- a/src/core/styles.scss +++ b/src/core/styles.scss @@ -132,18 +132,6 @@ $side-legend-max-inline-size: 30%; outline: none; } -// Highcharts built-in navigator accessibility: focus rings on handles and outline. -// The navigator handles are SVG elements that receive keyboard focus via the Highcharts -// accessibility module. We add visible focus indicators for WCAG 2.4.7 compliance. -// stylelint-disable-next-line selector-class-pattern -:global(.highcharts-navigator-handle) { - &:focus { - outline: 2px solid cs.$color-border-item-focused; - outline-offset: 2px; - border-radius: cs.$border-radius-control-default-focus-ring; - } -} - // stylelint-disable-next-line selector-class-pattern :global(.highcharts-navigator-mask-inside) { cursor: grab; diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle-wrapper/direction-button.tsx new file mode 100644 index 00000000..d5cca284 --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/direction-button.tsx @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useRef } from "react"; +import clsx from "clsx"; + +import Icon, { IconProps } from "@cloudscape-design/components/icon"; + +import { Direction, DirectionState } from "./interfaces"; + +import styles from "./styles.css.js"; +import testUtilsStyles from "./test-classes/styles.css.js"; + +// Minimal Transition wrapper — just shows/hides without animation. +function Transition({ + in: isIn, + children, +}: { + in: boolean; + children: (state: string, ref: React.Ref) => React.ReactNode; +}) { + const ref = useRef(null); + if (!isIn) { + return <>{children("exited", ref)}; + } + return <>{children("entered", ref)}; +} + +// Mapping from CSS logical property direction to icon name. The icon component +// already flips the left/right icons automatically based on RTL, so we don't +// need to do anything special. +const ICON_LOGICAL_PROPERTY_MAP: Record = { + "block-start": "arrow-up", + "block-end": "arrow-down", + "inline-start": "arrow-left", + "inline-end": "arrow-right", +}; + +interface DirectionButtonProps { + direction: Direction; + state: DirectionState; + onClick: React.MouseEventHandler; + show: boolean; + forcedPosition: null | "top" | "bottom"; + forcedIndex: number; +} + +export default function DirectionButton({ + direction, + state, + show, + onClick, + forcedPosition, + forcedIndex, +}: DirectionButtonProps) { + return ( + + {(transitionState, ref) => ( + + event.preventDefault()} + > + + + + )} + + ); +} diff --git a/src/internal/components/drag-handle-wrapper/index.tsx b/src/internal/components/drag-handle-wrapper/index.tsx new file mode 100644 index 00000000..4509d3ec --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/index.tsx @@ -0,0 +1,206 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef, useState } from "react"; +import clsx from "clsx"; + +import { nodeContains } from "@cloudscape-design/component-toolkit/dom"; +import { getLogicalBoundingClientRect } from "@cloudscape-design/component-toolkit/internal"; + +import DirectionButton from "./direction-button"; +import { Direction, DragHandleWrapperProps } from "./interfaces"; +import PortalOverlay from "./portal-overlay"; + +import styles from "./styles.css.js"; +import testUtilsStyles from "./test-classes/styles.css.js"; + +// The UAP buttons are forced to top/bottom position if the handle is close to the screen edge. +const FORCED_POSITION_PROXIMITY_PX = 50; +// Approximate UAP button size with margins to decide forced direction. +const UAP_BUTTON_SIZE_PX = 40; +const DIRECTIONS_ORDER: Direction[] = ["block-end", "block-start", "inline-end", "inline-start"]; + +export default function DragHandleWrapper({ + directions, + children, + onDirectionClick, + triggerMode = "focus", + initialShowButtons = false, + controlledShowButtons = false, + wrapperClassName, + hideButtonsOnDrag, + clickDragThreshold, +}: DragHandleWrapperProps) { + const wrapperRef = useRef(null); + const dragHandleRef = useRef(null); + const [uncontrolledShowButtons, setUncontrolledShowButtons] = useState(initialShowButtons); + + const isPointerDown = useRef(false); + const initialPointerPosition = useRef<{ x: number; y: number } | undefined>(); + const didPointerDrag = useRef(false); + + const isDisabled = + !directions["block-start"] && !directions["block-end"] && !directions["inline-start"] && !directions["inline-end"]; + + const onWrapperFocusIn: React.FocusEventHandler = (event) => { + if (document.body.dataset.awsuiFocusVisible && !nodeContains(wrapperRef.current, event.relatedTarget)) { + if (triggerMode === "focus") { + setUncontrolledShowButtons(true); + } + } + }; + + const onWrapperFocusOut: React.FocusEventHandler = (event) => { + if (document.hasFocus() && !nodeContains(wrapperRef.current, event.relatedTarget)) { + setUncontrolledShowButtons(false); + } + }; + + useEffect(() => { + const controller = new AbortController(); + + document.addEventListener( + "pointermove", + (event) => { + if ( + isPointerDown.current && + initialPointerPosition.current && + (event.clientX > initialPointerPosition.current.x + clickDragThreshold || + event.clientX < initialPointerPosition.current.x - clickDragThreshold || + event.clientY > initialPointerPosition.current.y + clickDragThreshold || + event.clientY < initialPointerPosition.current.y - clickDragThreshold) + ) { + didPointerDrag.current = true; + if (hideButtonsOnDrag) { + setUncontrolledShowButtons(false); + } + } + }, + { signal: controller.signal }, + ); + + const resetPointerDownState = () => { + isPointerDown.current = false; + initialPointerPosition.current = undefined; + }; + + document.addEventListener("pointercancel", resetPointerDownState, { signal: controller.signal }); + + document.addEventListener( + "pointerup", + () => { + if (isPointerDown.current && !didPointerDrag.current) { + setUncontrolledShowButtons(true); + } + resetPointerDownState(); + }, + { signal: controller.signal }, + ); + + return () => controller.abort(); + }, [clickDragThreshold, hideButtonsOnDrag]); + + const onHandlePointerDown: React.PointerEventHandler = (event) => { + isPointerDown.current = true; + didPointerDrag.current = false; + initialPointerPosition.current = { x: event.clientX, y: event.clientY }; + }; + + const onDragHandleKeyDown: React.KeyboardEventHandler = (event) => { + if (event.key === "Escape") { + setUncontrolledShowButtons(false); + } else if (triggerMode === "keyboard-activate" && (event.key === "Enter" || event.key === " ")) { + setUncontrolledShowButtons((prev) => !prev); + } else if ( + event.key !== "Alt" && + event.key !== "Control" && + event.key !== "Meta" && + event.key !== "Shift" && + triggerMode === "focus" + ) { + setUncontrolledShowButtons(true); + } + }; + + const showButtons = triggerMode === "controlled" ? controlledShowButtons : uncontrolledShowButtons; + + const [forcedPosition, setForcedPosition] = useState(null); + const directionsOrder = forcedPosition === "bottom" ? [...DIRECTIONS_ORDER].reverse() : DIRECTIONS_ORDER; + const visibleDirections = directionsOrder.filter((dir) => directions[dir]); + + useEffect(() => { + if (!showButtons || !dragHandleRef.current) { + if (forcedPosition !== null) { + setForcedPosition(null); + } + return; + } + + let frameId: number; + + const checkPosition = () => { + if (!dragHandleRef.current) { + return; + } + const rect = getLogicalBoundingClientRect(dragHandleRef.current); + const conflicts = { + "block-start": rect.insetBlockStart < FORCED_POSITION_PROXIMITY_PX, + "block-end": window.innerHeight - rect.insetBlockEnd < FORCED_POSITION_PROXIMITY_PX, + "inline-start": rect.insetInlineStart < FORCED_POSITION_PROXIMITY_PX, + "inline-end": window.innerWidth - rect.insetInlineEnd < FORCED_POSITION_PROXIMITY_PX, + }; + if (visibleDirections.some((direction) => conflicts[direction])) { + const hasEnoughSpaceAbove = rect.insetBlockStart > visibleDirections.length * UAP_BUTTON_SIZE_PX; + setForcedPosition(hasEnoughSpaceAbove ? "top" : "bottom"); + } else { + setForcedPosition(null); + } + frameId = requestAnimationFrame(checkPosition); + }; + + frameId = requestAnimationFrame(checkPosition); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [forcedPosition, showButtons, visibleDirections]); + + return ( + <> +
+
+
+ {children} +
+
+
+ + + {visibleDirections.map( + (direction, index) => + directions[direction] && ( + onDirectionClick?.(direction)} + forcedPosition={forcedPosition} + forcedIndex={index} + /> + ), + )} + + + ); +} diff --git a/src/internal/components/drag-handle-wrapper/interfaces.ts b/src/internal/components/drag-handle-wrapper/interfaces.ts new file mode 100644 index 00000000..1a62004d --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/interfaces.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type Direction = "block-start" | "block-end" | "inline-start" | "inline-end"; +export type DirectionState = "active" | "disabled"; +export type TriggerMode = "focus" | "keyboard-activate" | "controlled"; + +export interface DragHandleWrapperProps { + directions: Partial>; + onDirectionClick?: (direction: Direction) => void; + children: React.ReactNode; + wrapperClassName?: string; + triggerMode?: TriggerMode; + initialShowButtons?: boolean; + controlledShowButtons?: boolean; + hideButtonsOnDrag: boolean; + clickDragThreshold: number; +} diff --git a/src/internal/components/drag-handle-wrapper/motion.scss b/src/internal/components/drag-handle-wrapper/motion.scss new file mode 100644 index 00000000..d88f1bd5 --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/motion.scss @@ -0,0 +1,17 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// Simplified motion — no animations for now. +.direction-button-wrapper { + &-motion-entering, + &-motion-entered { + opacity: 1; + } + + &-motion-exiting, + &-motion-exited { + opacity: 0; + } +} diff --git a/src/internal/components/drag-handle-wrapper/portal-overlay.tsx b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx new file mode 100644 index 00000000..1b7c0fcc --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/portal-overlay.tsx @@ -0,0 +1,85 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import clsx from "clsx"; + +import { + getIsRtl, + getLogicalBoundingClientRect, + getScrollInlineStart, + Portal, +} from "@cloudscape-design/component-toolkit/internal"; + +import styles from "./styles.css.js"; + +export default function PortalOverlay({ + track, + isDisabled, + children, +}: { + track: React.RefObject; + isDisabled: boolean; + children: React.ReactNode; +}) { + const ref = useRef(null); + const [container, setContainer] = useState(null); + + useLayoutEffect(() => { + if (track.current) { + const newContainer = track.current.ownerDocument.createElement("div"); + track.current.ownerDocument.body.appendChild(newContainer); + setContainer(newContainer); + return () => newContainer.remove(); + } + }, [track]); + + useEffect(() => { + if (track.current === null || isDisabled) { + return; + } + + let cleanedUp = false; + let lastX: number | undefined; + let lastY: number | undefined; + let lastInlineSize: number | undefined; + let lastBlockSize: number | undefined; + const updateElement = () => { + if (track.current && ref.current && document.body.contains(ref.current)) { + const isRtl = getIsRtl(ref.current); + const { insetInlineStart, insetBlockStart, inlineSize, blockSize } = getLogicalBoundingClientRect( + track.current, + ); + const newX = (insetInlineStart + getScrollInlineStart(document.documentElement)) * (isRtl ? -1 : 1); + const newY = insetBlockStart + document.documentElement.scrollTop; + if (lastX !== newX || lastY !== newY) { + ref.current.style.translate = `${newX}px ${newY}px`; + lastX = newX; + lastY = newY; + } + if (lastInlineSize !== inlineSize || lastBlockSize !== blockSize) { + ref.current.style.width = `${inlineSize}px`; + ref.current.style.height = `${blockSize}px`; + lastInlineSize = inlineSize; + lastBlockSize = blockSize; + } + } + if (!cleanedUp) { + requestAnimationFrame(updateElement); + } + }; + updateElement(); + + return () => { + cleanedUp = true; + }; + }, [isDisabled, track]); + + return ( + + + {children} + + + ); +} diff --git a/src/internal/components/drag-handle-wrapper/styles.scss b/src/internal/components/drag-handle-wrapper/styles.scss new file mode 100644 index 00000000..fdc0a8fe --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/styles.scss @@ -0,0 +1,141 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use "../../styles" as styles; +@use "../../../../node_modules/@cloudscape-design/design-tokens/index.scss" as awsui; +@use "./motion"; + +$direction-button-wrapper-size: calc(#{awsui.$space-static-xl} + 2 * #{awsui.$space-static-xxs}); +$direction-button-size: awsui.$space-static-xl; +$direction-button-offset: awsui.$space-static-xxs; + +.contents { + display: contents; +} + +.portal-overlay { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + + // Since the overlay takes up the exact width/height of the element below it, this prevents + // any clicks on this element from occluding clicks on the element below. + pointer-events: none; + + // Similar to the expandToViewport dropdown, this needs to be higher than modal's z-index. + z-index: 7000; +} + +.portal-overlay-disabled { + display: none; +} + +.portal-overlay-contents { + pointer-events: auto; +} + +.drag-handle { + position: relative; + display: inline-flex; +} + +.direction-button-wrapper { + position: absolute; + block-size: $direction-button-size; + inline-size: $direction-button-size; + padding-block: $direction-button-offset; + padding-inline: $direction-button-offset; +} + +.direction-button-wrapper-hidden { + display: none; +} + +.direction-button-wrapper-block-start { + inset-block-start: calc(-1 * $direction-button-wrapper-size); + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-block-end { + inset-block-end: calc(-1 * $direction-button-wrapper-size); + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-inline-start { + inset-inline-start: calc(-1 * $direction-button-wrapper-size); + inset-block-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-inline-end { + inset-inline-end: calc(-1 * $direction-button-wrapper-size); + inset-block-start: calc(50% - $direction-button-wrapper-size / 2); +} + +.direction-button-wrapper-forced { + inset-inline-start: calc(50% - $direction-button-wrapper-size / 2); +} +.direction-button-wrapper-forced-top-0 { + inset-block-start: calc(-1 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-1 { + inset-block-start: calc(-2 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-2 { + inset-block-start: calc(-3 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-top-3 { + inset-block-start: calc(-4 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-bottom-0 { + inset-block-start: calc(1 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-bottom-1 { + inset-block-start: calc(2 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-bottom-2 { + inset-block-start: calc(3 * $direction-button-wrapper-size); +} +.direction-button-wrapper-forced-bottom-3 { + inset-block-start: calc(4 * $direction-button-wrapper-size); +} + +.direction-button { + position: absolute; + border-width: 0; + cursor: pointer; + display: inline-block; + box-sizing: border-box; + + // This skips the browser waiting for a double-tap interaction before activating. + // False positive - this isn't supported in Safari Desktop but is supported on iOS. + // stylelint-disable-next-line plugin/no-unsupported-browser-features + touch-action: manipulation; + + inline-size: $direction-button-size; + block-size: $direction-button-size; + padding-block: awsui.$space-static-xxs; + padding-inline: awsui.$space-static-xxs; + border-start-start-radius: 50%; + border-start-end-radius: 50%; + border-end-start-radius: 50%; + border-end-end-radius: 50%; + background-color: awsui.$color-background-button-normal-default; + color: awsui.$color-text-button-normal-default; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + + &:not(.direction-button-disabled):hover { + background-color: awsui.$color-background-button-normal-hover; + } + + &:not(.direction-button-disabled):active { + background-color: awsui.$color-background-button-normal-active; + } +} + +.direction-button-disabled { + cursor: default; + background-color: awsui.$color-background-button-normal-disabled; + color: awsui.$color-text-button-normal-disabled; +} diff --git a/src/internal/components/drag-handle-wrapper/test-classes/styles.scss b/src/internal/components/drag-handle-wrapper/test-classes/styles.scss new file mode 100644 index 00000000..b5ea159c --- /dev/null +++ b/src/internal/components/drag-handle-wrapper/test-classes/styles.scss @@ -0,0 +1,32 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +.root { + /* used in test-utils */ +} + +.direction-button { + /* used in test-utils */ +} + +.direction-button-visible { + /* used in test-utils */ +} + +.direction-button-block-start { + /* used in test-utils */ +} + +.direction-button-block-end { + /* used in test-utils */ +} + +.direction-button-inline-start { + /* used in test-utils */ +} + +.direction-button-inline-end { + /* used in test-utils */ +} From 4a9edbbb839de48bcbe5e07f4fabeee690fea6be Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Wed, 20 May 2026 18:23:02 +0200 Subject: [PATCH 3/3] feat: Keyboard controls for charts zoom --- .../chart-cartesian-internal.tsx | 43 +++++++++++-------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/cartesian-chart/chart-cartesian-internal.tsx b/src/cartesian-chart/chart-cartesian-internal.tsx index 53b7e267..e07ad321 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -300,11 +300,31 @@ export const InternalCartesianChart = forwardRef( return ( <> - {/* Invisible tracking element — gets moved into chart container via callback */} -
+ {liveAnnouncement} + {isZoomed && !props.zoom?.hideResetButton && ( + + )} +
+ ), + } + : undefined + } callback={(api) => { apiRef.current = api; setChartReady(true); @@ -403,6 +423,8 @@ export const InternalCartesianChart = forwardRef( }} className={testClasses.root} /> + {/* Invisible tracking element — gets moved into chart container via callback */} +
{/* Direction buttons rendered via portal overlay — positioned at the highlighted point */} - {/* Zoom controls: live region + reset button */} - {zoomEnabled && ( -
- {liveAnnouncement} - {isZoomed && !props.zoom?.hideResetButton && ( - - )} -
- )} ); },