diff --git a/.changeset/warm-charts-wheel.md b/.changeset/warm-charts-wheel.md new file mode 100644 index 00000000..8da62e0e --- /dev/null +++ b/.changeset/warm-charts-wheel.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add configurable focus, modifier, and always wheel activation policies to `zoomX`. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..890eecaf 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent evaluations so later API, documentation, and TanStack Intent skill work is based on evidence. -Last updated: 2026-08-26 +Last updated: 2026-09-03 ## Triage rule @@ -2285,9 +2285,10 @@ Each entry records: touch previews, controlled pinning, leave/cancel cleanup, toggle/Escape clearing, and host teardown without importing brush or D3 policy. Exact-subpath `zoomX` now binds a controlled semantic window to the final x - scale and owns focus-gated wheel capture, pointer-anchored zoom, drag and - horizontal-wheel pan, touch and keyboard input, clamping, cancellation, and - teardown. D3 Zoom remains private to that optional DOM control. Exact-subpath + scale and owns configurable focus, modifier-gated, or unconditional wheel + capture, pointer-anchored zoom, drag and horizontal-wheel pan, touch and + keyboard input, clamping, cancellation, and teardown. D3 Zoom remains private + to that optional DOM control. Exact-subpath `handleX` binds one controlled semantic x value to ordered candidates and owns final-scale track, rule and handle paint, nearest snapping, pointer and touch capture, cancellation, keyboard slider semantics, and teardown without @@ -2357,8 +2358,9 @@ Each entry records: update, and strict type gate at 98.7% diagnostic geometry. - Zoom follow-up: case 90 now uses exact-subpath `zoomX` in one ordinary definition. The behavior owns final-scale inversion, wheel normalization and - focus gating, pointer-anchored zoom, drag and horizontal-wheel pan, touch and - keyboard input, clamping, cancellation, controlled updates, and teardown. + activation policy, pointer-anchored zoom, drag and horizontal-wheel pan, + touch and keyboard input, clamping, cancellation, controlled updates, and + teardown. The case retains the accepted semantic window, visible-row and y-domain policy, live status, Reset control, persistence, and conformance observation. The React effect, second SVG, D3 imports, copied scale, transform conversion, @@ -2367,12 +2369,23 @@ Each entry records: limits, responsive replacement, teardown, and source ownership. The exact optional bundle fixture isolates D3 Zoom and Selection from root, universal, ordinary DOM, brush, cursor, legend, and selection consumers. It adds 20,429 - bytes, or 19.95 KiB gzip, over the ordinary DOM host under its 20 KiB cap. + bytes, or 19.95 KiB gzip, over the ordinary DOM host under its original 20 + KiB cap. The quick paired browser matrix passes unfocused page scrolling, focus-gated pixel/line/page wheel zoom, horizontal-wheel pan, pointer drag, touch activation and pan, keyboard, Reset, revisions, visual, and strict type scenarios at 98.6% diagnostic geometry. TanStack uses 512 authored lines and 51.59 kB gzip versus ECharts' 727 lines and 172.82 kB. +- Wheel-policy follow-up: `zoomX` now accepts `wheelActivation: 'modifier'` for + immediate Control+wheel or Command+wheel input without focus while plain + wheel events remain page-owned. The existing focus policy remains the + default, and an explicit `always` policy supports chart-owned wheel surfaces. + Focused tests cover both modifier keys, unmodified passthrough while focused, + horizontal-wheel pan, unconditional capture, bounds and zero deltas, grouped + commits, cancellation, controlled updates, and mode-specific instructions. + The added policy and instructions bring the exact optional bundle to 20,957 + bytes, or 20.47 KiB gzip, under a narrow 20.55 KiB cap without changing root + or universal consumers. - Scale-handle follow-up: cases 91 and 92 now use exact-subpath `handleX` in their ordinary definitions. The behavior maps ordered semantic candidates through the final x scale, paints the track, optional rule and handle, and diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..81d72e42 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,8 +1,8 @@ { "schemaVersion": 4, - "generatedAt": "2026-08-26T21:51:12.832Z", + "generatedAt": "2026-09-03T17:27:31.349Z", "packageVersions": { - "tanstack": "0.15.0", + "tanstack": "0.16.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -12,7 +12,7 @@ "tanstack": { "kind": "workspace", "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e" + "inputDigest": "sha256:bb3b5cc84f6040f3c8c03d71510c1afafff88ec4827a403d799fa4afcb5c7ccb" }, "chartjs": { "kind": "package", diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..6ff0e0e1 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293", + "sourceHash": "d5e5bbdcb0c10fb17d0975dda7f9241720094d214bc73ee330babc358f353096", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..98c22d80 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-09-03`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index 62f3b914..d7eaf352 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -192,9 +192,11 @@ start and end values. Import `zoomX` from `@tanstack/charts/interaction/zoom`, bind its `window` to a controlled signal, and provide the full `extent` and allowed `scaleExtent`. -The behavior owns final-scale inversion, focus-gated wheel capture, +The behavior owns final-scale inversion, configurable wheel capture, pointer-anchored zoom, pan, touch and keyboard input, cancellation, clamping, -and teardown. +and teardown. Wheel capture defaults to focused plots. Use +`wheelActivation: 'modifier'` when Control+wheel or Command+wheel should act +without prior focus while unmodified wheels continue scrolling the page. Keep the accepted window, visible-row or clipping policy, y-domain policy, status, reset control, persistence, and follow-latest behavior in application diff --git a/docs/guides/accessibility.md b/docs/guides/accessibility.md index 7c3a2e21..66aeb1a4 100644 --- a/docs/guides/accessibility.md +++ b/docs/guides/accessibility.md @@ -96,10 +96,13 @@ retain the accepted handle paint but need an application-owned semantic input. `zoomX` exposes one named plot surface with visible focus. Plus and minus zoom, the arrow keys pan, Home resets, and Escape cancels an active gesture. Wheel -input is captured only while that surface is focused; an unfocused chart must -not trap page scrolling. Keep a visible Reset button and current-window text -bound to the same controlled window. Static SVG and React Native render the -accepted window but need application-owned semantic zoom controls. +input is captured only while that surface is focused by default. For scrolling +dashboards, `wheelActivation: 'modifier'` lets Control+wheel or Command+wheel +act immediately under the pointer while every unmodified wheel keeps scrolling +the page. `wheelActivation: 'always'` deliberately captures every wheel over +the plot. Keep a visible Reset button and current-window text bound to the same +controlled window. Static SVG and React Native render the accepted window but +need application-owned semantic zoom controls. ## Never rely on color alone diff --git a/docs/guides/bundle-size-and-performance.md b/docs/guides/bundle-size-and-performance.md index 8fcbaa14..194af7ab 100644 --- a/docs/guides/bundle-size-and-performance.md +++ b/docs/guides/bundle-size-and-performance.md @@ -150,7 +150,7 @@ Horizontal zoom is exact-subpath-only through behavior, final-scale interaction axis, DOM host control, `d3-zoom`, and `d3-selection` only for a consumer that imports it. Root, universal, ordinary DOM, brush, cursor, legend, and selection consumers retain none of those -modules. Its incremental DOM-host fixture adds 20.28 KiB gzip under a 20.35 KiB +modules. Its incremental DOM-host fixture adds 20.47 KiB gzip under a 20.55 KiB cap. Your bundler must honor ESM exports and tree shaking. Avoid namespace imports diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index 0d3b2ab1..a40c7383 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -667,9 +667,11 @@ const definition = defineChart({ `extent` is the complete allowed x domain. `scaleExtent` is `[1, maximum]` and defaults to `[1, Infinity]`. The behavior owns final-scale inversion, pointer-anchored wheel zoom, drag and horizontal-wheel pan, touch input, -keyboard zoom and pan, clamping, cancellation, and teardown. It captures the -wheel only after its plot surface receives focus, so normal page scrolling -remains available beforehand. +keyboard zoom and pan, clamping, cancellation, and teardown. By default, it +captures the wheel only after its plot surface receives focus. Set +`wheelActivation: 'modifier'` to capture Control+wheel or Command+wheel without +prior focus while leaving every unmodified wheel to the page. Set it to +`always` only when every wheel over the plot should be chart-owned. Every proposal is a complete number or Date window. `ZoomXChange` distinguishes gesture `preview`, `commit`, and `cancel` events, includes the gesture origin, diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 96ddf62d..8be75302 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -650,9 +650,13 @@ with that same window. The behavior resolves against the final x scale and plot bounds. Its contained SVG or Canvas host control owns pointer-anchored vertical-wheel zoom, drag and horizontal-wheel pan, pointer and touch input, delta-mode normalization, -clamping, cancellation, and teardown. It captures wheel input only while the -plot control is focused. Plus and minus zoom around the center, arrow keys pan, -Home proposes the full extent, and Escape cancels an active gesture. +clamping, cancellation, and teardown. `wheelActivation` defaults to `focus`, so +wheel input is captured only while the plot control is focused. Use `modifier` +to capture Control+wheel or Command+wheel under the pointer without prior focus +while every unmodified wheel continues to scroll the page. Use `always` only +when the chart should capture every wheel over its plot. Plus and minus zoom +around the center, arrow keys pan, Home proposes the full extent, and Escape +cancels an active gesture. `ZoomXChange` reports `preview`, `commit`, or `cancel`; the proposed `value`, the gesture `origin`, a `zoom`, `pan`, or `reset` action, and a wheel, pointer, @@ -672,14 +676,15 @@ render the accepted configured window but provide no zoom host control. The public zoom types are: -| Type | Contract | -| ---------------------- | ---------------------------------------------------------------------- | -| `ZoomXValue` | A finite `number` or valid `Date` | -| `ZoomXWindow` | Semantic `start` and `end` values | -| `ZoomXSource` | Wheel, pointer, touch, or keyboard origin | -| `ZoomXAction` | Zoom, pan, or reset action | -| `ZoomXChange` | Preview, commit, and cancel reason union | -| `ZoomXOptions` | Controlled window, extent, limits, accessibility, and keyboard options | +| Type | Contract | +| ---------------------- | ------------------------------------------------------------------------ | +| `ZoomXValue` | A finite `number` or valid `Date` | +| `ZoomXWindow` | Semantic `start` and `end` values | +| `ZoomXSource` | Wheel, pointer, touch, or keyboard origin | +| `ZoomXAction` | Zoom, pan, or reset action | +| `ZoomXWheelActivation` | Focus, modifier-gated, or unconditional wheel capture | +| `ZoomXChange` | Preview, commit, and cancel reason union | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, accessibility, and key options | ## Disabling chart-owned focus diff --git a/docs/reference/index.md b/docs/reference/index.md index 04cdcf2d..a8f4b09a 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -153,7 +153,7 @@ aggregate `/scales` export. | `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | | `@tanstack/charts/interaction/cursor` | `continuousCursor` and continuous-cursor position, change, guide, label, and option types | | `@tanstack/charts/interaction/signal` | `controlledSignal`, `ControlledSignal`, `ControlledSignalChangeContext` | -| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | | `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend`, `interactiveColorLegend`, `InteractiveColorLegendItemContext`, and legend option/change types | | `@tanstack/charts/line` | `lineY`, `lineX`, `LineYOptions`, and `LineXOptions` | | `@tanstack/charts/regression` | `linearRegressionRowsY`, `linearRegressionRowsX`, `linearRegressionY`, `linearRegressionX`, derived datum types, and option types | diff --git a/docs/reference/types.md b/docs/reference/types.md index f219b23b..21f37ace 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -457,7 +457,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and + `ZoomXOptions`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). - `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..98c22d80 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-09-03`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index 62f3b914..d7eaf352 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -192,9 +192,11 @@ start and end values. Import `zoomX` from `@tanstack/charts/interaction/zoom`, bind its `window` to a controlled signal, and provide the full `extent` and allowed `scaleExtent`. -The behavior owns final-scale inversion, focus-gated wheel capture, +The behavior owns final-scale inversion, configurable wheel capture, pointer-anchored zoom, pan, touch and keyboard input, cancellation, clamping, -and teardown. +and teardown. Wheel capture defaults to focused plots. Use +`wheelActivation: 'modifier'` when Control+wheel or Command+wheel should act +without prior focus while unmodified wheels continue scrolling the page. Keep the accepted window, visible-row or clipping policy, y-domain policy, status, reset control, persistence, and follow-latest behavior in application diff --git a/packages/charts-core/docs/guides/accessibility.md b/packages/charts-core/docs/guides/accessibility.md index 7c3a2e21..66aeb1a4 100644 --- a/packages/charts-core/docs/guides/accessibility.md +++ b/packages/charts-core/docs/guides/accessibility.md @@ -96,10 +96,13 @@ retain the accepted handle paint but need an application-owned semantic input. `zoomX` exposes one named plot surface with visible focus. Plus and minus zoom, the arrow keys pan, Home resets, and Escape cancels an active gesture. Wheel -input is captured only while that surface is focused; an unfocused chart must -not trap page scrolling. Keep a visible Reset button and current-window text -bound to the same controlled window. Static SVG and React Native render the -accepted window but need application-owned semantic zoom controls. +input is captured only while that surface is focused by default. For scrolling +dashboards, `wheelActivation: 'modifier'` lets Control+wheel or Command+wheel +act immediately under the pointer while every unmodified wheel keeps scrolling +the page. `wheelActivation: 'always'` deliberately captures every wheel over +the plot. Keep a visible Reset button and current-window text bound to the same +controlled window. Static SVG and React Native render the accepted window but +need application-owned semantic zoom controls. ## Never rely on color alone diff --git a/packages/charts-core/docs/guides/bundle-size-and-performance.md b/packages/charts-core/docs/guides/bundle-size-and-performance.md index 8fcbaa14..194af7ab 100644 --- a/packages/charts-core/docs/guides/bundle-size-and-performance.md +++ b/packages/charts-core/docs/guides/bundle-size-and-performance.md @@ -150,7 +150,7 @@ Horizontal zoom is exact-subpath-only through behavior, final-scale interaction axis, DOM host control, `d3-zoom`, and `d3-selection` only for a consumer that imports it. Root, universal, ordinary DOM, brush, cursor, legend, and selection consumers retain none of those -modules. Its incremental DOM-host fixture adds 20.28 KiB gzip under a 20.35 KiB +modules. Its incremental DOM-host fixture adds 20.47 KiB gzip under a 20.55 KiB cap. Your bundler must honor ESM exports and tree shaking. Avoid namespace imports diff --git a/packages/charts-core/docs/guides/interactions-and-selections.md b/packages/charts-core/docs/guides/interactions-and-selections.md index 0d3b2ab1..a40c7383 100644 --- a/packages/charts-core/docs/guides/interactions-and-selections.md +++ b/packages/charts-core/docs/guides/interactions-and-selections.md @@ -667,9 +667,11 @@ const definition = defineChart({ `extent` is the complete allowed x domain. `scaleExtent` is `[1, maximum]` and defaults to `[1, Infinity]`. The behavior owns final-scale inversion, pointer-anchored wheel zoom, drag and horizontal-wheel pan, touch input, -keyboard zoom and pan, clamping, cancellation, and teardown. It captures the -wheel only after its plot surface receives focus, so normal page scrolling -remains available beforehand. +keyboard zoom and pan, clamping, cancellation, and teardown. By default, it +captures the wheel only after its plot surface receives focus. Set +`wheelActivation: 'modifier'` to capture Control+wheel or Command+wheel without +prior focus while leaving every unmodified wheel to the page. Set it to +`always` only when every wheel over the plot should be chart-owned. Every proposal is a complete number or Date window. `ZoomXChange` distinguishes gesture `preview`, `commit`, and `cancel` events, includes the gesture origin, diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 96ddf62d..8be75302 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -650,9 +650,13 @@ with that same window. The behavior resolves against the final x scale and plot bounds. Its contained SVG or Canvas host control owns pointer-anchored vertical-wheel zoom, drag and horizontal-wheel pan, pointer and touch input, delta-mode normalization, -clamping, cancellation, and teardown. It captures wheel input only while the -plot control is focused. Plus and minus zoom around the center, arrow keys pan, -Home proposes the full extent, and Escape cancels an active gesture. +clamping, cancellation, and teardown. `wheelActivation` defaults to `focus`, so +wheel input is captured only while the plot control is focused. Use `modifier` +to capture Control+wheel or Command+wheel under the pointer without prior focus +while every unmodified wheel continues to scroll the page. Use `always` only +when the chart should capture every wheel over its plot. Plus and minus zoom +around the center, arrow keys pan, Home proposes the full extent, and Escape +cancels an active gesture. `ZoomXChange` reports `preview`, `commit`, or `cancel`; the proposed `value`, the gesture `origin`, a `zoom`, `pan`, or `reset` action, and a wheel, pointer, @@ -672,14 +676,15 @@ render the accepted configured window but provide no zoom host control. The public zoom types are: -| Type | Contract | -| ---------------------- | ---------------------------------------------------------------------- | -| `ZoomXValue` | A finite `number` or valid `Date` | -| `ZoomXWindow` | Semantic `start` and `end` values | -| `ZoomXSource` | Wheel, pointer, touch, or keyboard origin | -| `ZoomXAction` | Zoom, pan, or reset action | -| `ZoomXChange` | Preview, commit, and cancel reason union | -| `ZoomXOptions` | Controlled window, extent, limits, accessibility, and keyboard options | +| Type | Contract | +| ---------------------- | ------------------------------------------------------------------------ | +| `ZoomXValue` | A finite `number` or valid `Date` | +| `ZoomXWindow` | Semantic `start` and `end` values | +| `ZoomXSource` | Wheel, pointer, touch, or keyboard origin | +| `ZoomXAction` | Zoom, pan, or reset action | +| `ZoomXWheelActivation` | Focus, modifier-gated, or unconditional wheel capture | +| `ZoomXChange` | Preview, commit, and cancel reason union | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, accessibility, and key options | ## Disabling chart-owned focus diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index 04cdcf2d..a8f4b09a 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -153,7 +153,7 @@ aggregate `/scales` export. | `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | | `@tanstack/charts/interaction/cursor` | `continuousCursor` and continuous-cursor position, change, guide, label, and option types | | `@tanstack/charts/interaction/signal` | `controlledSignal`, `ControlledSignal`, `ControlledSignalChangeContext` | -| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | | `@tanstack/charts/legend` | `colorLegend`, `colorGradientLegend`, `interactiveColorLegend`, `InteractiveColorLegendItemContext`, and legend option/change types | | `@tanstack/charts/line` | `lineY`, `lineX`, `LineYOptions`, and `LineXOptions` | | `@tanstack/charts/regression` | `linearRegressionRowsY`, `linearRegressionRowsX`, `linearRegressionY`, `linearRegressionX`, derived datum types, and option types | diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md index f219b23b..21f37ace 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -457,7 +457,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXChange`, and `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and + `ZoomXOptions`. See [Horizontal zoom](./focus-and-interaction.md#horizontal-zoom). - `@tanstack/charts/polar`: `PolarOptions`, `PolarScales`, `PolarMark`, `PolarGuide`, `PolarGuideScene`, `PolarAngleOptions`, diff --git a/packages/charts-core/src/interaction-zoom.test.ts b/packages/charts-core/src/interaction-zoom.test.ts index 171c4e63..d05984da 100644 --- a/packages/charts-core/src/interaction-zoom.test.ts +++ b/packages/charts-core/src/interaction-zoom.test.ts @@ -2,7 +2,12 @@ import { scaleLinear, scaleUtc } from 'd3-scale' import { describe, expect, it, vi } from 'vitest' import { dot } from './dot' import { mountChart } from './dom' -import { zoomX, type ZoomXChange, type ZoomXWindow } from './interaction-zoom' +import { + zoomX, + type ZoomXChange, + type ZoomXWheelActivation, + type ZoomXWindow, +} from './interaction-zoom' import { controlledSignal } from './interaction-signal' import { createChartScene, defineChart } from './scene' import { renderChartSvg } from './svg' @@ -35,6 +40,17 @@ describe('zoomX', () => { scaleExtent: [0.5, 4], }), ).toThrow(/must start at 1/) + expect(() => + zoomX({ + window: controlledSignal({ start: 0, end: 1 }, () => {}), + extent: [0, 1], + wheelActivation: 'hover' as never, + }), + ).toThrowError( + new TypeError( + 'zoomX wheelActivation must be "focus", "modifier", or "always"', + ), + ) }) it('owns focus, keyboard navigation, reset, controlled updates, and teardown', () => { @@ -240,6 +256,380 @@ describe('zoomX', () => { } }) + it.each([ + { label: 'Control', modifier: { ctrlKey: true } }, + { label: 'Meta', modifier: { metaKey: true } }, + ])( + 'captures blurred $label + vertical wheel input in modifier mode', + ({ modifier }) => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'modifier' }) + const event = wheel(harness.x, harness.y, { + deltaY: -240, + ...modifier, + }) + + expect(document.activeElement).not.toBe(harness.target) + harness.target.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + expect(harness.activeChanges).toEqual([]) + expect(harness.target.dataset.zoomActive).toBe('false') + expect(harness.target.dataset.zoomWheelCaptured).toBe('true') + expect(harness.calls.at(-1)?.value.start).toBeCloseTo(2.5) + expect(harness.calls.at(-1)?.value.end).toBeCloseTo(7.5) + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'preview', + origin: { start: 0, end: 10 }, + source: 'wheel', + action: 'zoom', + }) + + vi.advanceTimersByTime(150) + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'commit', + origin: { start: 0, end: 10 }, + source: 'wheel', + action: 'zoom', + }) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }, + ) + + it('passes plain wheel through in modifier mode even while focused and captures horizontal modifier pan', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ + initial: { start: 2.5, end: 7.5 }, + wheelActivation: 'modifier', + }) + harness.target.focus() + + const plainVertical = wheel(harness.x, harness.y, { deltaY: -240 }) + harness.target.dispatchEvent(plainVertical) + const plainHorizontal = wheel(harness.x, harness.y, { deltaX: 220 }) + harness.target.dispatchEvent(plainHorizontal) + const shift = wheel(harness.x, harness.y, { + deltaY: -240, + shiftKey: true, + }) + harness.target.dispatchEvent(shift) + const alt = wheel(harness.x, harness.y, { + deltaY: -240, + altKey: true, + }) + harness.target.dispatchEvent(alt) + + for (const event of [plainVertical, plainHorizontal, shift, alt]) { + expect(event.defaultPrevented).toBe(false) + } + expect(harness.calls).toEqual([]) + expect(harness.target.dataset.zoomWheelCaptured).toBe('false') + + const horizontal = wheel(harness.x, harness.y, { + deltaX: 220, + ctrlKey: true, + }) + harness.target.dispatchEvent(horizontal) + expect(horizontal.defaultPrevented).toBe(true) + expect(harness.calls.at(-1)?.value.start).toBeGreaterThan(2.5) + expect(harness.calls.at(-1)?.value.end).toBeGreaterThan(7.5) + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'preview', + origin: { start: 2.5, end: 7.5 }, + source: 'wheel', + action: 'pan', + }) + + vi.advanceTimersByTime(150) + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'commit', + origin: { start: 2.5, end: 7.5 }, + source: 'wheel', + action: 'pan', + }) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('captures plain wheel while blurred in always mode', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'always' }) + const event = wheel(harness.x, harness.y, { deltaY: -240 }) + + expect(document.activeElement).not.toBe(harness.target) + harness.target.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(harness.target.dataset.zoomActive).toBe('false') + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'preview', + source: 'wheel', + action: 'zoom', + }) + + vi.advanceTimersByTime(150) + expect(harness.calls.at(-1)?.reason).toMatchObject({ + type: 'commit', + source: 'wheel', + action: 'zoom', + }) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('keeps focus activation as the default, including for modified wheel input', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness() + + const control = wheel(harness.x, harness.y, { + deltaY: -240, + ctrlKey: true, + }) + const meta = wheel(harness.x, harness.y, { + deltaY: -240, + metaKey: true, + }) + harness.target.dispatchEvent(control) + harness.target.dispatchEvent(meta) + expect(control.defaultPrevented).toBe(false) + expect(meta.defaultPrevented).toBe(false) + expect(harness.calls).toEqual([]) + + harness.target.focus() + const focused = wheel(harness.x, harness.y, { deltaY: -240 }) + harness.target.dispatchEvent(focused) + expect(focused.defaultPrevented).toBe(true) + expect(harness.calls.at(-1)?.reason.type).toBe('preview') + vi.advanceTimersByTime(150) + expect(harness.calls.at(-1)?.reason.type).toBe('commit') + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('updates generated wheel instructions by mode and preserves description overrides', () => { + const harness = numericWheelHarness() + + expect(harness.target.getAttribute('aria-description')).toContain( + 'Focus before using a vertical wheel', + ) + expect(harness.target.getAttribute('aria-description')).toContain( + 'Use plus, minus, arrow keys, or Home.', + ) + + harness.setWheelActivation('modifier') + expect(harness.target.getAttribute('aria-description')).toContain( + 'Hold Control or Command', + ) + + harness.setWheelActivation('always') + expect(harness.target.getAttribute('aria-description')).toContain( + 'Use a vertical wheel to zoom', + ) + expect(harness.target.getAttribute('aria-description')).not.toContain( + 'Control or Command', + ) + + harness.setKeyboard(false) + expect(harness.target.getAttribute('aria-description')).not.toContain( + 'Use plus', + ) + + harness.setAriaDescription('Custom wheel instructions') + expect(harness.target.getAttribute('aria-description')).toBe( + 'Custom wheel instructions', + ) + + harness.setAriaDescription(undefined) + expect(harness.target.getAttribute('aria-description')).toContain( + 'Use a vertical wheel to zoom', + ) + harness.destroy() + }) + + it('passes out-of-bounds and zero-delta modifier wheel input through', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'modifier' }) + const scene = harness.host.getScene() + const outside = wheel(scene.chart.x - 1, harness.y, { + deltaY: -240, + ctrlKey: true, + }) + const zero = wheel(harness.x, harness.y, { ctrlKey: true }) + + harness.target.dispatchEvent(outside) + harness.target.dispatchEvent(zero) + vi.advanceTimersByTime(200) + + expect(outside.defaultPrevented).toBe(false) + expect(zero.defaultPrevented).toBe(false) + expect(harness.calls).toEqual([]) + expect(harness.target.dataset.zoomWheelCaptured).toBe('false') + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('groups blurred modifier wheel previews and commits 150ms after the last event', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'modifier' }) + + const first = wheel(harness.x, harness.y, { + deltaY: -120, + ctrlKey: true, + }) + harness.target.dispatchEvent(first) + vi.advanceTimersByTime(100) + const second = wheel(harness.x, harness.y, { + deltaY: -120, + metaKey: true, + }) + harness.target.dispatchEvent(second) + + expect(first.defaultPrevented).toBe(true) + expect(second.defaultPrevented).toBe(true) + expect(harness.calls.map(({ reason }) => reason.type)).toEqual([ + 'preview', + 'preview', + ]) + expect(harness.calls[1]?.reason.origin).toEqual({ start: 0, end: 10 }) + + vi.advanceTimersByTime(149) + expect(harness.calls).toHaveLength(2) + vi.advanceTimersByTime(1) + expect(harness.calls).toHaveLength(3) + expect(harness.calls[2]?.reason).toMatchObject({ + type: 'commit', + origin: { start: 0, end: 10 }, + source: 'wheel', + action: 'zoom', + }) + expect(harness.calls[2]?.value).toEqual(harness.calls[1]?.value) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('finishes an authorized wheel batch across a policy update, then applies the new policy', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'modifier' }) + const authorized = wheel(harness.x, harness.y, { + deltaY: -240, + ctrlKey: true, + }) + harness.target.dispatchEvent(authorized) + expect(harness.calls.map(({ reason }) => reason.type)).toEqual([ + 'preview', + ]) + + harness.setWheelActivation('focus') + vi.advanceTimersByTime(150) + expect(harness.calls.map(({ reason }) => reason.type)).toEqual([ + 'preview', + 'commit', + ]) + + const nowDisallowed = wheel(harness.x, harness.y, { + deltaY: -240, + ctrlKey: true, + }) + harness.target.dispatchEvent(nowDisallowed) + vi.advanceTimersByTime(200) + expect(nowDisallowed.defaultPrevented).toBe(false) + expect(harness.calls).toHaveLength(2) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('cancels a modifier wheel batch on Escape without a later commit', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ wheelActivation: 'modifier' }) + harness.target.focus() + harness.target.dispatchEvent( + wheel(harness.x, harness.y, { deltaY: -240, ctrlKey: true }), + ) + + const escape = key('Escape') + harness.target.dispatchEvent(escape) + expect(escape.defaultPrevented).toBe(true) + expect(harness.calls.map(({ reason }) => reason.type)).toEqual([ + 'preview', + 'cancel', + ]) + expect(harness.calls.at(-1)).toMatchObject({ + value: { start: 0, end: 10 }, + reason: { + type: 'cancel', + value: { start: 0, end: 10 }, + origin: { start: 0, end: 10 }, + source: 'keyboard', + action: 'zoom', + }, + }) + + vi.advanceTimersByTime(200) + expect(harness.calls).toHaveLength(2) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + + it('does not commit a blurred modifier wheel preview rejected by a controlled update', () => { + vi.useFakeTimers() + try { + const harness = numericWheelHarness({ + wheelActivation: 'modifier', + acceptChanges: false, + }) + const event = wheel(harness.x, harness.y, { + deltaY: -240, + ctrlKey: true, + }) + + harness.target.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(harness.calls).toHaveLength(1) + expect(harness.calls[0]?.reason.type).toBe('preview') + vi.advanceTimersByTime(200) + expect(harness.calls).toHaveLength(1) + expect(harness.target.getAttribute('aria-description')).toContain( + '0 to 10', + ) + + harness.destroy() + } finally { + vi.useRealTimers() + } + }) + it('uses reversed final-scale inversion and clones temporal payloads', () => { vi.useFakeTimers() try { @@ -751,6 +1141,8 @@ function numericDefinition( onChange: (value: ZoomXWindow, reason: ZoomXChange) => void, onActiveChange?: (active: boolean) => void, keyboard = true, + wheelActivation?: ZoomXWheelActivation, + ariaDescription?: string, ) { return defineChart({ marks: [dot(numericRows, { x: 'x', y: 'y' })], @@ -770,12 +1162,93 @@ function numericDefinition( format: (value) => String(value), onActiveChange, keyboard, + wheelActivation, + ariaDescription, }), ], keyboard: false, }) } +function numericWheelHarness({ + initial = { start: 0, end: 10 }, + wheelActivation: initialWheelActivation, + keyboard: initialKeyboard = true, + ariaDescription: initialAriaDescription, + acceptChanges = true, +}: { + initial?: ZoomXWindow + wheelActivation?: ZoomXWheelActivation + keyboard?: boolean + ariaDescription?: string + acceptChanges?: boolean +} = {}) { + let accepted = copyWindow(initial) + let wheelActivation = initialWheelActivation + let keyboard = initialKeyboard + let ariaDescription = initialAriaDescription + const calls: Array<{ + value: ZoomXWindow + reason: ZoomXChange + }> = [] + const activeChanges: boolean[] = [] + const container = document.createElement('div') + document.body.append(container) + let host: ChartHost<(typeof numericRows)[number], number, number> + const options = (): ChartHostOptions< + (typeof numericRows)[number], + number, + number + > => ({ + definition: numericDefinition( + accepted, + (next, reason) => { + calls.push({ value: copyWindow(next), reason }) + if (acceptChanges) accepted = copyWindow(next) + host.update(options()) + }, + (active) => activeChanges.push(active), + keyboard, + wheelActivation, + ariaDescription, + ), + width: 480, + height: 240, + ariaLabel: 'Wheel zoom', + }) + host = mountChart(container, options()) + const target = zoomTarget(container) + const surface = container.querySelector('svg.ts-chart')! + mockBounds(surface, 480, 240) + const scene = host.getScene() + + return { + activeChanges, + calls, + container, + host, + target, + x: scene.chart.x + scene.chart.width / 2, + y: scene.chart.y + scene.chart.height / 2, + setWheelActivation(next: ZoomXWheelActivation | undefined) { + wheelActivation = next + host.update(options()) + }, + setKeyboard(next: boolean) { + keyboard = next + host.update(options()) + }, + setAriaDescription(next: string | undefined) { + ariaDescription = next + host.update(options()) + }, + destroy() { + host.destroy() + container.remove() + }, + } +} + function temporalDefinition( window: ZoomXWindow, extent: readonly [Date, Date], @@ -853,7 +1326,15 @@ function key(value: string) { function wheel( clientX: number, clientY: number, - options: { deltaX?: number; deltaY?: number; deltaMode?: number }, + options: { + deltaX?: number + deltaY?: number + deltaMode?: number + ctrlKey?: boolean + metaKey?: boolean + shiftKey?: boolean + altKey?: boolean + }, ) { return new WheelEvent('wheel', { bubbles: true, diff --git a/packages/charts-core/src/interaction-zoom.ts b/packages/charts-core/src/interaction-zoom.ts index 109f7f24..bf3da3b3 100644 --- a/packages/charts-core/src/interaction-zoom.ts +++ b/packages/charts-core/src/interaction-zoom.ts @@ -29,6 +29,7 @@ export interface ZoomXWindow { export type ZoomXSource = 'wheel' | 'pointer' | 'touch' | 'keyboard' export type ZoomXAction = 'zoom' | 'pan' | 'reset' +export type ZoomXWheelActivation = 'focus' | 'modifier' | 'always' export type ZoomXChange = | ZoomXChangeEvent< @@ -60,6 +61,8 @@ export interface ZoomXOptions { extent: readonly [TValue, TValue] /** Minimum and maximum zoom factors relative to `extent`. */ scaleExtent?: readonly [number, number] + /** When wheel input may zoom or pan. Defaults to `focus`. */ + wheelActivation?: ZoomXWheelActivation keyboard?: boolean ariaLabel?: string ariaDescription?: string @@ -77,6 +80,7 @@ interface ZoomXControl extends ChartHostControl { readonly window: ZoomXWindow readonly extent: ZoomXWindow readonly scaleExtent: readonly [number, number] + readonly wheelActivation: ZoomXWheelActivation readonly keyboard: boolean readonly ariaLabel: string readonly ariaDescription?: string @@ -90,10 +94,12 @@ interface ZoomXControl extends ChartHostControl { const defaultId = 'zoom-x' const defaultAriaLabel = 'Zoomable horizontal chart region' +const defaultWheelActivation: ZoomXWheelActivation = 'focus' /** - * Controls a continuous x-domain with focus-gated wheel, pan, touch, and - * keyboard input. The application owns the accepted semantic window. + * Controls a continuous x-domain with configurable wheel activation, pan, + * touch, and keyboard input. The application owns the accepted semantic + * window. */ export function zoomX( options: ZoomXOptions, @@ -103,6 +109,7 @@ export function zoomX( throw new TypeError('zoomX id cannot be empty') } const scaleExtent = resolveScaleExtent(options.scaleExtent) + const wheelActivation = resolveWheelActivation(options.wheelActivation) return { id, @@ -138,6 +145,7 @@ export function zoomX( window, extent, scaleExtent, + wheelActivation, keyboard: options.keyboard !== false, ariaLabel: options.ariaLabel ?? defaultAriaLabel, ariaDescription: options.ariaDescription, @@ -355,7 +363,8 @@ function createZoomXControl({ } function handleWheel(event: WheelEvent) { - if (!active || !control || !scene) return + if (!control || !scene) return + if (!wheelInputEnabled(control.wheelActivation, event, active)) return const vertical = Math.abs(event.deltaY) >= Math.abs(event.deltaX) const rawDelta = vertical ? event.deltaY : event.deltaX if (!rawDelta) return @@ -902,12 +911,44 @@ function cloneChange( } function defaultDescription(control: ZoomXControl) { - const pointerInstructions = `${control.format(control.window.start)} to ${control.format(control.window.end)}. Focus before wheel zoom. Drag or use a horizontal wheel to pan.` + const wheelInstructions = + control.wheelActivation === 'modifier' + ? 'Hold Control or Command while using a vertical wheel to zoom or a horizontal wheel to pan.' + : control.wheelActivation === 'always' + ? 'Use a vertical wheel to zoom or a horizontal wheel to pan.' + : 'Focus before using a vertical wheel to zoom or a horizontal wheel to pan.' + const pointerInstructions = `${control.format(control.window.start)} to ${control.format(control.window.end)}. ${wheelInstructions} Drag to pan.` return control.keyboard ? `${pointerInstructions} Use plus, minus, arrow keys, or Home.` : pointerInstructions } +function resolveWheelActivation( + activation: ZoomXWheelActivation | undefined, +): ZoomXWheelActivation { + if (activation === undefined) return defaultWheelActivation + if ( + activation !== 'focus' && + activation !== 'modifier' && + activation !== 'always' + ) { + throw new TypeError( + 'zoomX wheelActivation must be "focus", "modifier", or "always"', + ) + } + return activation +} + +function wheelInputEnabled( + activation: ZoomXWheelActivation, + event: WheelEvent, + active: boolean, +) { + if (activation === 'always') return true + if (activation === 'modifier') return event.ctrlKey || event.metaKey + return active +} + function defaultFormat(value: ZoomXValue) { return value instanceof Date ? value.toLocaleDateString() : String(value) } diff --git a/packages/charts-core/src/type-contract.test.ts b/packages/charts-core/src/type-contract.test.ts index 0c1223c6..db1fdae2 100644 --- a/packages/charts-core/src/type-contract.test.ts +++ b/packages/charts-core/src/type-contract.test.ts @@ -26,7 +26,12 @@ import { type HandleXOptions, } from './interaction-handle' import { controlledSignal } from './interaction-signal' -import { zoomX, type ZoomXChange, type ZoomXWindow } from './interaction-zoom' +import { + zoomX, + type ZoomXChange, + type ZoomXWheelActivation, + type ZoomXWindow, +} from './interaction-zoom' import { lineY } from './line' import { createMark } from './mark' import { createMarkWithScaleValues } from './mark-with-scale-values' @@ -145,12 +150,14 @@ const dateStringHandle = handleX({ }) // @ts-expect-error Horizontal handles require explicit ordered values. handleX({ value: dateHandleSignal, cross: { edge: 'bottom' } }) +const wheelActivation: ZoomXWheelActivation = 'modifier' const dateZoom = zoomX({ window: controlledSignal, ZoomXChange>( { start: rows[0]!.date, end: new Date('2025-01-02T00:00:00Z') }, () => {}, ), extent: [rows[0]!.date, new Date('2025-01-03T00:00:00Z')], + wheelActivation, format: (value) => { expectTypeOf(value).toEqualTypeOf() return value.toISOString() diff --git a/scripts/check-packed-consumers.mjs b/scripts/check-packed-consumers.mjs index 6d84c8d3..79989ede 100644 --- a/scripts/check-packed-consumers.mjs +++ b/scripts/check-packed-consumers.mjs @@ -824,6 +824,7 @@ async function verifyEsmRuntime() { }), extent: [0, 10], scaleExtent: [1, 8], + wheelActivation: 'modifier', }) assert.equal(packedZoom.id, 'zoom-x') assert.equal(typeof packedZoom.resolve, 'function') @@ -1688,6 +1689,7 @@ async function verifyDeclarations() { zoomX, type ZoomXChange, type ZoomXOptions, + type ZoomXWheelActivation, type ZoomXWindow, } from '@tanstack/charts/interaction/zoom' import { @@ -2969,10 +2971,12 @@ async function verifyDeclarations() { { start: new Date('2026-01-01'), end: new Date('2026-02-01') }, (_next, { reason }) => reason.action, ) + const packedZoomWheelActivation: ZoomXWheelActivation = 'modifier' const packedZoomOptions: ZoomXOptions = { window: packedZoomWindow, extent: [new Date('2026-01-01'), new Date('2026-03-01')], scaleExtent: [1, 8], + wheelActivation: packedZoomWheelActivation, } const packedZoom = zoomX(packedZoomOptions) const packedSelectionOptions: KeyedSelectionOptions< @@ -3021,6 +3025,7 @@ async function verifyDeclarations() { packedHandleOptions, packedHandle, packedZoomWindow, + packedZoomWheelActivation, packedZoomOptions, packedZoom, packedSelectionOptions, diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..e23e357b 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1487,7 +1487,7 @@ const entries = [ 'Horizontal zoom + DOM host', 'benchmarks/entries/charts-zoom-x.ts', 'TanStack DOM host', - 20.35, + 20.55, { rendererBoundary: 'svg', inputBoundary: {