diff --git a/.changeset/focus-ring-options.md b/.changeset/focus-ring-options.md new file mode 100644 index 00000000..a2597a29 --- /dev/null +++ b/.changeset/focus-ring-options.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': minor +--- + +Allow the built-in focus ring to configure its radius, stroke width, fill, and stroke while preserving the existing boolean behavior and defaults. diff --git a/API-FRICTION.md b/API-FRICTION.md index 5204435b..c0791442 100644 --- a/API-FRICTION.md +++ b/API-FRICTION.md @@ -331,6 +331,7 @@ Each entry records: | F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved | | F-293 | Root scale slots blocked named axes | API | resolved | | F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved | +| F-295 | Focus ring paint required generated SVG selectors | API | resolved | ## Findings @@ -8505,3 +8506,24 @@ Each entry records: React Native Metro gates, and framework package checks pass. Bundle boundary checks keep SVG-only entries free of Canvas and measure the opt-in mixed representative and React consumers at 35.68 KiB and 41.63 KiB gzip. + +### F-295 - Focus ring paint required generated SVG selectors + +- Status: resolved +- Severity: medium +- Owner: API +- Observed in: Rewardo's TanStack Charts migration and issue #94 +- Friction: the built-in primary-point ring exposed only a boolean definition + option. Rewardo had to target `.ts-chart__focus-layer--default circle` to + reduce the fixed 5-pixel radius, tying application styling to generated SVG + structure and leaving Canvas and React Native without the same control. +- Decision: extend `focusRing` to accept a reusable `ChartFocusRingOptions` + object with radius, stroke width, fill, and stroke. Preserve `true`, `false`, + and every existing default. An omitted stroke keeps each point's resolved + series color. +- Verification: scene, SVG, Canvas, and React Native tests cover configured + geometry and paint, series-color fallback, keyboard focus, and hidden + accessibility presentation. The catalog's pointer-tooltip case uses the + object form, and the reference docs record the complete contract. Locked + universal consumers add 65-79 minified bytes and 25-36 gzip bytes; the + reviewed bundle baselines and budgets pass. diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json index 486ae8f9..3981a2ce 100644 --- a/benchmarks/bundle-size/universal-baseline.json +++ b/benchmarks/bundle-size/universal-baseline.json @@ -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": 50813, - "gzip": 19070 + "bytes": 50878, + "gzip": 19095 }, "D3-scale line + static SVG": { - "bytes": 55544, - "gzip": 20776 + "bytes": 55612, + "gzip": 20808 }, "Representative marks": { - "bytes": 75617, - "gzip": 27582 + "bytes": 75683, + "gzip": 27615 }, "TanStack DOM host": { - "bytes": 74947, - "gzip": 26053 + "bytes": 75026, + "gzip": 26087 }, "React adapter": { - "bytes": 77130, - "gzip": 26831 + "bytes": 77209, + "gzip": 26866 }, "React line consumer": { - "bytes": 100766, - "gzip": 36222 + "bytes": 100839, + "gzip": 36251 }, "Compact-scale line scene": { - "bytes": 33265, - "gzip": 11935 + "bytes": 33336, + "gzip": 11969 }, "React compact-scale line consumer": { - "bytes": 83266, - "gzip": 29144 + "bytes": 83345, + "gzip": 29173 }, "Custom-scale line scene": { - "bytes": 31447, - "gzip": 11193 + "bytes": 31518, + "gzip": 11229 }, "D3 linear-scale line scene": { - "bytes": 50745, - "gzip": 19032 + "bytes": 50810, + "gzip": 19057 } } } diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json index a22a54c1..b457f5f6 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-08-27T22:10:29.425Z", "packageVersions": { - "tanstack": "0.15.0", + "tanstack": "0.16.0", "chartjs": "4.5.1", "echarts": "6.1.0", "recharts": "3.10.1", @@ -11,8 +11,8 @@ "sources": { "tanstack": { "kind": "workspace", - "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a", - "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e" + "revision": "cb1779249a6803d591526f7f4e42a20a40e11f4f", + "inputDigest": "sha256:f269a4edd2c1aea574be0de98bc6dff87ad5044501cb4d0a65c4cb565ad4c2ae" }, "chartjs": { "kind": "package", @@ -45,88 +45,88 @@ }, "bundles": { "tanstack-line-basic": { - "minifiedBytes": 110431, - "gzipBytes": 39891, - "brotliBytes": 35325, - "incrementalGzipBytes": 39891, - "incrementalBrotliBytes": 35325 + "minifiedBytes": 110507, + "gzipBytes": 39915, + "brotliBytes": 35396, + "incrementalGzipBytes": 39915, + "incrementalBrotliBytes": 35396 }, "tanstack-line-interactive": { - "minifiedBytes": 115872, - "gzipBytes": 41648, - "brotliBytes": 36746, - "incrementalGzipBytes": 41648, - "incrementalBrotliBytes": 36746 + "minifiedBytes": 115948, + "gzipBytes": 41682, + "brotliBytes": 36752, + "incrementalGzipBytes": 41682, + "incrementalBrotliBytes": 36752 }, "tanstack-line-advanced": { - "minifiedBytes": 123062, - "gzipBytes": 43974, - "brotliBytes": 38764, - "incrementalGzipBytes": 43974, - "incrementalBrotliBytes": 38764 + "minifiedBytes": 123138, + "gzipBytes": 43996, + "brotliBytes": 38742, + "incrementalGzipBytes": 43996, + "incrementalBrotliBytes": 38742 }, "tanstack-bar-basic": { - "minifiedBytes": 119225, - "gzipBytes": 43210, - "brotliBytes": 38168, - "incrementalGzipBytes": 43210, - "incrementalBrotliBytes": 38168 + "minifiedBytes": 119300, + "gzipBytes": 43240, + "brotliBytes": 38149, + "incrementalGzipBytes": 43240, + "incrementalBrotliBytes": 38149 }, "tanstack-bar-interactive": { - "minifiedBytes": 123521, - "gzipBytes": 44585, - "brotliBytes": 39223, - "incrementalGzipBytes": 44585, - "incrementalBrotliBytes": 39223 + "minifiedBytes": 123596, + "gzipBytes": 44613, + "brotliBytes": 39219, + "incrementalGzipBytes": 44613, + "incrementalBrotliBytes": 39219 }, "tanstack-bar-advanced": { - "minifiedBytes": 123860, - "gzipBytes": 44735, - "brotliBytes": 39349, - "incrementalGzipBytes": 44735, - "incrementalBrotliBytes": 39349 + "minifiedBytes": 123935, + "gzipBytes": 44758, + "brotliBytes": 39385, + "incrementalGzipBytes": 44758, + "incrementalBrotliBytes": 39385 }, "tanstack-area-basic": { - "minifiedBytes": 115640, - "gzipBytes": 41867, - "brotliBytes": 37073, - "incrementalGzipBytes": 41867, - "incrementalBrotliBytes": 37073 + "minifiedBytes": 115714, + "gzipBytes": 41897, + "brotliBytes": 37085, + "incrementalGzipBytes": 41897, + "incrementalBrotliBytes": 37085 }, "tanstack-area-interactive": { - "minifiedBytes": 121085, - "gzipBytes": 43594, - "brotliBytes": 38493, - "incrementalGzipBytes": 43594, - "incrementalBrotliBytes": 38493 + "minifiedBytes": 121159, + "gzipBytes": 43630, + "brotliBytes": 38508, + "incrementalGzipBytes": 43630, + "incrementalBrotliBytes": 38508 }, "tanstack-area-advanced": { - "minifiedBytes": 128457, - "gzipBytes": 46057, - "brotliBytes": 40527, - "incrementalGzipBytes": 46057, - "incrementalBrotliBytes": 40527 + "minifiedBytes": 128530, + "gzipBytes": 46084, + "brotliBytes": 40565, + "incrementalGzipBytes": 46084, + "incrementalBrotliBytes": 40565 }, "tanstack-scatter-basic": { - "minifiedBytes": 111460, - "gzipBytes": 40266, - "brotliBytes": 35596, - "incrementalGzipBytes": 40266, - "incrementalBrotliBytes": 35596 + "minifiedBytes": 111536, + "gzipBytes": 40299, + "brotliBytes": 35718, + "incrementalGzipBytes": 40299, + "incrementalBrotliBytes": 35718 }, "tanstack-scatter-interactive": { - "minifiedBytes": 116901, - "gzipBytes": 42015, - "brotliBytes": 36976, - "incrementalGzipBytes": 42015, - "incrementalBrotliBytes": 36976 + "minifiedBytes": 116977, + "gzipBytes": 42045, + "brotliBytes": 36987, + "incrementalGzipBytes": 42045, + "incrementalBrotliBytes": 36987 }, "tanstack-scatter-advanced": { - "minifiedBytes": 116917, - "gzipBytes": 42020, - "brotliBytes": 37012, - "incrementalGzipBytes": 42020, - "incrementalBrotliBytes": 37012 + "minifiedBytes": 116993, + "gzipBytes": 42050, + "brotliBytes": 37016, + "incrementalGzipBytes": 42050, + "incrementalBrotliBytes": 37016 }, "chartjs-line-basic": { "minifiedBytes": 137909, diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx index 319b366e..8d989259 100644 --- a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx +++ b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx @@ -69,6 +69,11 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) { }, }, { + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, keyboard: true, tooltip: { use: tooltip, ...interactiveTooltip }, }, diff --git a/benchmarks/conformance/previews/34-pointer-tooltip.svg b/benchmarks/conformance/previews/34-pointer-tooltip.svg index fdb6d046..4bc5eb3a 100644 --- a/benchmarks/conformance/previews/34-pointer-tooltip.svg +++ b/benchmarks/conformance/previews/34-pointer-tooltip.svg @@ -1 +1 @@ - + diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json index e1176205..68a5e224 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": "19e1c1412fb763649cbf1fa03b7c99cf5b6e1319fca7c9657a2add18e2aac322", "assets": [ { "id": "01-line-gaps", @@ -166,8 +166,8 @@ }, { "id": "34-pointer-tooltip", - "sha256": "e4ff132c905893b271d67984435b50620b267447c8081c2ac9f96295d99482e5", - "bytes": 15814 + "sha256": "dd7351252b3594da5e6fe0ac0b3a7bc0e748764fd588cd2c1432fd2ff1f9b218", + "bytes": 15780 }, { "id": "35-grouped-tooltip", diff --git a/docs/comparison.md b/docs/comparison.md index 912adf20..cd477364 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cb17792` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `cb17792`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -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 | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.98–45.00 KiB | Not applicable | 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 | diff --git a/docs/examples/interactive-charts.md b/docs/examples/interactive-charts.md index 62f3b914..9a3bef96 100644 --- a/docs/examples/interactive-charts.md +++ b/docs/examples/interactive-charts.md @@ -49,6 +49,21 @@ dot(rows, { }) ``` +The same definition can make the built-in indicator smaller while retaining +its series-colored outline: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + A focused band emphasizes the shared x value for every series. Its position diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md index 74996619..c18c84fe 100644 --- a/docs/reference/chart-definitions.md +++ b/docs/reference/chart-definitions.md @@ -103,6 +103,11 @@ does not return or own them. belong to both static and responsive definitions. Hosts and framework adapters do not override them. +`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with +`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in +defaults, including the focused point's resolved color for `stroke`. See +[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). + Each `ChartControl` resolves after final scales and plot bounds exist. It can provide renderer-neutral fallback nodes and an optional host control. Control IDs and host-control identities must be unique. Browser hosts remove a diff --git a/docs/reference/dom-host.md b/docs/reference/dom-host.md index 85633106..e094e536 100644 --- a/docs/reference/dom-host.md +++ b/docs/reference/dom-host.md @@ -116,7 +116,7 @@ The definition owns these chart controls: | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- | | `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus | | `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer | -| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only | +| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only | | `cursor` | None | Focus-snapped or free application-owned cursor binding | | `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index | | `svgAnimation` | `false` | Keyed attribute, enter, and exit animation | diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md index 96ddf62d..9ba1695a 100644 --- a/docs/reference/focus-and-interaction.md +++ b/docs/reference/focus-and-interaction.md @@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set definition `focusRing: false` only when authored focus geometry replaces that indicator. +### Built-in focus ring + +Pass an options object to change the built-in ring without targeting generated +SVG: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + +| Option | Default | +| ------------- | ----------------------------------------- | +| `radius` | `5` | +| `strokeWidth` | `2.5` | +| `fill` | `var(--ts-chart-focus-fill, Canvas)` | +| `stroke` | The focused point's resolved series color | + +The object form still enables the ring. `true` keeps every default and `false` +removes only the built-in ring. The definition compiles this presentation into +ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom +mark renderers, and the optional motion renderer share the same values. The +ring remains hidden from the accessibility tree; the chart root continues to +report focus and values. + Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable structural keys, so the optional motion renderer can animate rapid focus diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md index 912adf20..cd477364 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 `3df87d7` | +| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cb17792` | | [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 `3df87d7`. +at page render time. The measured TanStack workspace revision is `cb17792`. ## Capability matrix @@ -90,7 +90,7 @@ output model. ## Bundle snapshot -Baseline date: `2026-08-26`. +Baseline date: `2026-08-27`. Controlled ranges cover 12 independently built, minified browser consumers: line, bar, area, and scatter at basic, interactive, and advanced tiers. Only @@ -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 | 38.96–44.98 KiB | Not applicable | Controlled suite | +| TanStack Charts | 38.98–45.00 KiB | Not applicable | 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 | diff --git a/packages/charts-core/docs/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md index 62f3b914..9a3bef96 100644 --- a/packages/charts-core/docs/examples/interactive-charts.md +++ b/packages/charts-core/docs/examples/interactive-charts.md @@ -49,6 +49,21 @@ dot(rows, { }) ``` +The same definition can make the built-in indicator smaller while retaining +its series-colored outline: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + A focused band emphasizes the shared x value for every series. Its position diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md index 74996619..c18c84fe 100644 --- a/packages/charts-core/docs/reference/chart-definitions.md +++ b/packages/charts-core/docs/reference/chart-definitions.md @@ -103,6 +103,11 @@ does not return or own them. belong to both static and responsive definitions. Hosts and framework adapters do not override them. +`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with +`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in +defaults, including the focused point's resolved color for `stroke`. See +[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring). + Each `ChartControl` resolves after final scales and plot bounds exist. It can provide renderer-neutral fallback nodes and an optional host control. Control IDs and host-control identities must be unique. Browser hosts remove a diff --git a/packages/charts-core/docs/reference/dom-host.md b/packages/charts-core/docs/reference/dom-host.md index 85633106..e094e536 100644 --- a/packages/charts-core/docs/reference/dom-host.md +++ b/packages/charts-core/docs/reference/dom-host.md @@ -116,7 +116,7 @@ The definition owns these chart controls: | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- | | `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus | | `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer | -| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only | +| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only | | `cursor` | None | Focus-snapped or free application-owned cursor binding | | `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index | | `svgAnimation` | `false` | Keyed attribute, enter, and exit animation | diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md index 96ddf62d..9ba1695a 100644 --- a/packages/charts-core/docs/reference/focus-and-interaction.md +++ b/packages/charts-core/docs/reference/focus-and-interaction.md @@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set definition `focusRing: false` only when authored focus geometry replaces that indicator. +### Built-in focus ring + +Pass an options object to change the built-in ring without targeting generated +SVG: + +```ts +const definition = defineChart({ + marks, + scales, + focusRing: { + radius: 4, + strokeWidth: 2, + fill: '#ffffff', + }, +}) +``` + +| Option | Default | +| ------------- | ----------------------------------------- | +| `radius` | `5` | +| `strokeWidth` | `2.5` | +| `fill` | `var(--ts-chart-focus-fill, Canvas)` | +| `stroke` | The focused point's resolved series color | + +The object form still enables the ring. `true` keeps every default and `false` +removes only the built-in ring. The definition compiles this presentation into +ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom +mark renderers, and the optional motion renderer share the same values. The +ring remains hidden from the accessibility tree; the chart root continues to +report focus and values. + Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for datum-bound rules, markers, and axis labels. Their active geometry uses stable structural keys, so the optional motion renderer can animate rapid focus diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts index 5ce7d73c..88561a1f 100644 --- a/packages/charts-core/src/canvas.test.ts +++ b/packages/charts-core/src/canvas.test.ts @@ -988,6 +988,51 @@ describe('Canvas renderer', () => { surface.destroy() }) + it('paints configured focus-ring geometry and paint on Canvas', () => { + const chart = createChartScene( + defineChart({ + marks: [dot([{ x: 1, y: 2 }], { x: 'x', y: 'y' })], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 200, height: 120 }, + ) + const point = chart.points[0] + if (!point) throw new Error('Expected a focus point') + const container = document.createElement('div') + document.body.append(container) + const surface = createCanvasChartRenderer().mount(container, () => {}) + surface.render(chart, renderOptions()) + const focus = contexts.get(surface.focusCanvas) + if (!focus) throw new Error('Expected a Canvas focus layer') + + surface.paintFocus({ + primary: point, + group: [point], + source: 'pointer', + pinned: false, + }) + + expect(focus.operations).toContain(`arc:${point.x},${point.y},4`) + expect(focus.operations).toEqual( + expect.arrayContaining([expect.stringMatching(/^stroke:.*:1\.5:1$/)]), + ) + expect(focus.operations).toEqual( + expect.arrayContaining([expect.stringMatching(/^fill:.*(?:ffffff|255)/)]), + ) + surface.destroy() + container.remove() + }) + it('snaps retargeting focus candidates without adding interaction points', () => { const container = document.createElement('div') const surface = createCanvasChartRenderer().mount(container, () => {}) @@ -2466,14 +2511,16 @@ function fakeContext(): FakeCanvasContext { operations.push(`translate:${values.join(',')}`), rotate: (value: number) => operations.push(`rotate:${value}`), clip: () => operations.push('clip'), - fill: (pathOrRule?: Path2D | CanvasFillRule) => + fill: (pathOrRule?: Path2D | CanvasFillRule) => { operations.push( pathOrRule === 'evenodd' ? 'fill:evenodd' : pathOrRule ? 'fill:path' : 'fill:current', - ), + ) + operations.push(`fill:${String(fillStyle)}:${globalAlpha}`) + }, stroke: (path?: Path2D) => { operations.push(path ? 'stroke:path' : 'stroke:current') operations.push( diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts index 192eb680..f8b24647 100644 --- a/packages/charts-core/src/index.ts +++ b/packages/charts-core/src/index.ts @@ -336,6 +336,7 @@ export type { ChartFocusAnchor, ChartFocusCursorBinding, ChartFocusPresentation, + ChartFocusRingOptions, ChartFocusMode, ChartFocusFilter, ChartFocusAffinity, diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts index 77158588..1e1a9817 100644 --- a/packages/charts-core/src/scene.test.ts +++ b/packages/charts-core/src/scene.test.ts @@ -173,6 +173,119 @@ describe('native mark and channel scene', () => { ) }) + it('configures the built-in focus ring while retaining point-color defaults', () => { + const rows = [ + { id: 'alpha', series: 'Alpha', x: 0, y: 1 }, + { id: 'beta', series: 'Beta', x: 1, y: 2 }, + ] + const spec = { + marks: [ + lineY(rows, { + id: 'series', + x: 'x', + y: 'y', + z: 'series', + key: 'id', + }), + ], + color: { + domain: ['Alpha', 'Beta'], + range: ['#2563eb', '#f97316'], + }, + theme: { background: '#f8fafc', foreground: '#0f172a' }, + ...linearAxes([0, 1], [0, 2]), + } as const + const scene = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + }, + }), + { width: 320, height: 180 }, + ) + const focusDots = flatten(scene.nodes).filter( + (node) => + node.kind === 'dot' && + scene.points.some((point) => point.key === node.key) && + node.radius === 4, + ) + + expect(scene.theme).toMatchObject({ + background: '#f8fafc', + foreground: '#0f172a', + }) + expect(focusDots.map((node) => node.style)).toEqual([ + { fill: '#ffffff', stroke: '#2563eb', strokeWidth: 1.5 }, + { fill: '#ffffff', stroke: '#f97316', strokeWidth: 1.5 }, + ]) + + const fixedStroke = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: 3, + strokeWidth: 2, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 320, height: 180 }, + ) + const fixedDots = flatten(fixedStroke.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 3, + ) + + expect(fixedDots.map((node) => node.style)).toEqual([ + { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 }, + { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 }, + ]) + + const booleanRing = createChartScene( + defineChart({ ...spec, focusRing: true }), + { width: 320, height: 180 }, + ) + const booleanDots = flatten(booleanRing.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 5, + ) + + expect(booleanDots.map((node) => node.style)).toEqual([ + { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#2563eb', + strokeWidth: 2.5, + }, + { + fill: 'var(--ts-chart-focus-fill, Canvas)', + stroke: '#f97316', + strokeWidth: 2.5, + }, + ]) + + const undefinedFields = createChartScene( + defineChart({ + ...spec, + focusRing: { + radius: undefined, + fill: undefined, + stroke: undefined, + strokeWidth: undefined, + }, + }), + { width: 320, height: 180 }, + ) + const undefinedDots = flatten(undefinedFields.nodes).filter( + (node) => node.kind === 'dot' && node.radius === 5, + ) + + expect(undefinedDots.map((node) => node.style)).toEqual( + booleanDots.map((node) => node.style), + ) + expectTypeOf(scene).toMatchTypeOf>() + }) + it('rejects duplicate control ids', () => { const behavior = { id: 'duplicate', resolve: () => ({}) } diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts index 5f465fd4..f84b2053 100644 --- a/packages/charts-core/src/scene.ts +++ b/packages/charts-core/src/scene.ts @@ -27,6 +27,7 @@ import type { ResponsiveChartConfig, ChartColorLegend, ChartFocusFilter, + ChartFocusRingOptions, ChartLayoutOptions, ChartMargin, ChartMark, @@ -529,6 +530,12 @@ function createChartSceneWithScaleResolver< definition.focusRing !== false && points.length ) { + const { + radius = 5, + fill = 'var(--ts-chart-focus-fill, Canvas)', + stroke, + strokeWidth = 2.5, + } = (definition.focusRing || {}) as ChartFocusRingOptions for (const entry of defaultFocusEntries) { nodes.push({ kind: 'group', @@ -547,11 +554,11 @@ function createChartSceneWithScaleResolver< key: point.key, x: point.x, y: point.y, - radius: 5, + radius, style: { - fill: 'var(--ts-chart-focus-fill, Canvas)', - stroke: point.color, - strokeWidth: 2.5, + fill, + stroke: stroke ?? point.color, + strokeWidth, }, })), }) diff --git a/packages/charts-core/src/svg-surface.test.ts b/packages/charts-core/src/svg-surface.test.ts index 0f23946f..847e9f3d 100644 --- a/packages/charts-core/src/svg-surface.test.ts +++ b/packages/charts-core/src/svg-surface.test.ts @@ -9,6 +9,62 @@ import { renderChartSvgWithResources } from './svg-resources' import { createSvgChartRenderer, svgChartRenderer } from './svg-surface' describe('SVG surface coordinates', () => { + it('paints a configured focus ring without exposing it to accessibility', () => { + const scene = createChartScene( + defineChart({ + marks: [ + lineY([{ x: 1, y: 2 }], { + id: 'trend', + x: 'x', + y: 'y', + }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }), + { width: 200, height: 120 }, + ) + const point = scene.points[0] + if (!point) throw new Error('Expected a focus point') + const container = document.createElement('div') + const surface = svgChartRenderer.mount(container, () => {}) + surface.render(scene, { ariaLabel: 'Configured focus ring' }) + const svg = container.querySelector('svg') + const layer = container.querySelector( + '.ts-chart__focus-layer--default', + ) + const ring = layer?.querySelector('circle') + + expect(svg?.getAttribute('role')).toBe('img') + expect(svg?.getAttribute('aria-label')).toBe('Configured focus ring') + expect(layer?.getAttribute('aria-hidden')).toBe('true') + expect(layer?.getAttribute('visibility')).toBe('hidden') + expect(ring?.getAttribute('r')).toBe('4') + expect(ring?.getAttribute('fill')).toBe('#ffffff') + expect(ring?.getAttribute('stroke')).toBe('#0f172a') + expect(ring?.getAttribute('stroke-width')).toBe('1.5') + + surface.paintFocus({ + primary: point, + group: [point], + source: 'keyboard', + pinned: false, + }) + + expect(layer?.getAttribute('visibility')).toBe('visible') + expect(ring?.getAttribute('visibility')).toBe('visible') + surface.destroy() + }) + it('mounts viewport content with the fixed authored plot clip', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts index 3ed32bb9..a708ea5b 100644 --- a/packages/charts-core/src/types.ts +++ b/packages/charts-core/src/types.ts @@ -581,6 +581,15 @@ export interface ChartTheme { palette: readonly string[] } +/** Paint and geometry for the built-in primary-point focus indicator. */ +export interface ChartFocusRingOptions { + radius?: number + fill?: string + /** Defaults to the focused point's resolved color. */ + stroke?: string + strokeWidth?: number +} + export interface ChartGradientStop { offset: number color: string @@ -879,8 +888,8 @@ export interface ChartDefinitionOptions< > { maxFocusDistance?: number focus?: ChartFocusMode, NoInfer, NoInfer> - /** Shows the built-in primary-point focus ring. Defaults to true. */ - focusRing?: boolean + /** Shows and optionally styles the built-in primary-point focus ring. Defaults to true. */ + focusRing?: boolean | ChartFocusRingOptions /** Optional app-owned cursor shared by one or more chart definitions. */ cursor?: ChartCursorBinding< NoInfer, @@ -913,7 +922,7 @@ export interface ChartDefinitionOptions< interface StoredChartDefinitionOptions { maxFocusDistance?: number focus?: ChartFocusMode - focusRing?: boolean + focusRing?: boolean | ChartFocusRingOptions cursor?: ChartCursorBinding spatialIndex?: ChartSpatialIndexFactory svgAnimation?: boolean | ChartAnimationOptions diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts index 3aef282c..8aed7dbe 100644 --- a/packages/charts-core/src/universal-types.ts +++ b/packages/charts-core/src/universal-types.ts @@ -225,6 +225,7 @@ export type { ChartFocusAnchor, ChartFocusCursorBinding, ChartFocusPresentation, + ChartFocusRingOptions, ChartFocusFilter, ChartFocusAffinity, ChartFocusGroupContext, diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx index 4c62d636..31385a8e 100644 --- a/packages/react-native-charts/src/Chart.test.tsx +++ b/packages/react-native-charts/src/Chart.test.tsx @@ -878,6 +878,54 @@ describe('React Native Chart', () => { } }) + it('paints configured focus-ring geometry and paint in native SVG', async () => { + const configuredDefinition = defineChart({ + marks: [lineY(data, { x: 'month', y: 'value' })], + scales: { + x: { scale: scaleLinear().domain([1, 2]) }, + y: { scale: scaleLinear().domain([8, 12]) }, + }, + guides: false, + focusRing: { + radius: 4, + strokeWidth: 1.5, + fill: '#ffffff', + stroke: '#0f172a', + }, + }) + const container = document.createElement('div') + const root = createRoot(container) + + try { + await React.act(() => { + root.render( + , + ) + }) + await React.act(() => { + container.firstElementChild?.dispatchEvent( + new FocusEvent('focusin', { bubbles: true }), + ) + }) + + const ring = container.querySelector('circle') + expect(ring?.getAttribute('r')).toBe('4') + expect(ring?.getAttribute('fill')).toBe('#ffffff') + expect(ring?.getAttribute('stroke')).toBe('#0f172a') + expect(ring?.getAttribute('stroke-width')).toBe('1.5') + expect( + container.firstElementChild?.getAttribute('data-accessibility-role'), + ).toBe('adjustable') + } finally { + await React.act(() => root.unmount()) + } + }) + it('renders a clipped two-axis crosshair, marker, labels, and focus fill', async () => { const crosshairDefinition = defineChart({ marks: [ diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs index bb05b66a..cd6aae51 100644 --- a/scripts/measure-bundles.mjs +++ b/scripts/measure-bundles.mjs @@ -671,7 +671,7 @@ const entries = [ budgeted( 'D3-scale lineX + static SVG', 'benchmarks/entries/charts-line-x-svg.ts', - 20.24, + 20.26, ), budgeted( 'D3-scale UTC line + static SVG', @@ -681,7 +681,7 @@ const entries = [ budgeted( 'D3-scale histogram + static SVG', 'benchmarks/entries/charts-histogram-svg.ts', - 22.78, + 22.79, ), budgeted( 'D3-scale facets + static SVG', @@ -691,17 +691,17 @@ const entries = [ budgeted( 'D3-scale arrows + static SVG', 'benchmarks/entries/charts-arrow-svg.ts', - 20.2, + 20.22, ), budgeted( 'D3-scale areaX + static SVG', 'benchmarks/entries/charts-area-x-svg.ts', - 24.25, + 24.28, ), budgeted( 'D3-scale dots + static SVG', 'benchmarks/entries/charts-dot-svg.ts', - 20.67, + 20.68, { inputBoundary: { forbid: [ @@ -764,7 +764,7 @@ const entries = [ budgeted( 'Frame + static SVG', 'benchmarks/entries/charts-frame-svg.ts', - 11.63, + 11.64, ), incrementalBudgeted( 'Spatial density contours + static SVG', @@ -903,7 +903,7 @@ const entries = [ budgeted( 'Composite mark + static SVG', 'benchmarks/entries/charts-composite-mark.ts', - 27.75, + 27.79, { inputBoundary: { require: [ @@ -943,7 +943,7 @@ const entries = [ 'Linear regression + static SVG', 'benchmarks/entries/charts-regression-svg.ts', 'D3-scale line + static SVG', - 6.59, + 6.6, { inputBoundary: { require: [ @@ -1072,7 +1072,7 @@ const entries = [ budgeted( 'D3-scale hexagons + static SVG', 'benchmarks/entries/charts-hexagon-svg.ts', - 20.11, + 20.13, { inputBoundary: { forbid: ['d3Hexbin'] } }, ), incrementalBudgeted( @@ -1102,7 +1102,7 @@ const entries = [ budgeted( 'D3-scale link + static SVG', 'benchmarks/entries/charts-link-svg.ts', - 20.1, + 20.12, { inputBoundary: { forbid: [ @@ -1142,22 +1142,22 @@ const entries = [ budgeted( 'D3-scale ticks + static SVG', 'benchmarks/entries/charts-tick-svg.ts', - 21.13, + 21.14, ), budgeted( 'D3-scale vectors + static SVG', 'benchmarks/entries/charts-vector-svg.ts', - 20.31, + 20.32, ), budgeted( 'D3 geo shape + static SVG', 'benchmarks/entries/charts-geo-svg.ts', - 17.58, + 17.59, ), budgeted( 'Polar arc + static SVG', 'benchmarks/entries/charts-polar-arc-svg.ts', - 16.56, + 16.58, { inputBoundary: { forbid: ['polarPie'] } }, ), incrementalBudgeted( @@ -1176,7 +1176,7 @@ const entries = [ budgeted( 'Polar gauge composition + static SVG', 'benchmarks/entries/charts-polar-gauge-svg.ts', - 25.69, + 25.7, ), budgeted( 'Radial labels + static SVG', @@ -1187,7 +1187,7 @@ const entries = [ budgeted( 'Polar radial bars + static SVG', 'benchmarks/entries/charts-radial-bar-svg.ts', - 25.7, + 25.71, { inputBoundary: { require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'], @@ -1198,7 +1198,7 @@ const entries = [ budgeted( 'Polar line + scatter composition + static SVG', 'benchmarks/entries/charts-polar-line-scatter-svg.ts', - 26.8, + 26.81, ), locked( 'Representative marks', @@ -1642,7 +1642,7 @@ const entries = [ lockedBudgeted( 'Compact-scale line scene', 'benchmarks/entries/charts-compact-linear-scene.ts', - 11.68, + 11.69, { inputBoundary: { require: ['compactLinear'], @@ -1661,7 +1661,7 @@ const entries = [ lockedBudgeted( 'React compact-scale line consumer', 'benchmarks/entries/charts-react-compact-line.ts', - 28.47, + 28.49, { external: ['react', 'react/jsx-runtime', 'react-dom'], rendererBoundary: 'svg', @@ -1829,7 +1829,7 @@ const entries = [ budgeted( 'Stats parity surface', 'benchmarks/entries/charts-stats-parity.ts', - 52.82, + 52.86, ), locked( 'Custom-scale line scene', @@ -1855,7 +1855,7 @@ const entries = [ budgeted( 'D3 curved line scene', 'benchmarks/entries/charts-d3-curved-line-scene.ts', - 20.9, + 20.92, ), budgeted( 'D3 time-scale line scene', @@ -1880,12 +1880,12 @@ const entries = [ budgeted( 'Direct D3 quadtree + TanStack DOM host', 'benchmarks/entries/charts-d3-quadtree-dom.ts', - 36.84, + 36.86, ), budgeted( 'Direct D3 Delaunay + TanStack DOM host', 'benchmarks/entries/charts-d3-delaunay-dom.ts', - 42.07, + 42.09, ), measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'), measured( @@ -2089,7 +2089,7 @@ const entries = [ budgeted( 'React Stats parity surface', 'benchmarks/entries/charts-react-stats-parity.tsx', - 53.72, + 53.75, { external: ['react', 'react/jsx-runtime', 'react-dom'] }, ), measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'),