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 (
+ <>
+
+
+
+ {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 */
+}