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..0cf75c6f 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,34 @@ function CategoryDatetime() { /> ); } + +// Zoom demo: drag-to-zoom on x-axis + keyboard zoom (Shift+Arrow to select, Enter/release to zoom). +function DatetimeLinearWithZoom() { + const { chartProps } = useChartSettings(); + + 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/use-highcharts.ts b/pages/common/use-highcharts.ts index 2f0bb0dd..cfc16bdd 100644 --- a/pages/common/use-highcharts.ts +++ b/pages/common/use-highcharts.ts @@ -12,7 +12,13 @@ export function useHighcharts({ solidgauge = false, boost = false, heatmap = false, -}: { more?: boolean; xrange?: boolean; solidgauge?: boolean; boost?: boolean; heatmap?: boolean } = {}) { +}: { + more?: boolean; + xrange?: boolean; + solidgauge?: boolean; + boost?: boolean; + heatmap?: boolean; +} = {}) { const [highcharts, setHighcharts] = useState(null); useEffect(() => { diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 4b6863fc..88b5c292 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": [ { @@ -162,6 +171,11 @@ Supported Highcharts versions: 12.", "optional": true, "type": "string", }, + { + "name": "resetZoomText", + "optional": true, + "type": "string", + }, { "name": "seriesFilterLabel", "optional": true, @@ -187,6 +201,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 +649,40 @@ 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": [ + { + "name": "hideResetButton", + "optional": true, + "type": "boolean", + }, + { + "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..e07ad321 100644 --- a/src/cartesian-chart/chart-cartesian-internal.tsx +++ b/src/cartesian-chart/chart-cartesian-internal.tsx @@ -1,14 +1,17 @@ // 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"; import { getOptionsId, isXThreshold } from "../core/utils"; import { InternalBaseComponentProps } from "../internal/base-component/use-base-component"; +import DirectionButton from "../internal/components/drag-handle-wrapper/direction-button"; +import PortalOverlay from "../internal/components/drag-handle-wrapper/portal-overlay"; import { fireNonCancelableEvent } from "../internal/events"; import { castArray, SomeRequired } from "../internal/utils/utils"; import { transformCartesianSeries } from "./chart-series-cartesian"; @@ -21,19 +24,45 @@ 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); + + // 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); - // 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 +74,166 @@ 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. + 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.", + 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); + setLiveAnnouncement(i18n.zoomResetLiveAnnouncementText); + fireNonCancelableEvent(props.onZoomChange, null); + }, [i18n.zoomResetLiveAnnouncementText, props.onZoomChange]); + + // Focus the reset button when it appears. + useEffect(() => { + if (isZoomed && resetButtonRef.current) { + resetButtonRef.current.focus(); + } + }, [isZoomed]); + + // 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) @@ -99,39 +277,212 @@ 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, })); + // 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, - }, - 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} - /> + <> + + {liveAnnouncement} + {isZoomed && !props.zoom?.hideResetButton && ( + + )} + + ), + } + : undefined + } + callback={(api) => { + 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} + /> + {/* Invisible tracking element — gets moved into chart container via callback */} +
+ {/* 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); + } + } + }} + 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} + /> + + ); }, ); diff --git a/src/cartesian-chart/interfaces.ts b/src/cartesian-chart/interfaces.ts index 9a0d5cfa..f2c27596 100644 --- a/src/cartesian-chart/interfaces.ts +++ b/src/cartesian-chart/interfaces.ts @@ -109,6 +109,25 @@ 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; + + /** + * 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 +236,20 @@ export namespace CartesianChartProps { export type FilterOptions = CoreTypes.BaseFilterOptions; export type NoDataOptions = CoreTypes.BaseNoDataOptions; + + export interface ZoomOptions { + type?: "x"; + /** + * 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 { + startValue: number; + endValue: number; + } } // Internal types diff --git a/src/core/interfaces.ts b/src/core/interfaces.ts index df13ce71..c6097c20 100644 --- a/src/core/interfaces.ts +++ b/src/core/interfaces.ts @@ -160,6 +160,10 @@ 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.". + * * `zoomControlsAriaLabel` (optional, string) - Accessible label for the zoom controls region, e.g. "Chart zoom controls". */ i18nStrings?: CartesianI18nStrings; } @@ -187,6 +191,10 @@ export interface WithPieI18nStrings { export interface CartesianI18nStrings extends BaseI18nStrings { xAxisRoleDescription?: string; yAxisRoleDescription?: string; + resetZoomText?: string; + zoomLiveAnnouncementText?: string; + zoomResetLiveAnnouncementText?: string; + zoomControlsAriaLabel?: string; } export interface PieI18nStrings extends BaseI18nStrings { diff --git a/src/core/styles.scss b/src/core/styles.scss index d9c5f23d..7ad9a1b6 100644 --- a/src/core/styles.scss +++ b/src/core/styles.scss @@ -131,3 +131,12 @@ $side-legend-max-inline-size: 30%; // We hide the native focus outline to render a custom one around the chart plot instead. outline: none; } + +// stylelint-disable-next-line selector-class-pattern +:global(.highcharts-navigator-mask-inside) { + cursor: grab; + + &:active { + cursor: grabbing; + } +} diff --git a/src/internal/components/drag-handle-wrapper/direction-button.tsx b/src/internal/components/drag-handle-wrapper/direction-button.tsx new file mode 100644 index 00000000..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 */ +}