Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/steady-focus-rings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/charts': patch
---

Keep the built-in primary focus ring when authored focus marks are present, and
add `focusRing: false` for charts that replace the indicator explicitly.
40 changes: 40 additions & 0 deletions API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ Each entry records:
| F-193 | Fixed catalog height hid compact responsive examples | Tooling/App | resolved |
| F-194 | Behavior runs omitted the interactive input | Tooling | resolved |
| F-195 | Release versions matched dependency substrings | Tooling | resolved |
| F-196 | Focus decorations suppressed the primary indicator | API | resolved |
| F-197 | Workspace validation omitted comparison provenance | Tooling | resolved |

## Findings

Expand Down Expand Up @@ -4868,3 +4870,41 @@ Each entry records:
from 0.6.0 to 0.6.1 while leaving Observable Plot 0.6.17 unchanged. The
release workflow can create the 0.6.1 version pull request from the same
changeset.

### F-196 — Focus decorations suppressed the primary indicator

- Status: resolved
- Severity: high
- Owner: API
- Observed in: issue #33 focused-band composition
- Friction: adding any `whenFocused` mark removed the built-in primary-point
ring from the complete chart. A category highlight or crosshair therefore
also removed the indicator that identified the point owned by the tooltip
and keyboard focus.
- Decision: compose the built-in ring with authored focus layers by default.
Add definition `focusRing: false` for charts whose authored geometry
deliberately replaces the primary indicator.
- Verification: scene and SVG regressions retain both a focused category band
and one primary ring, Canvas paints authored underlays and the default
overlay together, facets retain synchronized authored bands plus one shared
ring, and an explicit opt-out omits the ring. Removing the implicit
focus-layer scan reduces the ten locked universal bundles by 109–110
minified bytes and 22–48 gzip bytes; the reviewed exact baseline records the
decrease.

### F-197 — Workspace validation omitted comparison provenance

- Status: resolved
- Severity: low
- Owner: Tooling
- Observed in: issue #33 release validation
- Friction: `pnpm run validate` passed the locked universal bundle policy but
did not run the separate comparison baseline gate. Because the focus fix
changed a core comparison input, pull-request CI correctly rejected the
stale workspace revision even though local validation was green.
- Decision: refresh the comparison evidence whenever a change advances one of
its tracked TanStack inputs, and run `pnpm benchmark:check` alongside the
workspace validation before release.
- Verification: the 60-case comparison baseline records revision `e4b5249`
and package version 0.6.2, the canonical comparison page identifies the same
revision, and `pnpm benchmark:check` passes locally and in pull-request CI.
40 changes: 20 additions & 20 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,44 @@
"policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.",
"bundles": {
"D3-scale line scene": {
"bytes": 39555,
"gzip": 15385
"bytes": 39446,
"gzip": 15338
},
"D3-scale line + static SVG": {
"bytes": 42496,
"gzip": 16463
"bytes": 42387,
"gzip": 16429
},
"Representative marks": {
"bytes": 58285,
"gzip": 21632
"bytes": 58176,
"gzip": 21599
},
"TanStack DOM host": {
"bytes": 44570,
"gzip": 16278
"bytes": 44461,
"gzip": 16247
},
"React adapter": {
"bytes": 46738,
"gzip": 16971
"bytes": 46629,
"gzip": 16949
},
"React line consumer": {
"bytes": 69404,
"gzip": 26052
"bytes": 69295,
"gzip": 26026
},
"Compact-scale line scene": {
"bytes": 22019,
"gzip": 8326
"bytes": 21910,
"gzip": 8283
},
"React compact-scale line consumer": {
"bytes": 51915,
"gzip": 18954
"bytes": 51805,
"gzip": 18924
},
"Custom-scale line scene": {
"bytes": 20204,
"gzip": 7581
"bytes": 20094,
"gzip": 7544
},
"D3 linear-scale line scene": {
"bytes": 39487,
"gzip": 15349
"bytes": 39378,
"gzip": 15301
}
}
}
126 changes: 63 additions & 63 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"schemaVersion": 3,
"generatedAt": "2026-08-02T20:16:51.564Z",
"generatedAt": "2026-08-02T21:03:04.476Z",
"packageVersions": {
"tanstack": "0.6.1",
"tanstack": "0.6.2",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
Expand All @@ -11,7 +11,7 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "0eeec7dbe62d362839820b953a731ae0596206ee"
"revision": "e4b5249ceca9910d990aefc9f8777a8d28e63921"
},
"chartjs": {
"kind": "package",
Expand Down Expand Up @@ -44,88 +44,88 @@
},
"bundles": {
"tanstack-line-basic": {
"minifiedBytes": 78211,
"gzipBytes": 29465,
"brotliBytes": 26139,
"incrementalGzipBytes": 29465,
"incrementalBrotliBytes": 26139
"minifiedBytes": 78102,
"gzipBytes": 29435,
"brotliBytes": 26049,
"incrementalGzipBytes": 29435,
"incrementalBrotliBytes": 26049
},
"tanstack-line-interactive": {
"minifiedBytes": 83382,
"gzipBytes": 31104,
"brotliBytes": 27459,
"incrementalGzipBytes": 31104,
"incrementalBrotliBytes": 27459
"minifiedBytes": 83273,
"gzipBytes": 31071,
"brotliBytes": 27438,
"incrementalGzipBytes": 31071,
"incrementalBrotliBytes": 27438
},
"tanstack-line-advanced": {
"minifiedBytes": 90567,
"gzipBytes": 33457,
"brotliBytes": 29507,
"incrementalGzipBytes": 33457,
"incrementalBrotliBytes": 29507
"minifiedBytes": 90458,
"gzipBytes": 33429,
"brotliBytes": 29450,
"incrementalGzipBytes": 33429,
"incrementalBrotliBytes": 29450
},
"tanstack-bar-basic": {
"minifiedBytes": 85222,
"gzipBytes": 32128,
"brotliBytes": 28411,
"incrementalGzipBytes": 32128,
"incrementalBrotliBytes": 28411
"minifiedBytes": 85113,
"gzipBytes": 32109,
"brotliBytes": 28367,
"incrementalGzipBytes": 32109,
"incrementalBrotliBytes": 28367
},
"tanstack-bar-interactive": {
"minifiedBytes": 89248,
"gzipBytes": 33326,
"brotliBytes": 29367,
"incrementalGzipBytes": 33326,
"incrementalBrotliBytes": 29367
"minifiedBytes": 89139,
"gzipBytes": 33306,
"brotliBytes": 29384,
"incrementalGzipBytes": 33306,
"incrementalBrotliBytes": 29384
},
"tanstack-bar-advanced": {
"minifiedBytes": 89587,
"gzipBytes": 33458,
"brotliBytes": 29488,
"incrementalGzipBytes": 33458,
"incrementalBrotliBytes": 29488
"minifiedBytes": 89478,
"gzipBytes": 33449,
"brotliBytes": 29510,
"incrementalGzipBytes": 33449,
"incrementalBrotliBytes": 29510
},
"tanstack-area-basic": {
"minifiedBytes": 82020,
"gzipBytes": 30953,
"brotliBytes": 27423,
"incrementalGzipBytes": 30953,
"incrementalBrotliBytes": 27423
"minifiedBytes": 81911,
"gzipBytes": 30928,
"brotliBytes": 27358,
"incrementalGzipBytes": 30928,
"incrementalBrotliBytes": 27358
},
"tanstack-area-interactive": {
"minifiedBytes": 87199,
"gzipBytes": 32637,
"brotliBytes": 28802,
"incrementalGzipBytes": 32637,
"incrementalBrotliBytes": 28802
"minifiedBytes": 87090,
"gzipBytes": 32608,
"brotliBytes": 28679,
"incrementalGzipBytes": 32608,
"incrementalBrotliBytes": 28679
},
"tanstack-area-advanced": {
"minifiedBytes": 94566,
"gzipBytes": 34929,
"brotliBytes": 30869,
"incrementalGzipBytes": 34929,
"incrementalBrotliBytes": 30869
"minifiedBytes": 94457,
"gzipBytes": 34904,
"brotliBytes": 30844,
"incrementalGzipBytes": 34904,
"incrementalBrotliBytes": 30844
},
"tanstack-scatter-basic": {
"minifiedBytes": 77886,
"gzipBytes": 29367,
"brotliBytes": 26035,
"incrementalGzipBytes": 29367,
"incrementalBrotliBytes": 26035
"minifiedBytes": 77777,
"gzipBytes": 29332,
"brotliBytes": 26021,
"incrementalGzipBytes": 29332,
"incrementalBrotliBytes": 26021
},
"tanstack-scatter-interactive": {
"minifiedBytes": 83057,
"gzipBytes": 31005,
"brotliBytes": 27357,
"incrementalGzipBytes": 31005,
"incrementalBrotliBytes": 27357
"minifiedBytes": 82948,
"gzipBytes": 30973,
"brotliBytes": 27313,
"incrementalGzipBytes": 30973,
"incrementalBrotliBytes": 27313
},
"tanstack-scatter-advanced": {
"minifiedBytes": 83073,
"gzipBytes": 31010,
"brotliBytes": 27387,
"incrementalGzipBytes": 31010,
"incrementalBrotliBytes": 27387
"minifiedBytes": 82964,
"gzipBytes": 30979,
"brotliBytes": 27318,
"incrementalGzipBytes": 30979,
"incrementalBrotliBytes": 27318
},
"chartjs-line-basic": {
"minifiedBytes": 137909,
Expand Down
6 changes: 3 additions & 3 deletions docs/comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ untested behavior into a checkmark.

| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `0eeec7d` |
| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `e4b5249` |
| [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 `0eeec7d`.
at page render time. The measured TanStack workspace revision is `e4b5249`.

## Capability matrix

Expand Down Expand Up @@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July

| Library | Bundle size | React externalized | Evidence |
| ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- |
| TanStack Charts | 28.68–34.11 KiB | — | Controlled suite |
| TanStack Charts | 28.64–34.09 KiB | — | Controlled suite |
| D3 | 90 KB gzip | — | External main export |
| Chart.js | 44.70–58.21 KiB | — | Controlled suite |
| Apache ECharts | 153.10–173.18 KiB | — | Controlled suite |
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/chart-definitions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ does not return or own them.
## Definition behavior

`ChartDefinitionOptions<TDatum, TXValue, TYValue>` contains `focus`,
`maxFocusDistance`, `spatialIndex`, `animate`, `keyboard`, and `tooltip`.
These options belong to both static and responsive definitions. Hosts and
framework adapters do not override them.
`focusRing`, `maxFocusDistance`, `spatialIndex`, `animate`, `keyboard`, and
`tooltip`. These options belong to both static and responsive definitions.
Hosts and framework adapters do not override them.

Tooltip placement policy stays with the definition. Add the `portal` extension
when the surface must escape clipped chart ancestors. Framework-only content
Expand Down
1 change: 1 addition & 0 deletions docs/reference/dom-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ The definition owns these chart behaviors:
| ------------------ | ------------------------- | -------------------------------------------------------- |
| `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus |
| `focus` | Nearest point | Pointer grouping and keyboard navigation strategy |
| `focusRing` | `true` | Built-in primary-point focus indicator |
| `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index |
| `animate` | `false` | Keyed attribute, enter, and exit animation |
| `keyboard` | `true` | Keyboard focus and navigation |
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/focus-and-interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ With no custom focus strategy:
`maxFocusDistance` defaults to `48` scene pixels. Set `tabIndex` to control
normal tab-order participation while keeping keyboard handling enabled. Set
`keyboard: false` to remove keyboard navigation and force tab index `-1`.
Authored `whenFocused` marks compose with the primary-point ring. Set
definition `focusRing: false` only when authored focus geometry replaces that
indicator.

### Interaction geometry

Expand Down
22 changes: 14 additions & 8 deletions examples/sandbox/src/InteractionGeometryLab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,20 @@ describe('intentional interaction contracts', () => {
expect(primaryLayers.every((node) => node.focus?.match === 'primary')).toBe(
true,
)
expect(synchronizedXLayers).toHaveLength(2)
expect(synchronizedXLayers.every((node) => node.focus?.match === 'x')).toBe(
true,
)
expect(synchronizedYLayers).toHaveLength(2)
expect(synchronizedYLayers.every((node) => node.focus?.match === 'y')).toBe(
true,
)
expect(synchronizedXLayers).toHaveLength(3)
expect(
synchronizedXLayers.filter((node) => node.focus?.match === 'x'),
).toHaveLength(2)
expect(
synchronizedXLayers.filter((node) => node.focus?.match === 'primary'),
).toHaveLength(1)
expect(synchronizedYLayers).toHaveLength(3)
expect(
synchronizedYLayers.filter((node) => node.focus?.match === 'y'),
).toHaveLength(2)
expect(
synchronizedYLayers.filter((node) => node.focus?.match === 'primary'),
).toHaveLength(1)
})

it('keeps the animated case on a deliberately tight fallback threshold', () => {
Expand Down
Loading