diff --git a/.changeset/control-hover-passthrough.md b/.changeset/control-hover-passthrough.md new file mode 100644 index 00000000..2523fe71 --- /dev/null +++ b/.changeset/control-hover-passthrough.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Add a shared `hover: 'capture' | 'passthrough'` option to `brushX` and `zoomX`. With `passthrough`, idle hover over the plot still focuses points and shows the tooltip, so brush or zoom can live on the same plot as point tooltips. Drags, clicks, wheel, keyboard input, and the brush selection and handles stay with the control. The default `capture` keeps the current behavior. diff --git a/API-FRICTION.md b/API-FRICTION.md index 049e9c6e..896e126c 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -351,6 +351,7 @@ Each entry records: | F-312 | Tick stubs could not be styled independently | API | resolved | | F-313 | Initial observer precision cancelled entrance motion | Tooling | resolved | | F-314 | Framework ticks and immediate focus layers were unclear | Documentation | resolved | +| F-315 | Brush and zoom hid point focus on their own plot | API | resolved | ## Findings @@ -9317,3 +9318,31 @@ Each entry records: direction changes. A focus dot appears during entrance while the data path and its running animation remain intact. Concurrent inline states remain a separate feature; this addresses the documented alternatives requested. + +### F-315 - Brush and zoom hid point focus on their own plot + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: a consumer dashboard on 0.18.0 that combined `brushX` or + `zoomX` with point tooltips on one time-series plot +- Friction: with either control on the main plot, hovering the plot showed no + point focus or tooltip. The zoom hit rectangle and the brush overlay sit + above the plot and capture every pointer event, and the DOM host clears + point focus whenever the pointer target is inside a control. The + application moved brush and zoom into a separate overview chart to keep + tooltips working. +- Decision: add one shared `hover: 'capture' | 'passthrough'` option to both + controls, typed `ChartControlHover`. The default `capture` keeps the old + behavior. `passthrough` lets idle hover over the zoom surface, or over the + blank brush overlay, resolve point focus and the tooltip through the host's + control-containment check. Brush selections and handles, active drags and + touch gestures, clicks, wheel input, and keyboard input stay with the + control. Hover is the only forwarded input, so one click never both selects + a point and starts a brush. +- Verification: table-driven zoom and brush tests cover the default, explicit + `capture`, and `passthrough`: idle hover focuses the nearest point only with + `passthrough`, a brush selection keeps capturing, and focus clears during + an active pan or brush drag and returns on the next idle hover. The + interaction guide has a runnable one-plot `zoomX` and `brushX` example with + point tooltips. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index d7b15a06..9c157e4e 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -15,24 +15,24 @@ "gzip": 30414 }, "TanStack DOM host": { - "bytes": 82961, - "gzip": 29116 + "bytes": 82995, + "gzip": 29135 }, "React adapter": { - "bytes": 85286, - "gzip": 30070 + "bytes": 85320, + "gzip": 30095 }, "React line consumer": { - "bytes": 108945, - "gzip": 39420 + "bytes": 108979, + "gzip": 39441 }, "Compact-scale line scene": { "bytes": 35859, "gzip": 12797 }, "React compact-scale line consumer": { - "bytes": 91443, - "gzip": 32305 + "bytes": 91477, + "gzip": 32323 }, "Custom-scale line scene": { "bytes": 34043, diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index 0406421b..e26e4ae8 100644 --- a/benchmarks/comparison/bundle-baseline.json +++ b/benchmarks/comparison/bundle-baseline.json @@ -1,6 +1,6 @@ { "schemaVersion": 4, - "generatedAt": "2026-10-13T23:35:26.997Z", + "generatedAt": "2026-10-14T00:02:00.663Z", "packageVersions": { "tanstack": "1.1.0", "chartjs": "4.5.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "a691036b07655adda97aa0dc3052428989e2b811", - "inputDigest": "sha256:390854b69e6ac7c4453ba8bcc75dfd5240f70bbd9f69efdc8d25c7fba528d951" + "revision": "3f579fd6a4057b444eb35baf7d3eb1d95347f66f", + "inputDigest": "sha256:a407dcfc0ad7cdda7e2869906d33cda8af55faa4a93d4a5512cd9b1ac1f1f337" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 119233, - "gzipBytes": 43211, - "brotliBytes": 38200, - "incrementalGzipBytes": 43211, - "incrementalBrotliBytes": 38200 + "minifiedBytes": 119267, + "gzipBytes": 43235, + "brotliBytes": 38218, + "incrementalGzipBytes": 43235, + "incrementalBrotliBytes": 38218 }, "tanstack-line-interactive": { - "minifiedBytes": 124980, - "gzipBytes": 45122, - "brotliBytes": 39727, - "incrementalGzipBytes": 45122, - "incrementalBrotliBytes": 39727 + "minifiedBytes": 125014, + "gzipBytes": 45149, + "brotliBytes": 39762, + "incrementalGzipBytes": 45149, + "incrementalBrotliBytes": 39762 }, "tanstack-line-advanced": { - "minifiedBytes": 132176, - "gzipBytes": 47454, - "brotliBytes": 41771, - "incrementalGzipBytes": 47454, - "incrementalBrotliBytes": 41771 + "minifiedBytes": 132210, + "gzipBytes": 47475, + "brotliBytes": 41789, + "incrementalGzipBytes": 47475, + "incrementalBrotliBytes": 41789 }, "tanstack-bar-basic": { - "minifiedBytes": 131670, - "gzipBytes": 47913, - "brotliBytes": 42147, - "incrementalGzipBytes": 47913, - "incrementalBrotliBytes": 42147 + "minifiedBytes": 131706, + "gzipBytes": 47939, + "brotliBytes": 42142, + "incrementalGzipBytes": 47939, + "incrementalBrotliBytes": 42142 }, "tanstack-bar-interactive": { - "minifiedBytes": 136276, - "gzipBytes": 49307, - "brotliBytes": 43250, - "incrementalGzipBytes": 49307, - "incrementalBrotliBytes": 43250 + "minifiedBytes": 136312, + "gzipBytes": 49330, + "brotliBytes": 43308, + "incrementalGzipBytes": 49330, + "incrementalBrotliBytes": 43308 }, "tanstack-bar-advanced": { - "minifiedBytes": 136615, - "gzipBytes": 49453, - "brotliBytes": 43349, - "incrementalGzipBytes": 49453, - "incrementalBrotliBytes": 43349 + "minifiedBytes": 136651, + "gzipBytes": 49477, + "brotliBytes": 43410, + "incrementalGzipBytes": 49477, + "incrementalBrotliBytes": 43410 }, "tanstack-area-basic": { - "minifiedBytes": 124413, - "gzipBytes": 45239, - "brotliBytes": 39996, - "incrementalGzipBytes": 45239, - "incrementalBrotliBytes": 39996 + "minifiedBytes": 124447, + "gzipBytes": 45263, + "brotliBytes": 40064, + "incrementalGzipBytes": 45263, + "incrementalBrotliBytes": 40064 }, "tanstack-area-interactive": { - "minifiedBytes": 130168, - "gzipBytes": 47088, - "brotliBytes": 41480, - "incrementalGzipBytes": 47088, - "incrementalBrotliBytes": 41480 + "minifiedBytes": 130202, + "gzipBytes": 47108, + "brotliBytes": 41537, + "incrementalGzipBytes": 47108, + "incrementalBrotliBytes": 41537 }, "tanstack-area-advanced": { - "minifiedBytes": 137540, - "gzipBytes": 49496, - "brotliBytes": 43606, - "incrementalGzipBytes": 49496, - "incrementalBrotliBytes": 43606 + "minifiedBytes": 137574, + "gzipBytes": 49519, + "brotliBytes": 43593, + "incrementalGzipBytes": 49519, + "incrementalBrotliBytes": 43593 }, "tanstack-scatter-basic": { - "minifiedBytes": 120286, - "gzipBytes": 43572, - "brotliBytes": 38499, - "incrementalGzipBytes": 43572, - "incrementalBrotliBytes": 38499 + "minifiedBytes": 120320, + "gzipBytes": 43597, + "brotliBytes": 38595, + "incrementalGzipBytes": 43597, + "incrementalBrotliBytes": 38595 }, "tanstack-scatter-interactive": { - "minifiedBytes": 126037, - "gzipBytes": 45469, - "brotliBytes": 40055, - "incrementalGzipBytes": 45469, - "incrementalBrotliBytes": 40055 + "minifiedBytes": 126071, + "gzipBytes": 45498, + "brotliBytes": 40045, + "incrementalGzipBytes": 45498, + "incrementalBrotliBytes": 40045 }, "tanstack-scatter-advanced": { - "minifiedBytes": 126053, - "gzipBytes": 45476, - "brotliBytes": 40107, - "incrementalGzipBytes": 45476, - "incrementalBrotliBytes": 40107 + "minifiedBytes": 126087, + "gzipBytes": 45504, + "brotliBytes": 40133, + "incrementalGzipBytes": 45504, + "incrementalBrotliBytes": 40133 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index 300c9bf6..3c31684f 100644 --- a/benchmarks/conformance/previews/manifest.json +++ b/benchmarks/conformance/previews/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "width": 288, "height": 192, - "sourceHash": "82eecd6c1fbdca5e857b965e09bb53c550520b2e596fbc4e9850266f03fdb9b4", + "sourceHash": "2873c5f0f113e63c96e17411cd08efce587d73f98189c45dc6af35609b302168", "assets": [ { "id": "01-line-gaps", diff --git a/docs/comparison.md b/docs/comparison.md index a6ab014c..0ab3ec4f 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3f579fd` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `3f579fd`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,10 +106,10 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.23–49.52 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | diff --git a/docs/guides/interactions-and-selections.md b/docs/guides/interactions-and-selections.md index a40c7383..7de30cdd 100644 --- a/docs/guides/interactions-and-selections.md +++ b/docs/guides/interactions-and-selections.md @@ -442,7 +442,9 @@ mountChart(element, { This prevents an application-owned gesture from competing with the host's point marker and tooltip. First-party host controls such as `brushX` and -`zoomX` isolate their own events. +`zoomX` isolate their own events by default. Set `hover: 'passthrough'` on +either control to keep point focus and tooltips on the same plot; see +[Tooltips with brush or zoom](#tooltips-with-brush-or-zoom). Use `pointer: false` plus the interaction controller when the application owns the gesture but the chart should still own datum focus. `focusDisabled` does @@ -684,6 +686,109 @@ recovery controls, follow-latest behavior, and persistence in the application. A reset updates the same controlled window. Import `d3-zoom` directly only when the application needs a different gesture policy. +## Tooltips with brush or zoom + +`brushX` and `zoomX` share one `hover` option. The default, `capture`, keeps +every pointer event over the control, so the plot shows no point focus or +tooltip while the pointer is over it. Set `hover: 'passthrough'` when the same +plot should also show point tooltips: + +- Idle hover over the zoom surface, or over the blank brush area, focuses the + nearest point and shows the tooltip. +- The brush selection and its handles keep capturing hover. +- During a pan, brush drag, or touch gesture, point focus clears. It returns on + the next idle hover. +- Presses, clicks, wheel input, and keyboard input still belong to the + control. A click on the plot pans or brushes; it never also selects a point. + After an accepted wheel zoom, the host re-resolves focus at the last pointer + position. + +```tsx group=zoom-with-tooltips env=charts-react file=/src/App.tsx entry +import { useMemo, useState } from 'react' +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { + zoomX, + type ZoomXChange, + type ZoomXWindow, +} from '@tanstack/charts/interaction/zoom' +import { controlledSignal } from '@tanstack/charts/interaction/signal' +import { tooltip } from '@tanstack/charts/tooltip' +import { Chart } from '@tanstack/charts/react' +import { rows } from './data' + +const extent = [1, 12] as const + +export default function App() { + const [visible, setVisible] = useState>({ + start: 3, + end: 9, + }) + const definition = useMemo( + () => + defineChart({ + marks: [ + lineY(rows, { + x: 'week', + y: 'signups', + points: true, + stroke: '#2563eb', + strokeWidth: 2.5, + }), + ], + scales: { + x: { scale: scaleLinear().domain([visible.start, visible.end]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ + zoomX({ + window: controlledSignal, ZoomXChange>( + visible, + (next) => setVisible(next), + ), + extent, + scaleExtent: [1, 4], + hover: 'passthrough', + ariaLabel: 'Zoomable signup weeks', + format: (week) => `Week ${Math.round(week)}`, + }), + ], + tooltip, + }), + [visible], + ) + + return ( + + ) +} +``` + +```ts group=zoom-with-tooltips file=/src/data.ts collapsed +export const rows = [ + { week: 1, signups: 18 }, + { week: 2, signups: 24 }, + { week: 3, signups: 31 }, + { week: 4, signups: 29 }, + { week: 5, signups: 42 }, + { week: 6, signups: 48 }, + { week: 7, signups: 46 }, + { week: 8, signups: 57 }, + { week: 9, signups: 61 }, + { week: 10, signups: 58 }, + { week: 11, signups: 66 }, + { week: 12, signups: 72 }, +] +``` + +`brushX({ range, values, hover: 'passthrough' })` works the same way. Put +only one of the two controls on a plot. Both cover the plot, so the one +mounted last takes every press over it. + ## Linked views Store one semantic cursor, selection, or domain and derive every view from it. diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index a0d33fc5..b27b0faf 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -624,7 +624,14 @@ drawn region. The behavior resolves against the final x scale and plot bounds. SVG and Canvas DOM hosts mount the same D3-backed overlay and contain its events before normal chart focus or selection. Static SVG and React Native paint the -renderer-neutral range and handles without interactive host controls. The +renderer-neutral range and handles without interactive host controls. + +`hover` is a `ChartControlHover`: `capture` (the default) or `passthrough`. +With `passthrough`, idle hover over the blank plot area resolves chart point +focus and the tooltip. The selection and handles keep capturing hover, and an +active pointer or touch drag clears point focus until the next idle hover. +Presses, clicks, and keyboard input stay with the brush, so a click never +also selects a point. `zoomX` accepts the same option. The application still owns fixed-window expansion, validation, linked-view layout, status text, persistence, and any native semantic control. @@ -710,6 +717,14 @@ controls. `ariaLabel` names the plot control, `ariaDescription` can replace its generated instructions, `format` formats values in those instructions, and `onActiveChange` observes focus without moving accepted state into Charts. +`hover` is the same `ChartControlHover` option that `brushX` accepts. The +default `capture` keeps all pointer input on the zoom surface, so the plot +shows no point focus underneath. `passthrough` lets idle hover resolve chart +point focus and the tooltip. An active pan or touch gesture clears point focus +until the next idle hover. Presses, clicks, wheel, and keyboard input stay +with zoom; after an accepted wheel zoom, the host re-resolves focus at the +last pointer position. + Charts does not mutate the controlled snapshot. Keep visible-row filtering or clipping, y-domain policy, status, reset and recovery controls, follow-latest behavior, and persistence in the application. Static SVG and React Native @@ -725,7 +740,8 @@ The public zoom types are: | `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 | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, hover, accessibility, and keys | +| `ChartControlHover` | `capture` or `passthrough` idle hover; shared with `brushX` | ## Disabling chart-owned focus diff --git a/docs/reference/index.md b/docs/reference/index.md index aa294b54..6cdb8935 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -150,10 +150,10 @@ aggregate `/scales` export. | `@tanstack/charts/hierarchy/treemap` | `treemap`, built-in and callable tile types, immutable node values, comparators, and path/parent option types | | `@tanstack/charts/hierarchy/sunburst` | `sunburst`, `SunburstNode`, `SunburstNodeComparator`, `SunburstPathOptions`, `SunburstParentOptions`, `SunburstOptions` | | `@tanstack/charts/hexagon` | `hexagon` | -| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | +| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, hover, 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`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, `ZoomXOptions`, and `ChartControlHover` | | `@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 0e977edd..2107150f 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -481,8 +481,8 @@ their behavior: and [Interactive categorical legend](./scales-guides-and-color.md#interactive-categorical-legend). - `@tanstack/charts/interaction/brush`: `BrushRange`, `BrushXChange`, - `BrushXFormatContext`, `BrushXSource`, `BrushXTarget`, - `BrushXValuesOptions`, and `BrushXContinuousOptions`. See + `BrushXFormatContext`, `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, + `BrushXContinuousOptions`, and `ChartControlHover`. See [Horizontal brush](./focus-and-interaction.md#horizontal-brush). - `@tanstack/charts/interaction/cursor`: `ContinuousCursorValue`, `ContinuousCursorPosition`, `ContinuousCursorPointerSource`, @@ -491,8 +491,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and - `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, + `ZoomXOptions`, and `ChartControlHover`. 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 a6ab014c..0ab3ec4f 100644 --- a/packages/charts-core/docs/comparison.md +++ b/packages/charts-core/docs/comparison.md @@ -12,14 +12,14 @@ turning untested behavior into a checkmark. | Library | Package | Measured source | | -------------------------------------------------------------------------------------- | -------------------- | ------------------- | -| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `a691036` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3f579fd` | | [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` | | [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` | | [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` | | [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` | The competitor versions are exact package pins, not latest versions inferred -at page render time. The measured TanStack workspace revision is `a691036`. +at page render time. The measured TanStack workspace revision is `3f579fd`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-10-13`. +Baseline date: `2026-10-14`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -106,10 +106,10 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Library | Bundle size | React externalized | Evidence | | ------------------ | -------------------------------------- | -------------------: | ---------------------------------------------------------- | -| TanStack Charts | 43.21–49.50 kB gzip | Not applicable | Controlled suite | +| TanStack Charts | 43.23–49.52 kB gzip | Not applicable | Controlled suite | | D3 | 90 KB gzip | — | External main export | -| Chart.js | 45.78–59.61 kB gzip | — | Controlled suite | -| Apache ECharts | 156.77–177.34 kB gzip | — | Controlled suite | +| Chart.js | 45.78–59.61 kB gzip | Not applicable | Controlled suite | +| Apache ECharts | 156.77–177.34 kB gzip | Not applicable | Controlled suite | | Recharts | 156.76–172.30 kB gzip | 97.24–112.60 kB gzip | Controlled suite | | visx | 49 KB gzip | — | External `@visx/xychart` main export | | Plotly.js | ~250 kB partial; ~3.6 MB full min+gzip | — | [Vendor distribution figures](https://plotly.com/graphs/) | @@ -120,7 +120,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July | Victory | 105 KB gzip | — | External main export | | uPlot | 22 KB gzip | — | External `uplot@1.6.32` main export | | Vega-Lite | 87 KB gzip | — | External `vega-lite@6.4.3`; excludes the peer Vega runtime | -| Observable Plot | 85.34–94.15 kB gzip | — | Controlled suite | +| Observable Plot | 85.34–94.15 kB gzip | Not applicable | Controlled suite | | Bklit UI | — | — | Registry-installed source; no fixed package bundle | | AG Charts | 367 KB gzip | — | External `ag-charts-community@14.0.2` main export | diff --git a/packages/charts-core/docs/guides/interactions-and-selections.md b/packages/charts-core/docs/guides/interactions-and-selections.md index a40c7383..7de30cdd 100644 --- a/packages/charts-core/docs/guides/interactions-and-selections.md +++ b/packages/charts-core/docs/guides/interactions-and-selections.md @@ -442,7 +442,9 @@ mountChart(element, { This prevents an application-owned gesture from competing with the host's point marker and tooltip. First-party host controls such as `brushX` and -`zoomX` isolate their own events. +`zoomX` isolate their own events by default. Set `hover: 'passthrough'` on +either control to keep point focus and tooltips on the same plot; see +[Tooltips with brush or zoom](#tooltips-with-brush-or-zoom). Use `pointer: false` plus the interaction controller when the application owns the gesture but the chart should still own datum focus. `focusDisabled` does @@ -684,6 +686,109 @@ recovery controls, follow-latest behavior, and persistence in the application. A reset updates the same controlled window. Import `d3-zoom` directly only when the application needs a different gesture policy. +## Tooltips with brush or zoom + +`brushX` and `zoomX` share one `hover` option. The default, `capture`, keeps +every pointer event over the control, so the plot shows no point focus or +tooltip while the pointer is over it. Set `hover: 'passthrough'` when the same +plot should also show point tooltips: + +- Idle hover over the zoom surface, or over the blank brush area, focuses the + nearest point and shows the tooltip. +- The brush selection and its handles keep capturing hover. +- During a pan, brush drag, or touch gesture, point focus clears. It returns on + the next idle hover. +- Presses, clicks, wheel input, and keyboard input still belong to the + control. A click on the plot pans or brushes; it never also selects a point. + After an accepted wheel zoom, the host re-resolves focus at the last pointer + position. + +```tsx group=zoom-with-tooltips env=charts-react file=/src/App.tsx entry +import { useMemo, useState } from 'react' +import { defineChart, lineY } from '@tanstack/charts' +import { scaleLinear } from '@tanstack/charts/scales/linear' +import { + zoomX, + type ZoomXChange, + type ZoomXWindow, +} from '@tanstack/charts/interaction/zoom' +import { controlledSignal } from '@tanstack/charts/interaction/signal' +import { tooltip } from '@tanstack/charts/tooltip' +import { Chart } from '@tanstack/charts/react' +import { rows } from './data' + +const extent = [1, 12] as const + +export default function App() { + const [visible, setVisible] = useState>({ + start: 3, + end: 9, + }) + const definition = useMemo( + () => + defineChart({ + marks: [ + lineY(rows, { + x: 'week', + y: 'signups', + points: true, + stroke: '#2563eb', + strokeWidth: 2.5, + }), + ], + scales: { + x: { scale: scaleLinear().domain([visible.start, visible.end]) }, + y: { scale: scaleLinear, grid: true, axis: { label: 'Signups' } }, + }, + controls: [ + zoomX({ + window: controlledSignal, ZoomXChange>( + visible, + (next) => setVisible(next), + ), + extent, + scaleExtent: [1, 4], + hover: 'passthrough', + ariaLabel: 'Zoomable signup weeks', + format: (week) => `Week ${Math.round(week)}`, + }), + ], + tooltip, + }), + [visible], + ) + + return ( + + ) +} +``` + +```ts group=zoom-with-tooltips file=/src/data.ts collapsed +export const rows = [ + { week: 1, signups: 18 }, + { week: 2, signups: 24 }, + { week: 3, signups: 31 }, + { week: 4, signups: 29 }, + { week: 5, signups: 42 }, + { week: 6, signups: 48 }, + { week: 7, signups: 46 }, + { week: 8, signups: 57 }, + { week: 9, signups: 61 }, + { week: 10, signups: 58 }, + { week: 11, signups: 66 }, + { week: 12, signups: 72 }, +] +``` + +`brushX({ range, values, hover: 'passthrough' })` works the same way. Put +only one of the two controls on a plot. Both cover the plot, so the one +mounted last takes every press over it. + ## Linked views Store one semantic cursor, selection, or domain and derive every view from it. diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index a0d33fc5..b27b0faf 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -624,7 +624,14 @@ drawn region. The behavior resolves against the final x scale and plot bounds. SVG and Canvas DOM hosts mount the same D3-backed overlay and contain its events before normal chart focus or selection. Static SVG and React Native paint the -renderer-neutral range and handles without interactive host controls. The +renderer-neutral range and handles without interactive host controls. + +`hover` is a `ChartControlHover`: `capture` (the default) or `passthrough`. +With `passthrough`, idle hover over the blank plot area resolves chart point +focus and the tooltip. The selection and handles keep capturing hover, and an +active pointer or touch drag clears point focus until the next idle hover. +Presses, clicks, and keyboard input stay with the brush, so a click never +also selects a point. `zoomX` accepts the same option. The application still owns fixed-window expansion, validation, linked-view layout, status text, persistence, and any native semantic control. @@ -710,6 +717,14 @@ controls. `ariaLabel` names the plot control, `ariaDescription` can replace its generated instructions, `format` formats values in those instructions, and `onActiveChange` observes focus without moving accepted state into Charts. +`hover` is the same `ChartControlHover` option that `brushX` accepts. The +default `capture` keeps all pointer input on the zoom surface, so the plot +shows no point focus underneath. `passthrough` lets idle hover resolve chart +point focus and the tooltip. An active pan or touch gesture clears point focus +until the next idle hover. Presses, clicks, wheel, and keyboard input stay +with zoom; after an accepted wheel zoom, the host re-resolves focus at the +last pointer position. + Charts does not mutate the controlled snapshot. Keep visible-row filtering or clipping, y-domain policy, status, reset and recovery controls, follow-latest behavior, and persistence in the application. Static SVG and React Native @@ -725,7 +740,8 @@ The public zoom types are: | `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 | +| `ZoomXOptions` | Controlled window, extent, limits, wheel, hover, accessibility, and keys | +| `ChartControlHover` | `capture` or `passthrough` idle hover; shared with `brushX` | ## Disabling chart-owned focus diff --git a/packages/charts-core/docs/reference/index.md b/packages/charts-core/docs/reference/index.md index aa294b54..6cdb8935 100644 --- a/packages/charts-core/docs/reference/index.md +++ b/packages/charts-core/docs/reference/index.md @@ -150,10 +150,10 @@ aggregate `/scales` export. | `@tanstack/charts/hierarchy/treemap` | `treemap`, built-in and callable tile types, immutable node values, comparators, and path/parent option types | | `@tanstack/charts/hierarchy/sunburst` | `sunburst`, `SunburstNode`, `SunburstNodeComparator`, `SunburstPathOptions`, `SunburstParentOptions`, `SunburstOptions` | | `@tanstack/charts/hexagon` | `hexagon` | -| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, and option types | +| `@tanstack/charts/interaction/brush` | `brushX` and horizontal-brush range, change, source, target, hover, 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`, `ZoomXWheelActivation`, `ZoomXChange`, and `ZoomXOptions` | +| `@tanstack/charts/interaction/zoom` | `zoomX`, `ZoomXValue`, `ZoomXWindow`, `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, `ZoomXOptions`, and `ChartControlHover` | | `@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 0e977edd..2107150f 100644 --- a/packages/charts-core/docs/reference/types.md +++ b/packages/charts-core/docs/reference/types.md @@ -481,8 +481,8 @@ their behavior: and [Interactive categorical legend](./scales-guides-and-color.md#interactive-categorical-legend). - `@tanstack/charts/interaction/brush`: `BrushRange`, `BrushXChange`, - `BrushXFormatContext`, `BrushXSource`, `BrushXTarget`, - `BrushXValuesOptions`, and `BrushXContinuousOptions`. See + `BrushXFormatContext`, `BrushXSource`, `BrushXTarget`, `BrushXValuesOptions`, + `BrushXContinuousOptions`, and `ChartControlHover`. See [Horizontal brush](./focus-and-interaction.md#horizontal-brush). - `@tanstack/charts/interaction/cursor`: `ContinuousCursorValue`, `ContinuousCursorPosition`, `ContinuousCursorPointerSource`, @@ -491,8 +491,8 @@ their behavior: `ContinuousCursorLabelOptions`, and `ContinuousCursorOptions`. See [Continuous cursor](./focus-and-interaction.md#continuous-cursor). - `@tanstack/charts/interaction/zoom`: `ZoomXValue`, `ZoomXWindow`, - `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, and - `ZoomXOptions`. See + `ZoomXSource`, `ZoomXAction`, `ZoomXWheelActivation`, `ZoomXChange`, + `ZoomXOptions`, and `ChartControlHover`. 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/dom-types.ts b/packages/charts-core/src/dom-types.ts index f1f3aca7..becb63bd 100644 --- a/packages/charts-core/src/dom-types.ts +++ b/packages/charts-core/src/dom-types.ts @@ -49,6 +49,12 @@ export interface ChartHostControlExtensionContext< export interface ChartHostControlInstance { update: (control: ChartHostControl, scene: ChartScene) => void contains?: (target: EventTarget | null) => boolean + /** + * Returns true when an idle pointer move over a contained `target` should + * still resolve chart point focus. Presses, clicks, wheel, and keyboard + * input stay with the control. + */ + passesHover?: (target: EventTarget | null) => boolean destroy: () => void } diff --git a/packages/charts-core/src/interaction-brush.test.ts b/packages/charts-core/src/interaction-brush.test.ts index 4eaad613..f94e22fe 100644 --- a/packages/charts-core/src/interaction-brush.test.ts +++ b/packages/charts-core/src/interaction-brush.test.ts @@ -2,7 +2,12 @@ import { scaleLinear, scaleUtc } from 'd3-scale' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { dot } from './dot' import { mountChart } from './dom' -import { brushX, type BrushRange, type BrushXChange } from './interaction-brush' +import { + brushX, + type BrushRange, + type BrushXChange, + type ChartControlHover, +} from './interaction-brush' import { controlledSignal, type ControlledSignalChangeContext, @@ -268,6 +273,62 @@ describe('brushX', () => { container.remove() }) + it.each([ + [undefined, false], + ['capture', false], + ['passthrough', true], + ] as const)( + 'with hover %s, forwards idle blank-plot hover to point focus: %s', + (hover, forwards) => { + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + const host = mountChart(container, { + definition: definition( + range(dates[1], dates[2]), + () => {}, + dates, + false, + hover, + ), + width: 480, + height: 240, + ariaLabel: 'Hover date range', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + const root = container.querySelector('[data-chart-brush="window"]')! + const overlay = root.querySelector('.overlay')! + const selection = root.querySelector('.selection')! + const [first, , last] = host.getScene().points + const focused = () => focusChanges.at(-1) ?? null + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBe(forwards ? first!.key : null) + + selection.dispatchEvent(pointerMove(last!.x, last!.y)) + expect(focused()).toBeNull() + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + beginMouseBrush(container) + root.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBeNull() + const mouseup = new MouseEvent('mouseup', { + bubbles: true, + clientX: 120, + clientY: 100, + }) + Object.defineProperty(mouseup, 'view', { value: window }) + window.dispatchEvent(mouseup) + + overlay.dispatchEvent(pointerMove(first!.x, first!.y)) + expect(focused()).toBe(forwards ? first!.key : null) + + host.destroy() + container.remove() + }, + ) + it('tells format which handle it is labeling', () => { const container = document.createElement('div') document.body.append(container) @@ -387,6 +448,7 @@ describe('brushX', () => { const scale = host.getScene().scales.x! dragMouse(selection, center, center + scale.map(1.6) - scale.map(1)) expect(changes.at(-1)).toEqual({ start: 1, end: 100 }) + host.destroy() container.remove() }, @@ -437,6 +499,7 @@ function definition( onChange: (value: BrushRange, reason: BrushXChange) => void, values: readonly Date[] = dates, reverse = false, + hover?: ChartControlHover, ) { return defineChart({ marks: [dot(rows, { x: 'date', y: 'value' })], @@ -452,6 +515,7 @@ function definition( (next, { reason }) => onChange(next, reason), ), values, + hover, ariaLabel: 'Visible date range', format: (date) => date.toLocaleDateString('en-US', { @@ -598,6 +662,24 @@ function dispatchTouch( target.dispatchEvent(event) } +function pointerMove(clientX: number, clientY: number) { + return new MouseEvent('pointermove', { bubbles: true, clientX, clientY }) +} + +function mockBounds(element: Element, width: number, height: number) { + vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: width, + bottom: height, + left: 0, + width, + height, + toJSON: () => ({}), + }) +} + function rectGeometry(element: SVGRectElement) { return { x: element.getAttribute('x'), diff --git a/packages/charts-core/src/interaction-brush.ts b/packages/charts-core/src/interaction-brush.ts index 35c0af0c..fb0b25e2 100644 --- a/packages/charts-core/src/interaction-brush.ts +++ b/packages/charts-core/src/interaction-brush.ts @@ -5,8 +5,10 @@ import type { InteractionAxis } from './interaction-axis-internal' import { cloneInteractionRange as cloneRange, normalizeInteractionRange as normalizeRange, + resolveControlHover, sameInteractionRange as sameRange, } from './interaction-range-internal' +import type { ChartControlHover } from './interaction-range-internal' import type { ControlledSignal } from './interaction-signal' import type { ChartControl, @@ -23,6 +25,8 @@ import type { } from './dom-types' import type { BrushSelection, D3BrushEvent } from 'd3-brush' +export type { ChartControlHover } from './interaction-range-internal' + export interface BrushRange { readonly start: TValue readonly end: TValue @@ -61,6 +65,12 @@ interface BrushXBaseOptions { endAriaLabel?: string format?: (value: TValue, context: BrushXFormatContext) => string handleSize?: number + /** + * `passthrough` lets idle hover over the blank plot focus points and show + * the tooltip. The selection, handles, drags, clicks, and keyboard input + * stay with the brush. Defaults to `capture`. + */ + hover?: ChartControlHover selectionStyle?: SceneStyle handleStyle?: SceneStyle } @@ -93,6 +103,7 @@ interface BrushXControl extends ChartHostControl { readonly endAriaLabel: string readonly format: (value: TValue, context: BrushXFormatContext) => string readonly handleSize: number + readonly hover: ChartControlHover readonly selectionStyle: SceneStyle readonly handleStyle: SceneStyle readonly change: ( @@ -117,6 +128,7 @@ export function brushX( ): ChartControl { const id = options.id ?? defaultId const handleSize = finitePositive(options.handleSize ?? 24, 'handleSize') + const hover = resolveControlHover(options.hover, 'brushX') const keyboard = 'values' in options && options.values !== undefined ? options.keyboard !== false @@ -168,6 +180,7 @@ export function brushX( endAriaLabel: options.endAriaLabel ?? 'Range end', format, handleSize, + hover, selectionStyle, handleStyle, change(value, reason) { @@ -274,6 +287,7 @@ function createBrushXControl({ let activeInput: 'mouse' | 'touch' | undefined let activeView: (Window & typeof globalThis) | undefined let cancelledTouchRange: BrushRange | undefined + let overlay: Element | null = null const brush = createD3BrushX() .touchable(true) .on('start.chart-brush-x', handleStart) @@ -309,6 +323,7 @@ function createBrushXControl({ ]) .handleSize(next.handleSize) select(group).call(brush) + overlay = group.querySelector('.overlay') if ( active && @@ -336,6 +351,13 @@ function createBrushXControl({ contains(eventTarget) { return Boolean(eventTarget && root.contains(eventTarget as Node)) }, + passesHover(eventTarget) { + return ( + control?.hover === 'passthrough' && + !active && + (eventTarget === root || eventTarget === overlay) + ) + }, destroy() { if (active) { active = false diff --git a/packages/charts-core/src/interaction-range-internal.ts b/packages/charts-core/src/interaction-range-internal.ts index 6beb53fd..2c0cf06c 100644 --- a/packages/charts-core/src/interaction-range-internal.ts +++ b/packages/charts-core/src/interaction-range-internal.ts @@ -40,3 +40,20 @@ export function cloneInteractionValue( ): TValue { return (value instanceof Date ? new Date(value.getTime()) : value) as TValue } + +/** + * Whether idle pointer hover over a first-party host control reaches chart + * point focus and tooltips. `capture` keeps hover inside the control; + * `passthrough` forwards idle hover while presses, drags, wheel, clicks, and + * keyboard input stay with the control. + */ +export type ChartControlHover = 'capture' | 'passthrough' + +export function resolveControlHover( + hover: ChartControlHover | undefined, + owner: string, +): ChartControlHover { + if (hover === undefined || hover === 'capture') return 'capture' + if (hover === 'passthrough') return hover + throw new TypeError(`${owner} hover must be "capture" or "passthrough"`) +} diff --git a/packages/charts-core/src/interaction-zoom.test.ts b/packages/charts-core/src/interaction-zoom.test.ts index d05984da..71a668ab 100644 --- a/packages/charts-core/src/interaction-zoom.test.ts +++ b/packages/charts-core/src/interaction-zoom.test.ts @@ -51,6 +51,15 @@ describe('zoomX', () => { 'zoomX wheelActivation must be "focus", "modifier", or "always"', ), ) + expect(() => + zoomX({ + window: controlledSignal({ start: 0, end: 1 }, () => {}), + extent: [0, 1], + hover: 'always' as never, + }), + ).toThrowError( + new TypeError('zoomX hover must be "capture" or "passthrough"'), + ) }) it('owns focus, keyboard navigation, reset, controlled updates, and teardown', () => { @@ -697,6 +706,142 @@ describe('zoomX', () => { } }) + it.each([ + [undefined, false], + ['capture', false], + ['passthrough', true], + ] as const)( + 'with hover %s, forwards idle plot hover to point focus: %s', + (hover, forwards) => { + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + const host = mountChart(container, { + definition: defineChart({ + marks: [dot(numericRows, { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 10]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + controls: [ + zoomX({ + id: 'window', + window: controlledSignal< + ZoomXWindow, + ZoomXChange + >({ start: 0, end: 10 }, () => {}), + extent: [0, 10], + hover, + }), + ], + }), + width: 480, + height: 240, + ariaLabel: 'Hover zoom', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + const target = zoomTarget(container) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + mockBounds(target, 480, 240) + const [point] = host.getScene().points + const hoverPoint = () => + target.dispatchEvent(mouse('pointermove', point!.x, point!.y)) + + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBe(forwards ? point!.key : null) + + target.focus() + target.dispatchEvent(mouse('mousedown', point!.x, point!.y)) + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBeNull() + window.dispatchEvent(mouse('mouseup', point!.x, point!.y)) + + hoverPoint() + expect(focusChanges.at(-1) ?? null).toBe(forwards ? point!.key : null) + + host.destroy() + container.remove() + }, + ) + + it('keeps passthrough hover focus through surface focus and re-resolves it after wheel zoom', () => { + const rows = [ + { x: 2, y: 5 }, + { x: 8, y: 5 }, + ] + let accepted: ZoomXWindow = { start: 0, end: 10 } + const focusChanges: Array = [] + const container = document.createElement('div') + document.body.append(container) + let host: ChartHost<(typeof rows)[number], number, number> + const options = (): ChartHostOptions< + (typeof rows)[number], + number, + number + > => ({ + definition: defineChart({ + marks: [dot(rows, { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([accepted.start, accepted.end]) }, + y: { scale: scaleLinear().domain([0, 10]) }, + }, + controls: [ + zoomX({ + id: 'window', + window: controlledSignal, ZoomXChange>( + accepted, + (next) => { + accepted = copyWindow(next) + host.update(options()) + }, + ), + extent: [0, 10], + hover: 'passthrough', + }), + ], + }), + width: 480, + height: 240, + ariaLabel: 'Wheel zoom with tooltips', + onFocusChange: (point) => focusChanges.push(point?.key ?? null), + }) + host = mountChart(container, options()) + const target = zoomTarget(container) + mockBounds(container.querySelector('svg.ts-chart')!, 480, 240) + const [point] = host.getScene().points + target.dispatchEvent(mouse('pointermove', point!.x, point!.y)) + expect(focusChanges.at(-1)).toBe(point!.key) + + target.dispatchEvent(mouse('pointerdown', point!.x, point!.y)) + expect(document.activeElement).toBe(target) + expect(focusChanges.at(-1)).toBe(point!.key) + + focusChanges.length = 0 + target.dispatchEvent(wheel(point!.x + 10, point!.y, { deltaY: -240 })) + expect(accepted.end - accepted.start).toBeLessThan(10) + expect(focusChanges).toEqual([]) + const [updatedPoint, nextPoint] = host.getScene().points + expect(updatedPoint!.x).not.toBe(point!.x) + const focusedCircle = container.querySelector( + '.ts-chart__focus-layer--default circle[visibility="visible"]', + ) + expect(focusedCircle).not.toBeNull() + expect(Number(focusedCircle!.getAttribute('cx'))).toBeCloseTo( + updatedPoint!.x, + 2, + ) + expect(Number(focusedCircle!.getAttribute('cy'))).toBeCloseTo( + updatedPoint!.y, + 2, + ) + + target.dispatchEvent(mouse('pointermove', nextPoint!.x, nextPoint!.y)) + expect(focusChanges).toEqual([nextPoint!.key]) + + host.destroy() + container.remove() + }) + it('previews and commits mouse pan, then rolls a later pan back on Escape', () => { const initial = { start: 2.5, end: 7.5 } const calls: Array<{ diff --git a/packages/charts-core/src/interaction-zoom.ts b/packages/charts-core/src/interaction-zoom.ts index bf3da3b3..6b932e6a 100644 --- a/packages/charts-core/src/interaction-zoom.ts +++ b/packages/charts-core/src/interaction-zoom.ts @@ -5,8 +5,10 @@ import type { InteractionAxis } from './interaction-axis-internal' import { cloneInteractionRange, normalizeInteractionRange, + resolveControlHover, sameInteractionRange, } from './interaction-range-internal' +import type { ChartControlHover } from './interaction-range-internal' import type { ControlledSignal } from './interaction-signal' import type { ChartControl, @@ -20,6 +22,8 @@ import type { } from './dom-types' import type { D3ZoomEvent, ZoomTransform } from 'd3-zoom' +export type { ChartControlHover } from './interaction-range-internal' + export type ZoomXValue = number | Date export interface ZoomXWindow { @@ -63,6 +67,12 @@ export interface ZoomXOptions { scaleExtent?: readonly [number, number] /** When wheel input may zoom or pan. Defaults to `focus`. */ wheelActivation?: ZoomXWheelActivation + /** + * `passthrough` lets idle hover over the plot focus points and show the + * tooltip. Drag, wheel, click, and keyboard input stay with zoom. Defaults + * to `capture`. + */ + hover?: ChartControlHover keyboard?: boolean ariaLabel?: string ariaDescription?: string @@ -81,6 +91,7 @@ interface ZoomXControl extends ChartHostControl { readonly extent: ZoomXWindow readonly scaleExtent: readonly [number, number] readonly wheelActivation: ZoomXWheelActivation + readonly hover: ChartControlHover readonly keyboard: boolean readonly ariaLabel: string readonly ariaDescription?: string @@ -110,6 +121,7 @@ export function zoomX( } const scaleExtent = resolveScaleExtent(options.scaleExtent) const wheelActivation = resolveWheelActivation(options.wheelActivation) + const hover = resolveControlHover(options.hover, 'zoomX') return { id, @@ -146,6 +158,7 @@ export function zoomX( extent, scaleExtent, wheelActivation, + hover, keyboard: options.keyboard !== false, ariaLabel: options.ariaLabel ?? defaultAriaLabel, ariaDescription: options.ariaDescription, @@ -301,6 +314,9 @@ function createZoomXControl({ contains(target) { return Boolean(target && root.contains(target as Node)) }, + passesHover() { + return control?.hover === 'passthrough' && !gestureActive + }, destroy() { destroying = true clearWheel() diff --git a/packages/charts-core/src/renderer.ts b/packages/charts-core/src/renderer.ts index 8f49a7d8..cd9c05fa 100644 --- a/packages/charts-core/src/renderer.ts +++ b/packages/charts-core/src/renderer.ts @@ -737,7 +737,7 @@ export function mountChartRenderer< } const handlePointerMove = (event: PointerEvent) => { - if (controlContains(event.target)) { + if (controlContains(event.target, true)) { if (!interactionIsPinned()) { pointerPosition = null updateFocus([]) @@ -931,7 +931,7 @@ export function mountChartRenderer< updateFocus(point ? focusPointsForPoint(point) : []) } const handleFocus = (event: FocusEvent) => { - if (controlContains(event.target)) { + if (controlContains(event.target, true)) { if (!pinnedKey) { pointerPosition = null updateFocus([]) @@ -1159,9 +1159,14 @@ export function mountChartRenderer< controlInstances.clear() } - function controlContains(target: EventTarget | null) { - for (const current of controlInstances.values()) { - if (current.instance.contains?.(target)) return true + function controlContains(target: EventTarget | null, hover?: boolean) { + for (const { instance } of controlInstances.values()) { + if ( + instance.contains?.(target) && + !(hover && instance.passesHover?.(target)) + ) { + return true + } } return false } diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index 93578ebc..2fc249b4 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -1553,7 +1553,7 @@ const entries = [ 'Horizontal zoom + DOM host', 'benchmarks/entries/charts-zoom-x.ts', 'TanStack DOM host', - 20.55, + 20.641796875, { rendererBoundary: 'svg', inputBoundary: { @@ -1628,7 +1628,7 @@ const entries = [ budgeted( 'React line consumer + mark Canvas renderer', 'benchmarks/entries/charts-react-line-mark-canvas.ts', - 45.547984375, + 45.5665390625, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'mixed', @@ -1727,7 +1727,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 31.547984375, + 31.5655625, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1895,7 +1895,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 57.3744609375, + 57.408640625, ), locked( 'Custom-scale line scene', @@ -1946,12 +1946,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 39.847984375, + 39.8684921875, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 45.1320546875, + 45.1525625, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2155,7 +2155,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 58.46075, + 58.4793046875, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot minimal line', 'benchmarks/entries/minimal-line.ts'), diff --git a/scripts/public-callback-contract.mjs b/scripts/public-callback-contract.mjs index 048a75fa..cb9864ec 100644 --- a/scripts/public-callback-contract.mjs +++ b/scripts/public-callback-contract.mjs @@ -606,7 +606,7 @@ const callbackInventory = { ['@tanstack/charts:src/dom-types.ts:ChartHostControlExtension', 'create'], [ '@tanstack/charts:src/dom-types.ts:ChartHostControlInstance', - 'update contains destroy', + 'update contains passesHover destroy', ], [ '@tanstack/angular-charts:src/Chart.ts:Chart',