From 529ae16bab7d54dbdea790608710134f2dfcfaaf Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 10:28:03 -0400 Subject: [PATCH 01/17] feat(a11y): scaffold package --- packages/visx-a11y/.npmrc | 1 + packages/visx-a11y/Readme.md | 22 ++++++++++ packages/visx-a11y/package.json | 58 +++++++++++++++++++++++++++ packages/visx-a11y/src/index.ts | 4 ++ packages/visx-a11y/src/server.ts | 2 + packages/visx-a11y/test/index.test.ts | 8 ++++ packages/visx-a11y/tsconfig.json | 18 +++++++++ packages/visx-a11y/vitest.config.ts | 24 +++++++++++ packages/visx-visx/package.json | 1 + packages/visx-visx/src/index.ts | 2 + packages/visx-visx/tsconfig.json | 3 ++ vitest.config.ts | 1 + yarn.lock | 15 +++++++ 13 files changed, 159 insertions(+) create mode 100644 packages/visx-a11y/.npmrc create mode 100644 packages/visx-a11y/Readme.md create mode 100644 packages/visx-a11y/package.json create mode 100644 packages/visx-a11y/src/index.ts create mode 100644 packages/visx-a11y/src/server.ts create mode 100644 packages/visx-a11y/test/index.test.ts create mode 100644 packages/visx-a11y/tsconfig.json create mode 100644 packages/visx-a11y/vitest.config.ts diff --git a/packages/visx-a11y/.npmrc b/packages/visx-a11y/.npmrc new file mode 100644 index 0000000000..43c97e719a --- /dev/null +++ b/packages/visx-a11y/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md new file mode 100644 index 0000000000..75efb619ae --- /dev/null +++ b/packages/visx-a11y/Readme.md @@ -0,0 +1,22 @@ +# @visx/a11y + +Accessibility helpers for primitive visx charts. + +`@visx/a11y` will provide spreadable chart semantics, data table fallbacks, live announcements, and +keyboard navigation helpers for charts built from low-level visx packages. The package follows the +same primitive-first model as the rest of visx: hooks and helpers own accessibility state and +generated semantics, while users keep control of their SVG render tree. + +This package is being introduced for the visx chart registry work. The initial implementation will +focus on semantic SVG annotation, server-safe description and table generators, and React helpers +that registry items can compose without adopting a new chart component. + +## Entry points + +```tsx +import {} from '@visx/a11y'; +import {} from '@visx/a11y/server'; +``` + +The root entry is reserved for React hooks and components. The `server` entry is reserved for pure +helpers that can run during server rendering. diff --git a/packages/visx-a11y/package.json b/packages/visx-a11y/package.json new file mode 100644 index 0000000000..8ae1523f80 --- /dev/null +++ b/packages/visx-a11y/package.json @@ -0,0 +1,58 @@ +{ + "name": "@visx/a11y", + "version": "4.0.0", + "description": "visx chart accessibility helpers", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./esm/index.js", + "require": "./lib/index.js" + }, + "./server": { + "types": "./lib/server.d.ts", + "import": "./esm/server.js", + "require": "./lib/server.js" + } + }, + "files": [ + "lib", + "esm" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/airbnb/visx.git" + }, + "keywords": [ + "visx", + "react", + "visualizations", + "charts", + "accessibility", + "a11y" + ], + "author": "@hshoff", + "license": "MIT", + "bugs": { + "url": "https://github.com/airbnb/visx/issues" + }, + "homepage": "https://github.com/airbnb/visx#readme", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0" + }, + "publishConfig": { + "access": "public" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + }, + "devDependencies": { + "@types/react": "^19.0.0" + } +} diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts new file mode 100644 index 0000000000..6a35f897c5 --- /dev/null +++ b/packages/visx-a11y/src/index.ts @@ -0,0 +1,4 @@ +'use client'; + +// @visx/a11y +export {}; diff --git a/packages/visx-a11y/src/server.ts b/packages/visx-a11y/src/server.ts new file mode 100644 index 0000000000..113d5107e6 --- /dev/null +++ b/packages/visx-a11y/src/server.ts @@ -0,0 +1,2 @@ +// @visx/a11y/server +export {}; diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts new file mode 100644 index 0000000000..31901fea55 --- /dev/null +++ b/packages/visx-a11y/test/index.test.ts @@ -0,0 +1,8 @@ +import '../src'; +import '../src/server'; + +describe('@visx/a11y scaffold', () => { + it('exposes package entry points', () => { + expect(true).toBe(true); + }); +}); diff --git a/packages/visx-a11y/tsconfig.json b/packages/visx-a11y/tsconfig.json new file mode 100644 index 0000000000..7712d9e02c --- /dev/null +++ b/packages/visx-a11y/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "declarationDir": "lib", + "outDir": "lib", + "rootDir": "src" + }, + "exclude": [ + "lib", + "test" + ], + "extends": "../../tsconfig.options.json", + "include": [ + "src/**/*", + "types/**/*", + "../../types/**/*" + ], + "references": [] +} diff --git a/packages/visx-a11y/vitest.config.ts b/packages/visx-a11y/vitest.config.ts new file mode 100644 index 0000000000..4278752926 --- /dev/null +++ b/packages/visx-a11y/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + name: '@visx/a11y', + globals: true, + environment: 'jsdom', + setupFiles: [], + coverage: { + provider: 'v8', + reporter: ['lcov', 'json-summary', 'html', 'json', 'text'], + include: ['packages/visx-a11y/src/**/*.{ts,tsx}'], + exclude: ['**/node_modules/**', '**/esm/**', '**/lib/**', '**/test/**', '**/dist/**'], + reportsDirectory: './coverage', + }, + }, + resolve: { + alias: { + '@visx/a11y': path.resolve(__dirname, './src'), + '@visx/a11y/server': path.resolve(__dirname, './src/server.ts'), + }, + }, +}); diff --git a/packages/visx-visx/package.json b/packages/visx-visx/package.json index 2958c8dbd8..9bc9c5adca 100644 --- a/packages/visx-visx/package.json +++ b/packages/visx-visx/package.json @@ -36,6 +36,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { + "@visx/a11y": "workspace:*", "@visx/annotation": "workspace:*", "@visx/axis": "workspace:*", "@visx/bounds": "workspace:*", diff --git a/packages/visx-visx/src/index.ts b/packages/visx-visx/src/index.ts index 381f1d7ae1..15d6b0d1a4 100644 --- a/packages/visx-visx/src/index.ts +++ b/packages/visx-visx/src/index.ts @@ -1,4 +1,5 @@ // @visx/visx +import * as A11y from '@visx/a11y'; import * as Annotation from '@visx/annotation'; import * as Axis from '@visx/axis'; import * as Bounds from '@visx/bounds'; @@ -35,6 +36,7 @@ import * as XYChart from '@visx/xychart'; import * as Zoom from '@visx/zoom'; export { + A11y, Annotation, Axis, Bounds, diff --git a/packages/visx-visx/tsconfig.json b/packages/visx-visx/tsconfig.json index 5def37d19f..df7010699e 100644 --- a/packages/visx-visx/tsconfig.json +++ b/packages/visx-visx/tsconfig.json @@ -15,6 +15,9 @@ "../../types/**/*" ], "references": [ + { + "path": "../visx-a11y" + }, { "path": "../visx-annotation" }, diff --git a/vitest.config.ts b/vitest.config.ts index 6173d685e9..543da00e7b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: [ + 'packages/visx-a11y/vitest.config.ts', 'packages/visx-scale/vitest.config.ts', 'packages/visx-point/vitest.config.ts', 'packages/visx-curve/vitest.config.ts', diff --git a/yarn.lock b/yarn.lock index a87fc0f894..9b156eea18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4250,6 +4250,20 @@ __metadata: languageName: node linkType: hard +"@visx/a11y@workspace:*, @visx/a11y@workspace:packages/visx-a11y": + version: 0.0.0-use.local + resolution: "@visx/a11y@workspace:packages/visx-a11y" + dependencies: + "@types/react": "npm:^19.0.0" + peerDependencies: + "@types/react": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + languageName: unknown + linkType: soft + "@visx/annotation@workspace:*, @visx/annotation@workspace:packages/visx-annotation": version: 0.0.0-use.local resolution: "@visx/annotation@workspace:packages/visx-annotation" @@ -4969,6 +4983,7 @@ __metadata: dependencies: "@types/react": "npm:^19.0.0" "@types/react-dom": "npm:^19.0.0" + "@visx/a11y": "workspace:*" "@visx/annotation": "workspace:*" "@visx/axis": "workspace:*" "@visx/bounds": "workspace:*" From b851f122b1b2bc255ef6d8c7b438326e3c99d96c Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 10:32:39 -0400 Subject: [PATCH 02/17] feat(a11y): add shared chart accessibility types --- packages/visx-a11y/Readme.md | 4 +- packages/visx-a11y/src/index.ts | 19 ++++++- packages/visx-a11y/src/server.ts | 19 ++++++- packages/visx-a11y/src/types.ts | 62 +++++++++++++++++++++++ packages/visx-a11y/src/utils/data.ts | 73 +++++++++++++++++++++++++++ packages/visx-a11y/test/data.test.ts | 72 ++++++++++++++++++++++++++ packages/visx-a11y/test/index.test.ts | 5 +- 7 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 packages/visx-a11y/src/types.ts create mode 100644 packages/visx-a11y/src/utils/data.ts create mode 100644 packages/visx-a11y/test/data.test.ts diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 75efb619ae..f9b400f3de 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -14,8 +14,8 @@ that registry items can compose without adopting a new chart component. ## Entry points ```tsx -import {} from '@visx/a11y'; -import {} from '@visx/a11y/server'; +import type { ChartA11yConfig } from '@visx/a11y'; +import { normalizeChartA11yData } from '@visx/a11y/server'; ``` The root entry is reserved for React hooks and components. The `server` entry is reserved for pure diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index 6a35f897c5..18d942e6e6 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -1,4 +1,21 @@ 'use client'; // @visx/a11y -export {}; +export { + DEFAULT_CHART_A11Y_ID_PREFIX, + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + DEFAULT_SINGLE_SERIES_LABEL, + normalizeChartA11yData, +} from './utils/data'; + +export type { + A11yLocale, + ChartA11yAccessor, + ChartA11yConfig, + ChartA11yFocusedPoint, + ChartA11yFormatter, + ChartA11ySeriesConfig, + ChartA11yValue, + ChartType, +} from './types'; +export type { NormalizedChartA11yData, NormalizedChartA11ySeries } from './utils/data'; diff --git a/packages/visx-a11y/src/server.ts b/packages/visx-a11y/src/server.ts index 113d5107e6..91c94b13a4 100644 --- a/packages/visx-a11y/src/server.ts +++ b/packages/visx-a11y/src/server.ts @@ -1,2 +1,19 @@ // @visx/a11y/server -export {}; +export { + DEFAULT_CHART_A11Y_ID_PREFIX, + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + DEFAULT_SINGLE_SERIES_LABEL, + normalizeChartA11yData, +} from './utils/data'; + +export type { + A11yLocale, + ChartA11yAccessor, + ChartA11yConfig, + ChartA11yFocusedPoint, + ChartA11yFormatter, + ChartA11ySeriesConfig, + ChartA11yValue, + ChartType, +} from './types'; +export type { NormalizedChartA11yData, NormalizedChartA11ySeries } from './utils/data'; diff --git a/packages/visx-a11y/src/types.ts b/packages/visx-a11y/src/types.ts new file mode 100644 index 0000000000..153ef3031f --- /dev/null +++ b/packages/visx-a11y/src/types.ts @@ -0,0 +1,62 @@ +export type ChartType = + | 'line' + | 'bar' + | 'scatter' + | 'area' + | 'pie' + | 'donut' + | 'histogram' + | 'heatmap'; + +export type ChartA11yValue = string | number | Date; + +export type ChartA11yAccessor = ( + datum: Datum, + index: number, + data: readonly Datum[], +) => Value; + +export type ChartA11yFormatter = (value: Value) => string; + +export type ChartA11ySeriesConfig = { + label: string | ((seriesIndex: number) => string); + data?: readonly Datum[]; +}; + +export type ChartA11yFocusedPoint = { + seriesIndex: number; + index: number; + datum: Datum; +}; + +export type A11yLocale = { + chartRoleDescription: string; + seriesRoleDescription: string; + pointRoleDescription: string; + chartTypes: Partial>; + dataTableCaption: (title: string) => string; + keyboardHelp: string; +}; + +export type ChartA11yConfig = { + /** Stable id for generated description, table, and live-region relationships. */ + id?: string; + /** Prefix used when an id is generated by client or server helpers. */ + idPrefix?: string; + data: readonly Datum[] | readonly (readonly Datum[])[]; + title: string; + x: ChartA11yAccessor; + y: ChartA11yAccessor; + series?: readonly ChartA11ySeriesConfig[]; + xLabel?: string; + yLabel?: string; + description?: string; + chartType?: ChartType; + formatX?: ChartA11yFormatter; + formatY?: ChartA11yFormatter; + pointDescriptionThreshold?: number; + dataTableEnabled?: boolean; + keyboardNavEnabled?: boolean; + onPointFocus?: (point: ChartA11yFocusedPoint) => void; + locale?: Partial; +}; diff --git a/packages/visx-a11y/src/utils/data.ts b/packages/visx-a11y/src/utils/data.ts new file mode 100644 index 0000000000..48c334338a --- /dev/null +++ b/packages/visx-a11y/src/utils/data.ts @@ -0,0 +1,73 @@ +import type { ChartA11yConfig, ChartA11ySeriesConfig } from '../types'; + +export const DEFAULT_CHART_A11Y_ID_PREFIX = 'visx-a11y'; +export const DEFAULT_POINT_DESCRIPTION_THRESHOLD = 150; +export const DEFAULT_SINGLE_SERIES_LABEL = 'Data'; + +export type NormalizedChartA11ySeries = { + index: number; + label: string; + data: readonly Datum[]; +}; + +export type NormalizedChartA11yData = { + series: NormalizedChartA11ySeries[]; + isMultiSeries: boolean; + pointCount: number; + maxSeriesLength: number; +}; + +function isNestedSeriesData( + data: ChartA11yConfig['data'], +): data is readonly (readonly Datum[])[] { + return Array.isArray(data[0]); +} + +function getSeriesLabel( + seriesConfig: ChartA11ySeriesConfig | undefined, + seriesIndex: number, + seriesCount: number, +) { + if (typeof seriesConfig?.label === 'function') { + return seriesConfig.label(seriesIndex); + } + + if (seriesConfig?.label) { + return seriesConfig.label; + } + + return seriesCount === 1 ? DEFAULT_SINGLE_SERIES_LABEL : `Series ${seriesIndex + 1}`; +} + +export function normalizeChartA11yData({ + data, + series: seriesConfig, +}: Pick, 'data' | 'series'>): NormalizedChartA11yData { + let seriesData: readonly (readonly Datum[])[]; + + if (isNestedSeriesData(data)) { + seriesData = data; + } else if (seriesConfig?.some((series) => series.data)) { + seriesData = seriesConfig.map((series) => series.data ?? []); + } else { + seriesData = [data]; + } + + const series = seriesData.map((seriesDatum, index) => ({ + index, + label: getSeriesLabel(seriesConfig?.[index], index, seriesData.length), + data: seriesDatum, + })); + const maxSeriesLength = series.reduce( + (maxLength, { data: seriesDatum }) => Math.max(maxLength, seriesDatum.length), + 0, + ); + const pointCount = series.reduce((total, { data: seriesDatum }) => total + seriesDatum.length, 0); + + return { + series, + isMultiSeries: series.length > 1, + pointCount, + maxSeriesLength, + }; +} diff --git a/packages/visx-a11y/test/data.test.ts b/packages/visx-a11y/test/data.test.ts new file mode 100644 index 0000000000..e221b57645 --- /dev/null +++ b/packages/visx-a11y/test/data.test.ts @@ -0,0 +1,72 @@ +import { + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + DEFAULT_CHART_A11Y_ID_PREFIX, + normalizeChartA11yData, +} from '../src'; + +type Datum = { + label: string; + value: number; +}; + +const data: Datum[] = [ + { label: 'A', value: 1 }, + { label: 'B', value: 2 }, +]; + +describe('normalizeChartA11yData', () => { + it('normalizes a single flat series', () => { + expect(normalizeChartA11yData({ data })).toEqual({ + series: [{ index: 0, label: 'Data', data }], + isMultiSeries: false, + pointCount: 2, + maxSeriesLength: 2, + }); + }); + + it('normalizes nested multi-series data with labels', () => { + expect( + normalizeChartA11yData({ + data: [data, [data[0]]], + series: [{ label: 'Desktop' }, { label: (index) => `Series ${index + 1}` }], + }), + ).toEqual({ + series: [ + { index: 0, label: 'Desktop', data }, + { index: 1, label: 'Series 2', data: [data[0]] }, + ], + isMultiSeries: true, + pointCount: 3, + maxSeriesLength: 2, + }); + }); + + it('uses per-series data when flat data is paired with series configs', () => { + const mobile = [{ label: 'A', value: 3 }]; + + expect( + normalizeChartA11yData({ + data, + series: [ + { label: 'Desktop', data }, + { label: 'Mobile', data: mobile }, + ], + }), + ).toEqual({ + series: [ + { index: 0, label: 'Desktop', data }, + { index: 1, label: 'Mobile', data: mobile }, + ], + isMultiSeries: true, + pointCount: 3, + maxSeriesLength: 2, + }); + }); +}); + +describe('@visx/a11y constants', () => { + it('exposes defaults used across client and server helpers', () => { + expect(DEFAULT_CHART_A11Y_ID_PREFIX).toBe('visx-a11y'); + expect(DEFAULT_POINT_DESCRIPTION_THRESHOLD).toBe(150); + }); +}); diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index 31901fea55..655c57942f 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -1,8 +1,7 @@ -import '../src'; -import '../src/server'; +import { normalizeChartA11yData } from '../src'; describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { - expect(true).toBe(true); + expect(normalizeChartA11yData).toBeDefined(); }); }); From 0a9c05770b3281332013fae93be0e3a825c1e572 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 10:45:52 -0400 Subject: [PATCH 03/17] feat(a11y): add server generators --- packages/visx-a11y/Readme.md | 2 +- .../visx-a11y/src/generators/ariaProps.ts | 79 ++++++++++ .../visx-a11y/src/generators/dataTableHTML.ts | 69 +++++++++ .../visx-a11y/src/generators/description.ts | 120 +++++++++++++++ packages/visx-a11y/src/index.ts | 8 + packages/visx-a11y/src/server.ts | 8 + packages/visx-a11y/src/types.ts | 32 ++++ packages/visx-a11y/src/utils/format.ts | 73 +++++++++ packages/visx-a11y/test/generators.test.ts | 142 ++++++++++++++++++ 9 files changed, 532 insertions(+), 1 deletion(-) create mode 100644 packages/visx-a11y/src/generators/ariaProps.ts create mode 100644 packages/visx-a11y/src/generators/dataTableHTML.ts create mode 100644 packages/visx-a11y/src/generators/description.ts create mode 100644 packages/visx-a11y/src/utils/format.ts create mode 100644 packages/visx-a11y/test/generators.test.ts diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index f9b400f3de..9837d64ea3 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -15,7 +15,7 @@ that registry items can compose without adopting a new chart component. ```tsx import type { ChartA11yConfig } from '@visx/a11y'; -import { normalizeChartA11yData } from '@visx/a11y/server'; +import { generateChartDescription, getChartAriaProps } from '@visx/a11y/server'; ``` The root entry is reserved for React hooks and components. The `server` entry is reserved for pure diff --git a/packages/visx-a11y/src/generators/ariaProps.ts b/packages/visx-a11y/src/generators/ariaProps.ts new file mode 100644 index 0000000000..6f9c8d9bfb --- /dev/null +++ b/packages/visx-a11y/src/generators/ariaProps.ts @@ -0,0 +1,79 @@ +import type { ChartA11yConfig, ChartA11yPointProps, ChartA11yProps } from '../types'; +import type { NormalizedChartA11ySeries } from '../utils/data'; +import { DEFAULT_POINT_DESCRIPTION_THRESHOLD, normalizeChartA11yData } from '../utils/data'; +import { formatPercent, formatXValue, formatYValue, getChartA11yId } from '../utils/format'; + +const defaultChartType = 'chart'; + +function getChartTypeLabel({ chartType, locale }: ChartA11yConfig) { + return ( + (chartType && locale?.chartTypes?.[chartType]) || + (chartType ? `${chartType} chart` : locale?.chartRoleDescription ?? defaultChartType) + ); +} + +function getPointLabel( + config: ChartA11yConfig, + series: NormalizedChartA11ySeries, + pointIndex: number, +): string { + const datum = series.data[pointIndex]; + + if (datum == null) { + return ''; + } + + const x = formatXValue(config, datum, pointIndex, series.data); + const y = formatYValue(config, datum, pointIndex, series.data); + + if (config.chartType === 'pie' || config.chartType === 'donut') { + const total = series.data.reduce( + (sum, nextDatum, index) => sum + config.y(nextDatum, index, series.data), + 0, + ); + const share = total === 0 ? 0 : (config.y(datum, pointIndex, series.data) / total) * 100; + + return `${x}, ${y} (${formatPercent(share)})`; + } + + return `${series.label}, ${x}, ${y}`; +} + +export function getChartAriaProps(config: ChartA11yConfig): ChartA11yProps { + const normalized = normalizeChartA11yData(config); + const id = getChartA11yId(config); + const descriptionId = `${id}-description`; + const tableId = `${id}-table`; + const chartTypeLabel = getChartTypeLabel(config); + const pointDescriptionThreshold = + config.pointDescriptionThreshold ?? DEFAULT_POINT_DESCRIPTION_THRESHOLD; + const includePointProps = normalized.pointCount <= pointDescriptionThreshold; + + return { + ids: { + rootId: id, + descriptionId, + tableId, + }, + svg: { + role: 'graphics-document', + 'aria-roledescription': chartTypeLabel, + 'aria-label': config.title, + 'aria-describedby': descriptionId, + }, + series: normalized.series.map((series) => ({ + role: 'graphics-object', + 'aria-roledescription': config.locale?.seriesRoleDescription ?? 'series', + 'aria-label': series.label, + })), + points: includePointProps + ? normalized.series.map((series) => + series.data.map((_, index) => ({ + role: 'graphics-symbol', + 'aria-roledescription': config.locale?.pointRoleDescription ?? 'data point', + 'aria-label': getPointLabel(config, series, index), + })), + ) + : normalized.series.map(() => []), + }; +} diff --git a/packages/visx-a11y/src/generators/dataTableHTML.ts b/packages/visx-a11y/src/generators/dataTableHTML.ts new file mode 100644 index 0000000000..0ccfe16ff5 --- /dev/null +++ b/packages/visx-a11y/src/generators/dataTableHTML.ts @@ -0,0 +1,69 @@ +import type { ChartA11yConfig } from '../types'; +import type { NormalizedChartA11yData } from '../utils/data'; +import { normalizeChartA11yData } from '../utils/data'; +import { escapeHtml, formatXValue, formatYValue, getChartA11yId } from '../utils/format'; + +export const VISUALLY_HIDDEN_TABLE_STYLE = + 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;'; + +function getFirstDatumAtIndex(normalized: NormalizedChartA11yData, index: number) { + return normalized.series.find(({ data }) => data[index] != null); +} + +function getXCell( + config: ChartA11yConfig, + normalized: NormalizedChartA11yData, + index: number, +) { + const series = getFirstDatumAtIndex(normalized, index); + const datum = series?.data[index]; + + return datum == null || !series ? '' : formatXValue(config, datum, index, series.data); +} + +function getYCell( + config: ChartA11yConfig, + normalized: NormalizedChartA11yData, + seriesIndex: number, + index: number, +) { + const series = normalized.series[seriesIndex]; + const datum = series?.data[index]; + + return datum == null || !series ? '' : formatYValue(config, datum, index, series.data); +} + +export function generateDataTableHTML(config: ChartA11yConfig): string { + const normalized = normalizeChartA11yData(config); + const tableId = `${getChartA11yId(config)}-table`; + const xLabel = config.xLabel ?? (config.chartType === 'histogram' ? 'Bin' : 'Category'); + const yLabel = config.yLabel ?? 'Value'; + const headers = normalized.isMultiSeries + ? [xLabel, ...normalized.series.map(({ label }) => label)] + : [ + xLabel, + normalized.series[0]?.label === 'Data' ? yLabel : normalized.series[0]?.label ?? yLabel, + ]; + const rows = Array.from({ length: normalized.maxSeriesLength }, (_, index) => { + const yCells = normalized.isMultiSeries + ? normalized.series.map(({ index: seriesIndex }) => + getYCell(config, normalized, seriesIndex, index), + ) + : [getYCell(config, normalized, 0, index)]; + + return [getXCell(config, normalized, index), ...yCells]; + }); + const headerHtml = headers.map((header) => `${escapeHtml(header)}`).join(''); + const rowHtml = rows + .map( + ([xCell, ...yCells]) => + `${escapeHtml(xCell)}${yCells + .map((cell) => `${escapeHtml(cell)}`) + .join('')}`, + ) + .join(''); + const tableIdHtml = escapeHtml(tableId); + const captionHtml = escapeHtml(config.locale?.dataTableCaption?.(config.title) ?? config.title); + + return `${headerHtml}${rowHtml}
${captionHtml}
`; +} diff --git a/packages/visx-a11y/src/generators/description.ts b/packages/visx-a11y/src/generators/description.ts new file mode 100644 index 0000000000..35ebb2565a --- /dev/null +++ b/packages/visx-a11y/src/generators/description.ts @@ -0,0 +1,120 @@ +import type { ChartA11yConfig } from '../types'; +import { DEFAULT_POINT_DESCRIPTION_THRESHOLD, normalizeChartA11yData } from '../utils/data'; +import { formatPercent, formatXValue, formatYValue } from '../utils/format'; + +type NumericPoint = { + x: string; + y: number; + yLabel: string; +}; + +function getChartTypeLabel({ chartType, locale }: ChartA11yConfig) { + return ( + (chartType && locale?.chartTypes?.[chartType]) || + (chartType ? `${chartType} chart` : locale?.chartRoleDescription ?? 'Chart') + ); +} + +function collectNumericPoints(config: ChartA11yConfig): NumericPoint[] { + return normalizeChartA11yData(config).series.flatMap((series) => + series.data + .map((datum, index) => ({ + x: formatXValue(config, datum, index, series.data), + y: config.y(datum, index, series.data), + yLabel: formatYValue(config, datum, index, series.data), + })) + .filter(({ y }) => Number.isFinite(y)), + ); +} + +function generateDenseDescription(config: ChartA11yConfig) { + const points = collectNumericPoints(config); + + if (points.length === 0) { + return `${getChartTypeLabel(config)} "${config.title}" has no numeric values.`; + } + + const values = points.map(({ y }) => y); + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + + return `${getChartTypeLabel(config)} of ${config.title}. ${ + points.length + } data points. Values range from ${config.formatY?.(min) ?? String(min)} to ${ + config.formatY?.(max) ?? String(max) + } with an average of ${config.formatY?.(mean) ?? String(Math.round(mean * 100) / 100)}.`; +} + +function generatePieDescription(config: ChartA11yConfig) { + const series = normalizeChartA11yData(config).series[0]; + + if (!series || series.data.length === 0) { + return `${getChartTypeLabel(config)} "${config.title}" has no segments.`; + } + + const segments = series.data.map((datum, index) => ({ + label: formatXValue(config, datum, index, series.data), + value: config.y(datum, index, series.data), + formattedValue: formatYValue(config, datum, index, series.data), + })); + const total = segments.reduce((sum, { value }) => sum + value, 0); + const sorted = [...segments].sort((a, b) => a.value - b.value); + const smallest = sorted[0]; + const largest = sorted[sorted.length - 1]; + const formatSegment = (segment: (typeof segments)[number]) => { + const share = total === 0 ? 0 : (segment.value / total) * 100; + + return `${segment.label}, ${segment.formattedValue} (${formatPercent(share)})`; + }; + + return `${getChartTypeLabel(config)} of ${config.title}. ${ + segments.length + } segments. Largest: ${formatSegment(largest)}. Smallest: ${formatSegment(smallest)}.`; +} + +function generateSmallSeriesDescription(config: ChartA11yConfig) { + const points = collectNumericPoints(config); + + if (points.length === 0) { + return `${getChartTypeLabel(config)} "${config.title}" has no numeric values.`; + } + + const first = points[0]; + const last = points[points.length - 1]; + const min = points.reduce((lowest, point) => (point.y < lowest.y ? point : lowest), points[0]); + const max = points.reduce((highest, point) => (point.y > highest.y ? point : highest), points[0]); + + return `${getChartTypeLabel(config)} of ${config.title} over ${ + points.length + } data points. Values start at ${first.yLabel} for ${first.x}, end at ${last.yLabel} for ${ + last.x + }, range from ${min.yLabel} at ${min.x} to ${max.yLabel} at ${max.x}.`; +} + +export function generateChartDescription(config: ChartA11yConfig): string { + if (config.description) { + return config.description; + } + + const normalized = normalizeChartA11yData(config); + const threshold = config.pointDescriptionThreshold ?? DEFAULT_POINT_DESCRIPTION_THRESHOLD; + + if (normalized.pointCount === 0) { + return `${getChartTypeLabel(config)} "${config.title}" has no data.`; + } + + if (config.chartType === 'pie' || config.chartType === 'donut') { + return generatePieDescription(config); + } + + if ( + normalized.pointCount > threshold || + config.chartType === 'scatter' || + config.chartType === 'heatmap' + ) { + return generateDenseDescription(config); + } + + return generateSmallSeriesDescription(config); +} diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index 18d942e6e6..d966e7a178 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -7,6 +7,9 @@ export { DEFAULT_SINGLE_SERIES_LABEL, normalizeChartA11yData, } from './utils/data'; +export { getChartAriaProps } from './generators/ariaProps'; +export { generateDataTableHTML } from './generators/dataTableHTML'; +export { generateChartDescription } from './generators/description'; export type { A11yLocale, @@ -14,7 +17,12 @@ export type { ChartA11yConfig, ChartA11yFocusedPoint, ChartA11yFormatter, + ChartA11yIds, + ChartA11yPointProps, + ChartA11yProps, + ChartA11ySeriesProps, ChartA11ySeriesConfig, + ChartA11ySvgProps, ChartA11yValue, ChartType, } from './types'; diff --git a/packages/visx-a11y/src/server.ts b/packages/visx-a11y/src/server.ts index 91c94b13a4..a5a47fea17 100644 --- a/packages/visx-a11y/src/server.ts +++ b/packages/visx-a11y/src/server.ts @@ -5,6 +5,9 @@ export { DEFAULT_SINGLE_SERIES_LABEL, normalizeChartA11yData, } from './utils/data'; +export { getChartAriaProps } from './generators/ariaProps'; +export { generateDataTableHTML } from './generators/dataTableHTML'; +export { generateChartDescription } from './generators/description'; export type { A11yLocale, @@ -12,7 +15,12 @@ export type { ChartA11yConfig, ChartA11yFocusedPoint, ChartA11yFormatter, + ChartA11yIds, + ChartA11yPointProps, + ChartA11yProps, + ChartA11ySeriesProps, ChartA11ySeriesConfig, + ChartA11ySvgProps, ChartA11yValue, ChartType, } from './types'; diff --git a/packages/visx-a11y/src/types.ts b/packages/visx-a11y/src/types.ts index 153ef3031f..e0530f7c60 100644 --- a/packages/visx-a11y/src/types.ts +++ b/packages/visx-a11y/src/types.ts @@ -29,6 +29,38 @@ export type ChartA11yFocusedPoint = { datum: Datum; }; +export type ChartA11yIds = { + rootId: string; + descriptionId: string; + tableId: string; +}; + +export type ChartA11ySvgProps = { + role: 'graphics-document'; + 'aria-roledescription': string; + 'aria-label': string; + 'aria-describedby': string; +}; + +export type ChartA11ySeriesProps = { + role: 'graphics-object'; + 'aria-roledescription': string; + 'aria-label': string; +}; + +export type ChartA11yPointProps = { + role: 'graphics-symbol'; + 'aria-roledescription': string; + 'aria-label': string; +}; + +export type ChartA11yProps = { + ids: ChartA11yIds; + svg: ChartA11ySvgProps; + series: ChartA11ySeriesProps[]; + points: ChartA11yPointProps[][]; +}; + export type A11yLocale = { chartRoleDescription: string; seriesRoleDescription: string; diff --git a/packages/visx-a11y/src/utils/format.ts b/packages/visx-a11y/src/utils/format.ts new file mode 100644 index 0000000000..308ae48fb2 --- /dev/null +++ b/packages/visx-a11y/src/utils/format.ts @@ -0,0 +1,73 @@ +import type { ChartA11yConfig } from '../types'; +import { DEFAULT_CHART_A11Y_ID_PREFIX } from './data'; + +const unsafeHtmlPattern = /["&'<>]/g; +const unsafeHtmlReplacements: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +function slugify(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^\da-z]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function escapeHtml(value: unknown) { + return String(value).replace(unsafeHtmlPattern, (match) => unsafeHtmlReplacements[match]); +} + +export function formatChartA11yValue(value: unknown) { + if (value instanceof Date) { + return value.toISOString().slice(0, 10); + } + + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + + return String(value); +} + +export function getChartA11yId({ + id, + idPrefix = DEFAULT_CHART_A11Y_ID_PREFIX, + title, +}: Pick, 'id' | 'idPrefix' | 'title'>) { + return id ?? `${idPrefix}-${slugify(title) || 'chart'}`; +} + +export function formatXValue( + config: Pick, 'formatX' | 'x'>, + datum: Datum, + index: number, + data: readonly Datum[], +) { + const value = config.x(datum, index, data); + + return config.formatX?.(value) ?? formatChartA11yValue(value); +} + +export function formatYValue( + config: Pick, 'formatY' | 'y'>, + datum: Datum, + index: number, + data: readonly Datum[], +) { + const value = config.y(datum, index, data); + + return config.formatY?.(value) ?? formatChartA11yValue(value); +} + +export function formatPercent(value: number) { + if (!Number.isFinite(value)) { + return '0%'; + } + + return `${Math.round(value * 10) / 10}%`; +} diff --git a/packages/visx-a11y/test/generators.test.ts b/packages/visx-a11y/test/generators.test.ts new file mode 100644 index 0000000000..0c1e4da73e --- /dev/null +++ b/packages/visx-a11y/test/generators.test.ts @@ -0,0 +1,142 @@ +import { generateChartDescription, generateDataTableHTML, getChartAriaProps } from '../src/server'; +import type { ChartA11yConfig } from '../src/server'; + +type Datum = { + label: string; + value: number; +}; + +const data: Datum[] = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 25 }, + { label: 'Mar', value: 15 }, +]; + +const lineConfig: ChartA11yConfig = { + id: 'revenue-chart', + title: 'Revenue', + chartType: 'line', + data, + x: (datum) => datum.label, + y: (datum) => datum.value, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], +}; + +describe('getChartAriaProps', () => { + it('generates svg, series, and point semantics', () => { + expect(getChartAriaProps(lineConfig)).toEqual({ + ids: { + rootId: 'revenue-chart', + descriptionId: 'revenue-chart-description', + tableId: 'revenue-chart-table', + }, + svg: { + role: 'graphics-document', + 'aria-roledescription': 'line chart', + 'aria-label': 'Revenue', + 'aria-describedby': 'revenue-chart-description', + }, + series: [ + { + role: 'graphics-object', + 'aria-roledescription': 'series', + 'aria-label': 'Revenue', + }, + ], + points: [ + [ + { + role: 'graphics-symbol', + 'aria-roledescription': 'data point', + 'aria-label': 'Revenue, Jan, $10', + }, + { + role: 'graphics-symbol', + 'aria-roledescription': 'data point', + 'aria-label': 'Revenue, Feb, $25', + }, + { + role: 'graphics-symbol', + 'aria-roledescription': 'data point', + 'aria-label': 'Revenue, Mar, $15', + }, + ], + ], + }); + }); + + it('generates deterministic ids from the title when an explicit id is not provided', () => { + expect( + getChartAriaProps({ ...lineConfig, id: undefined, title: 'Quarterly Revenue' }).ids, + ).toEqual({ + rootId: 'visx-a11y-quarterly-revenue', + descriptionId: 'visx-a11y-quarterly-revenue-description', + tableId: 'visx-a11y-quarterly-revenue-table', + }); + }); + + it('omits point labels above the point description threshold', () => { + expect(getChartAriaProps({ ...lineConfig, pointDescriptionThreshold: 2 }).points).toEqual([[]]); + }); +}); + +describe('generateChartDescription', () => { + it('uses consumer descriptions when provided', () => { + expect(generateChartDescription({ ...lineConfig, description: 'Custom description.' })).toBe( + 'Custom description.', + ); + }); + + it('summarizes small cartesian charts', () => { + expect(generateChartDescription(lineConfig)).toBe( + 'line chart of Revenue over 3 data points. Values start at $10 for Jan, end at $15 for Mar, range from $10 at Jan to $25 at Feb.', + ); + }); + + it('summarizes dense charts statistically', () => { + expect(generateChartDescription({ ...lineConfig, pointDescriptionThreshold: 2 })).toBe( + 'line chart of Revenue. 3 data points. Values range from $10 to $25 with an average of $16.666666666666668.', + ); + }); + + it('summarizes pie charts by segment share', () => { + expect( + generateChartDescription({ + ...lineConfig, + chartType: 'pie', + formatY: (value) => String(value), + }), + ).toBe('pie chart of Revenue. 3 segments. Largest: Feb, 25 (50%). Smallest: Jan, 10 (20%).'); + }); +}); + +describe('generateDataTableHTML', () => { + it('generates an escaped hidden table fallback', () => { + const html = generateDataTableHTML({ + ...lineConfig, + title: 'Revenue ', + data: [{ label: 'Jan & Feb', value: 10 }], + }); + + expect(html).toContain('Category'); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('uses one value column per series for multi-series data', () => { + const html = generateDataTableHTML({ + ...lineConfig, + data: [data, [{ label: 'Jan', value: 8 }]], + series: [{ label: 'Desktop' }, { label: 'Mobile' }], + }); + + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + expect(html).toContain(''); + }); +}); From 5dcc3358ee23c6f1642dd7f8fedd3732271dd8b2 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:07:57 -0400 Subject: [PATCH 04/17] feat(a11y): add data table fallback --- packages/visx-a11y/Readme.md | 1 + .../src/components/ChartA11yDataTable.tsx | 45 +++++++++ .../visx-a11y/src/generators/dataTableHTML.ts | 65 ++----------- packages/visx-a11y/src/index.ts | 2 + packages/visx-a11y/src/utils/table.ts | 81 ++++++++++++++++ packages/visx-a11y/test/dataTable.test.tsx | 94 +++++++++++++++++++ packages/visx-a11y/test/index.test.ts | 3 +- 7 files changed, 235 insertions(+), 56 deletions(-) create mode 100644 packages/visx-a11y/src/components/ChartA11yDataTable.tsx create mode 100644 packages/visx-a11y/src/utils/table.ts create mode 100644 packages/visx-a11y/test/dataTable.test.tsx diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 9837d64ea3..0fab296358 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -15,6 +15,7 @@ that registry items can compose without adopting a new chart component. ```tsx import type { ChartA11yConfig } from '@visx/a11y'; +import { ChartA11yDataTable } from '@visx/a11y'; import { generateChartDescription, getChartAriaProps } from '@visx/a11y/server'; ``` diff --git a/packages/visx-a11y/src/components/ChartA11yDataTable.tsx b/packages/visx-a11y/src/components/ChartA11yDataTable.tsx new file mode 100644 index 0000000000..54b4aa432c --- /dev/null +++ b/packages/visx-a11y/src/components/ChartA11yDataTable.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties } from 'react'; + +import type { ChartA11yConfig } from '../types'; +import { getChartA11yTable, VISUALLY_HIDDEN_STYLE } from '../utils/table'; + +export type ChartA11yDataTableProps = ChartA11yConfig & { + visible?: boolean; + className?: string; + style?: CSSProperties; +}; + +export function ChartA11yDataTable({ + visible = false, + className, + style, + ...config +}: ChartA11yDataTableProps) { + const table = getChartA11yTable(config); + const tableStyle = visible ? style : { ...VISUALLY_HIDDEN_STYLE, ...style }; + + return ( +
RevenueJan & Feb$10DesktopMobileJan$10$8Feb$25
+ + + + {table.headers.map((header, index) => ( + + ))} + + + + {table.rows.map(([xCell, ...yCells], rowIndex) => ( + + + {yCells.map((cell, cellIndex) => ( + + ))} + + ))} + +
{table.caption}
+ {header} +
{xCell}{cell}
+ ); +} diff --git a/packages/visx-a11y/src/generators/dataTableHTML.ts b/packages/visx-a11y/src/generators/dataTableHTML.ts index 0ccfe16ff5..55804c6d8d 100644 --- a/packages/visx-a11y/src/generators/dataTableHTML.ts +++ b/packages/visx-a11y/src/generators/dataTableHTML.ts @@ -1,60 +1,15 @@ import type { ChartA11yConfig } from '../types'; -import type { NormalizedChartA11yData } from '../utils/data'; -import { normalizeChartA11yData } from '../utils/data'; -import { escapeHtml, formatXValue, formatYValue, getChartA11yId } from '../utils/format'; +import { escapeHtml } from '../utils/format'; +import { getChartA11yTable, VISUALLY_HIDDEN_STYLE_STRING } from '../utils/table'; -export const VISUALLY_HIDDEN_TABLE_STYLE = - 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;'; - -function getFirstDatumAtIndex(normalized: NormalizedChartA11yData, index: number) { - return normalized.series.find(({ data }) => data[index] != null); -} - -function getXCell( - config: ChartA11yConfig, - normalized: NormalizedChartA11yData, - index: number, -) { - const series = getFirstDatumAtIndex(normalized, index); - const datum = series?.data[index]; - - return datum == null || !series ? '' : formatXValue(config, datum, index, series.data); -} - -function getYCell( - config: ChartA11yConfig, - normalized: NormalizedChartA11yData, - seriesIndex: number, - index: number, -) { - const series = normalized.series[seriesIndex]; - const datum = series?.data[index]; - - return datum == null || !series ? '' : formatYValue(config, datum, index, series.data); -} +export const VISUALLY_HIDDEN_TABLE_STYLE = VISUALLY_HIDDEN_STYLE_STRING; export function generateDataTableHTML(config: ChartA11yConfig): string { - const normalized = normalizeChartA11yData(config); - const tableId = `${getChartA11yId(config)}-table`; - const xLabel = config.xLabel ?? (config.chartType === 'histogram' ? 'Bin' : 'Category'); - const yLabel = config.yLabel ?? 'Value'; - const headers = normalized.isMultiSeries - ? [xLabel, ...normalized.series.map(({ label }) => label)] - : [ - xLabel, - normalized.series[0]?.label === 'Data' ? yLabel : normalized.series[0]?.label ?? yLabel, - ]; - const rows = Array.from({ length: normalized.maxSeriesLength }, (_, index) => { - const yCells = normalized.isMultiSeries - ? normalized.series.map(({ index: seriesIndex }) => - getYCell(config, normalized, seriesIndex, index), - ) - : [getYCell(config, normalized, 0, index)]; - - return [getXCell(config, normalized, index), ...yCells]; - }); - const headerHtml = headers.map((header) => `${escapeHtml(header)}`).join(''); - const rowHtml = rows + const table = getChartA11yTable(config); + const headerHtml = table.headers + .map((header) => `${escapeHtml(header)}`) + .join(''); + const rowHtml = table.rows .map( ([xCell, ...yCells]) => `${escapeHtml(xCell)}${yCells @@ -62,8 +17,8 @@ export function generateDataTableHTML(config: ChartA11yConfig): st .join('')}`, ) .join(''); - const tableIdHtml = escapeHtml(tableId); - const captionHtml = escapeHtml(config.locale?.dataTableCaption?.(config.title) ?? config.title); + const tableIdHtml = escapeHtml(table.id); + const captionHtml = escapeHtml(table.caption); return `${headerHtml}${rowHtml}
${captionHtml}
`; } diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index d966e7a178..3bc667bf7a 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -10,6 +10,8 @@ export { export { getChartAriaProps } from './generators/ariaProps'; export { generateDataTableHTML } from './generators/dataTableHTML'; export { generateChartDescription } from './generators/description'; +export { ChartA11yDataTable } from './components/ChartA11yDataTable'; +export type { ChartA11yDataTableProps } from './components/ChartA11yDataTable'; export type { A11yLocale, diff --git a/packages/visx-a11y/src/utils/table.ts b/packages/visx-a11y/src/utils/table.ts new file mode 100644 index 0000000000..e317580921 --- /dev/null +++ b/packages/visx-a11y/src/utils/table.ts @@ -0,0 +1,81 @@ +import type { ChartA11yConfig } from '../types'; +import type { NormalizedChartA11yData } from './data'; +import { normalizeChartA11yData } from './data'; +import { formatXValue, formatYValue, getChartA11yId } from './format'; + +export const VISUALLY_HIDDEN_STYLE = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +} as const; + +export const VISUALLY_HIDDEN_STYLE_STRING = + 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;'; + +export type ChartA11yTable = { + id: string; + caption: string; + headers: string[]; + rows: string[][]; +}; + +function getFirstDatumAtIndex(normalized: NormalizedChartA11yData, index: number) { + return normalized.series.find(({ data }) => data[index] != null); +} + +function getXCell( + config: ChartA11yConfig, + normalized: NormalizedChartA11yData, + index: number, +) { + const series = getFirstDatumAtIndex(normalized, index); + const datum = series?.data[index]; + + return datum == null || !series ? '' : formatXValue(config, datum, index, series.data); +} + +function getYCell( + config: ChartA11yConfig, + normalized: NormalizedChartA11yData, + seriesIndex: number, + index: number, +) { + const series = normalized.series[seriesIndex]; + const datum = series?.data[index]; + + return datum == null || !series ? '' : formatYValue(config, datum, index, series.data); +} + +export function getChartA11yTable(config: ChartA11yConfig): ChartA11yTable { + const normalized = normalizeChartA11yData(config); + const xLabel = config.xLabel ?? (config.chartType === 'histogram' ? 'Bin' : 'Category'); + const yLabel = config.yLabel ?? 'Value'; + const headers = normalized.isMultiSeries + ? [xLabel, ...normalized.series.map(({ label }) => label)] + : [ + xLabel, + normalized.series[0]?.label === 'Data' ? yLabel : normalized.series[0]?.label ?? yLabel, + ]; + const rows = Array.from({ length: normalized.maxSeriesLength }, (_, index) => { + const yCells = normalized.isMultiSeries + ? normalized.series.map(({ index: seriesIndex }) => + getYCell(config, normalized, seriesIndex, index), + ) + : [getYCell(config, normalized, 0, index)]; + + return [getXCell(config, normalized, index), ...yCells]; + }); + + return { + id: `${getChartA11yId(config)}-table`, + caption: config.locale?.dataTableCaption?.(config.title) ?? config.title, + headers, + rows, + }; +} diff --git a/packages/visx-a11y/test/dataTable.test.tsx b/packages/visx-a11y/test/dataTable.test.tsx new file mode 100644 index 0000000000..18b2a56ad0 --- /dev/null +++ b/packages/visx-a11y/test/dataTable.test.tsx @@ -0,0 +1,94 @@ +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; + +import { ChartA11yDataTable } from '../src'; +import type { ChartA11yConfig } from '../src'; + +type Datum = { + label: string; + value: number; +}; + +const data: Datum[] = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 25 }, +]; + +const lineConfig: ChartA11yConfig = { + id: 'revenue-chart', + title: 'Revenue', + chartType: 'line', + data, + x: (datum) => datum.label, + y: (datum) => datum.value, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], +}; + +function getTableText(table: HTMLElement) { + return Array.from(table.querySelectorAll('tbody tr')).map((row) => + Array.from(row.children).map((cell) => cell.textContent), + ); +} + +describe('ChartA11yDataTable', () => { + it('renders a visually hidden data table by default', () => { + const { container } = render(); + const table = container.querySelector('table'); + + expect(table?.id).toBe('revenue-chart-table'); + expect(table?.style.position).toBe('absolute'); + expect(table?.style.width).toBe('1px'); + expect(table?.querySelector('caption')?.textContent).toBe('Revenue'); + expect( + Array.from(table?.querySelectorAll('thead th') ?? []).map((cell) => cell.textContent), + ).toEqual(['Category', 'Revenue']); + expect(table ? getTableText(table) : []).toEqual([ + ['Jan', '$10'], + ['Feb', '$25'], + ]); + }); + + it('can render a visible data table', () => { + const { container } = render( + , + ); + const table = container.querySelector('table'); + + expect(table?.style.position).toBe(''); + expect(table?.style.border).toBe('1px solid red'); + }); + + it('uses one value column per series for multi-series data', () => { + const { container } = render( + , + ); + const table = container.querySelector('table'); + + expect( + Array.from(table?.querySelectorAll('thead th') ?? []).map((cell) => cell.textContent), + ).toEqual(['Category', 'Desktop', 'Mobile']); + expect(table ? getTableText(table) : []).toEqual([ + ['Jan', '$10', '$8'], + ['Feb', '$25', ''], + ]); + }); + + it('escapes table text during server rendering', () => { + const html = renderToString( + , + ); + + expect(html).toContain('Revenue <Q1>'); + expect(html).toContain('Jan & Feb'); + }); +}); diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index 655c57942f..a38efb43a6 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -1,7 +1,8 @@ -import { normalizeChartA11yData } from '../src'; +import { ChartA11yDataTable, normalizeChartA11yData } from '../src'; describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { expect(normalizeChartA11yData).toBeDefined(); + expect(ChartA11yDataTable).toBeDefined(); }); }); From 2260bcb17f3557e4e97fb907a3909412e79ce479 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:10:07 -0400 Subject: [PATCH 05/17] feat(a11y): add live announcer --- .../src/components/ChartA11yAnnouncer.tsx | 42 +++++++++++++++++++ packages/visx-a11y/src/index.ts | 5 +++ packages/visx-a11y/test/announcer.test.tsx | 37 ++++++++++++++++ packages/visx-a11y/test/index.test.ts | 3 +- 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/visx-a11y/src/components/ChartA11yAnnouncer.tsx create mode 100644 packages/visx-a11y/test/announcer.test.tsx diff --git a/packages/visx-a11y/src/components/ChartA11yAnnouncer.tsx b/packages/visx-a11y/src/components/ChartA11yAnnouncer.tsx new file mode 100644 index 0000000000..6c2ef8e379 --- /dev/null +++ b/packages/visx-a11y/src/components/ChartA11yAnnouncer.tsx @@ -0,0 +1,42 @@ +import type { CSSProperties, ReactNode } from 'react'; + +import { VISUALLY_HIDDEN_STYLE } from '../utils/table'; + +export type ChartA11yLivePoliteness = 'polite' | 'assertive'; + +export type ChartA11yAnnouncerProps = { + id?: string; + message?: ReactNode; + children?: ReactNode; + politeness?: ChartA11yLivePoliteness; + atomic?: boolean; + visible?: boolean; + className?: string; + style?: CSSProperties; +}; + +export function ChartA11yAnnouncer({ + id, + message, + children, + politeness = 'polite', + atomic = true, + visible = false, + className, + style, +}: ChartA11yAnnouncerProps) { + const liveRegionStyle = visible ? style : { ...VISUALLY_HIDDEN_STYLE, ...style }; + + return ( +
+ {children ?? message} +
+ ); +} diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index 3bc667bf7a..b440b2ae88 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -10,7 +10,12 @@ export { export { getChartAriaProps } from './generators/ariaProps'; export { generateDataTableHTML } from './generators/dataTableHTML'; export { generateChartDescription } from './generators/description'; +export { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; export { ChartA11yDataTable } from './components/ChartA11yDataTable'; +export type { + ChartA11yAnnouncerProps, + ChartA11yLivePoliteness, +} from './components/ChartA11yAnnouncer'; export type { ChartA11yDataTableProps } from './components/ChartA11yDataTable'; export type { diff --git a/packages/visx-a11y/test/announcer.test.tsx b/packages/visx-a11y/test/announcer.test.tsx new file mode 100644 index 0000000000..35259cbea3 --- /dev/null +++ b/packages/visx-a11y/test/announcer.test.tsx @@ -0,0 +1,37 @@ +import { render } from '@testing-library/react'; +import { renderToString } from 'react-dom/server'; + +import { ChartA11yAnnouncer } from '../src'; + +describe('ChartA11yAnnouncer', () => { + it('renders a visually hidden polite live region by default', () => { + const { container } = render(); + const region = container.querySelector('[role="status"]') as HTMLElement | null; + + expect(region?.getAttribute('aria-live')).toBe('polite'); + expect(region?.getAttribute('aria-atomic')).toBe('true'); + expect(region?.style.position).toBe('absolute'); + expect(region?.textContent).toBe('Revenue updated'); + }); + + it('can render an assertive live region from children', () => { + const { container } = render( + Selection changed, + ); + const region = container.querySelector('[role="alert"]'); + + expect(region?.getAttribute('aria-live')).toBe('assertive'); + expect(region?.textContent).toBe('Selection changed'); + }); + + it('can render visibly during server rendering', () => { + const html = renderToString( + , + ); + + expect(html).toContain('id="chart-announcer"'); + expect(html).toContain('role="status"'); + expect(html).toContain('Ready'); + expect(html).not.toContain('position:absolute'); + }); +}); diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index a38efb43a6..fc5a4c6ce7 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -1,8 +1,9 @@ -import { ChartA11yDataTable, normalizeChartA11yData } from '../src'; +import { ChartA11yAnnouncer, ChartA11yDataTable, normalizeChartA11yData } from '../src'; describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { expect(normalizeChartA11yData).toBeDefined(); + expect(ChartA11yAnnouncer).toBeDefined(); expect(ChartA11yDataTable).toBeDefined(); }); }); From 07d379e5f32f0f8a30e5efb3b681926c49ce3316 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:17:12 -0400 Subject: [PATCH 06/17] docs(a11y): document package adoption --- packages/visx-a11y/Readme.md | 40 ++++- packages/visx-a11y/src/index.ts | 9 + packages/visx-a11y/src/useChartA11y.tsx | 156 ++++++++++++++++++ packages/visx-a11y/test/index.test.ts | 8 +- packages/visx-a11y/test/useChartA11y.test.tsx | 118 +++++++++++++ 5 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 packages/visx-a11y/src/useChartA11y.tsx create mode 100644 packages/visx-a11y/test/useChartA11y.test.tsx diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 0fab296358..0023237086 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -15,9 +15,47 @@ that registry items can compose without adopting a new chart component. ```tsx import type { ChartA11yConfig } from '@visx/a11y'; -import { ChartA11yDataTable } from '@visx/a11y'; +import { ChartA11yDataTable, useChartA11y } from '@visx/a11y'; import { generateChartDescription, getChartAriaProps } from '@visx/a11y/server'; ``` The root entry is reserved for React hooks and components. The `server` entry is reserved for pure helpers that can run during server rendering. + +## Semantic Hook + +`useChartA11y` is the lowest-friction adoption path for client-rendered charts. It returns +spreadable SVG, series, and point props, a generated description, pre-bound data table and announcer +components, and an imperative `announce` helper. + +```tsx +const a11y = useChartA11y({ + id: 'revenue-chart', + title: 'Revenue by month', + chartType: 'line', + data, + x: (datum) => datum.month, + y: (datum) => datum.revenue, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, +}); + +return ( + <> + + {a11y.description} + + {data.map((datum, index) => ( + + ))} + + + + + +); +``` + +The hook uses React `useId()` only when `id` is omitted. Server-safe helpers remain pure and derive +ids from explicit `id` / `idPrefix` inputs. `keyboardNavEnabled` is part of the hook API, but full +keyboard traversal is intentionally left to the dedicated keyboard-navigation follow-up. diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index b440b2ae88..272880132b 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -10,8 +10,17 @@ export { export { getChartAriaProps } from './generators/ariaProps'; export { generateDataTableHTML } from './generators/dataTableHTML'; export { generateChartDescription } from './generators/description'; +export { useChartA11y } from './useChartA11y'; export { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; export { ChartA11yDataTable } from './components/ChartA11yDataTable'; +export type { + ChartA11yMode, + UseChartA11yAnnouncerProps, + UseChartA11yDataTableProps, + UseChartA11yPointProps, + UseChartA11yResult, + UseChartA11ySvgProps, +} from './useChartA11y'; export type { ChartA11yAnnouncerProps, ChartA11yLivePoliteness, diff --git a/packages/visx-a11y/src/useChartA11y.tsx b/packages/visx-a11y/src/useChartA11y.tsx new file mode 100644 index 0000000000..aae073056a --- /dev/null +++ b/packages/visx-a11y/src/useChartA11y.tsx @@ -0,0 +1,156 @@ +import type { ComponentType, KeyboardEvent, RefCallback } from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; + +import { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; +import type { ChartA11yAnnouncerProps } from './components/ChartA11yAnnouncer'; +import { ChartA11yDataTable } from './components/ChartA11yDataTable'; +import type { ChartA11yDataTableProps } from './components/ChartA11yDataTable'; +import { getChartAriaProps } from './generators/ariaProps'; +import { generateChartDescription } from './generators/description'; +import type { + ChartA11yConfig, + ChartA11yPointProps, + ChartA11ySeriesProps, + ChartA11ySvgProps, +} from './types'; +import { DEFAULT_CHART_A11Y_ID_PREFIX } from './utils/data'; + +export type ChartA11yMode = 'chart' | 'data'; + +export type UseChartA11ySvgProps = ChartA11ySvgProps & { + tabIndex?: 0; + onKeyDown?: (event: KeyboardEvent) => void; + ref: RefCallback; +}; + +export type UseChartA11yPointProps = ChartA11yPointProps & { + tabIndex: -1; + 'data-a11y-focused'?: boolean; +}; + +export type UseChartA11yDataTableProps = Pick< + ChartA11yDataTableProps, + 'className' | 'style' | 'visible' +>; + +export type UseChartA11yAnnouncerProps = Omit; + +export type UseChartA11yResult = { + svgProps: UseChartA11ySvgProps; + getSeriesProps: (seriesIndex: number) => ChartA11ySeriesProps; + getPointProps: (seriesIndex: number, index: number) => UseChartA11yPointProps; + descriptionId: string; + description: string; + DataTable: ComponentType>; + Announcer: ComponentType; + announce: (message: string) => void; + mode: ChartA11yMode; + focusedPoint: { seriesIndex: number; index: number } | null; +}; + +function formatReactId(id: string) { + return id.replace(/[^\w-]/g, '') || 'chart'; +} + +export function useChartA11y(config: ChartA11yConfig): UseChartA11yResult { + const reactId = useId(); + const svgRef = useRef(null); + const [announcement, setAnnouncement] = useState(''); + const keyboardNavEnabled = config.keyboardNavEnabled ?? true; + const id = + config.id ?? `${config.idPrefix ?? DEFAULT_CHART_A11Y_ID_PREFIX}-${formatReactId(reactId)}`; + const hookConfig = useMemo(() => ({ ...config, id }), [config, id]); + const ariaProps = useMemo(() => getChartAriaProps(hookConfig), [hookConfig]); + const description = useMemo(() => generateChartDescription(hookConfig), [hookConfig]); + const focusedPoint = null; + + const setSvgRef = useCallback>((node) => { + svgRef.current = node; + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === '?' || event.key === 'F1') { + setAnnouncement( + hookConfig.locale?.keyboardHelp ?? 'Keyboard navigation is not yet available.', + ); + } + }, + [hookConfig.locale], + ); + + const svgProps = useMemo(() => { + const interactionProps = keyboardNavEnabled + ? { + onKeyDown: handleKeyDown, + tabIndex: 0 as const, + } + : {}; + + return { + ...ariaProps.svg, + ...interactionProps, + ref: setSvgRef, + }; + }, [ariaProps.svg, handleKeyDown, keyboardNavEnabled, setSvgRef]); + + const getSeriesProps = useCallback( + (seriesIndex: number) => + ariaProps.series[seriesIndex] ?? { + role: 'graphics-object', + 'aria-roledescription': hookConfig.locale?.seriesRoleDescription ?? 'series', + 'aria-label': `Series ${seriesIndex + 1}`, + }, + [ariaProps.series, hookConfig.locale], + ); + + const getPointProps = useCallback( + (seriesIndex: number, index: number): UseChartA11yPointProps => { + const pointProps = ariaProps.points[seriesIndex]?.[index]; + + return { + ...(pointProps ?? { + role: 'graphics-symbol', + 'aria-roledescription': hookConfig.locale?.pointRoleDescription ?? 'data point', + 'aria-label': '', + }), + tabIndex: -1, + }; + }, + [ariaProps.points, hookConfig.locale], + ); + + const DataTable = useCallback( + ({ visible, className, style }: UseChartA11yDataTableProps = {}) => { + if (hookConfig.dataTableEnabled === false) { + return null; + } + + return ( + + ); + }, + [hookConfig], + ); + + const Announcer = useCallback( + (props: UseChartA11yAnnouncerProps = {}) => ( + + ), + [announcement], + ); + const announce = useCallback((message: string) => setAnnouncement(message), []); + + return { + svgProps, + getSeriesProps, + getPointProps, + descriptionId: ariaProps.ids.descriptionId, + description, + DataTable, + Announcer, + announce, + mode: 'chart', + focusedPoint, + }; +} diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index fc5a4c6ce7..ff808c609f 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -1,8 +1,14 @@ -import { ChartA11yAnnouncer, ChartA11yDataTable, normalizeChartA11yData } from '../src'; +import { + ChartA11yAnnouncer, + ChartA11yDataTable, + normalizeChartA11yData, + useChartA11y, +} from '../src'; describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { expect(normalizeChartA11yData).toBeDefined(); + expect(useChartA11y).toBeDefined(); expect(ChartA11yAnnouncer).toBeDefined(); expect(ChartA11yDataTable).toBeDefined(); }); diff --git a/packages/visx-a11y/test/useChartA11y.test.tsx b/packages/visx-a11y/test/useChartA11y.test.tsx new file mode 100644 index 0000000000..13ad0812bc --- /dev/null +++ b/packages/visx-a11y/test/useChartA11y.test.tsx @@ -0,0 +1,118 @@ +import { act, render, renderHook } from '@testing-library/react'; + +import { useChartA11y } from '../src'; +import type { ChartA11yConfig } from '../src'; + +type Datum = { + label: string; + value: number; +}; + +const data: Datum[] = [ + { label: 'Jan', value: 10 }, + { label: 'Feb', value: 25 }, +]; + +const lineConfig: ChartA11yConfig = { + id: 'revenue-chart', + title: 'Revenue', + chartType: 'line', + data, + x: (datum) => datum.label, + y: (datum) => datum.value, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], +}; + +describe('useChartA11y', () => { + it('returns chart semantics and generated description metadata', () => { + const { result } = renderHook(() => useChartA11y(lineConfig)); + + expect(result.current.descriptionId).toBe('revenue-chart-description'); + expect(result.current.description).toBe( + 'line chart of Revenue over 2 data points. Values start at $10 for Jan, end at $25 for Feb, range from $10 at Jan to $25 at Feb.', + ); + expect(result.current.svgProps).toEqual( + expect.objectContaining({ + role: 'graphics-document', + 'aria-roledescription': 'line chart', + 'aria-label': 'Revenue', + 'aria-describedby': 'revenue-chart-description', + tabIndex: 0, + }), + ); + expect(result.current.svgProps.ref).toEqual(expect.any(Function)); + expect(result.current.svgProps.onKeyDown).toEqual(expect.any(Function)); + expect(result.current.getSeriesProps(0)).toEqual({ + role: 'graphics-object', + 'aria-roledescription': 'series', + 'aria-label': 'Revenue', + }); + expect(result.current.getPointProps(0, 1)).toEqual({ + role: 'graphics-symbol', + 'aria-roledescription': 'data point', + 'aria-label': 'Revenue, Feb, $25', + tabIndex: -1, + }); + expect(result.current.mode).toBe('chart'); + expect(result.current.focusedPoint).toBeNull(); + }); + + it('uses React ids when the hook has no explicit id', () => { + const { result } = renderHook(() => + useChartA11y({ ...lineConfig, id: undefined, idPrefix: 'chart' }), + ); + + expect(result.current.descriptionId).toMatch(/^chart-[\w-]+-description$/); + expect(result.current.descriptionId).not.toBe('chart-revenue-description'); + }); + + it('can disable keyboard navigation props while keeping semantics', () => { + const { result } = renderHook(() => useChartA11y({ ...lineConfig, keyboardNavEnabled: false })); + + expect(result.current.svgProps).toEqual( + expect.objectContaining({ + role: 'graphics-document', + 'aria-label': 'Revenue', + }), + ); + expect(result.current.svgProps.tabIndex).toBeUndefined(); + expect(result.current.svgProps.onKeyDown).toBeUndefined(); + }); + + it('returns a pre-bound data table component', () => { + const { result } = renderHook(() => useChartA11y(lineConfig)); + const { DataTable } = result.current; + const { container } = render(); + const table = container.querySelector('table'); + + expect(table?.id).toBe('revenue-chart-table'); + expect(table?.querySelector('caption')?.textContent).toBe('Revenue'); + expect( + Array.from(table?.querySelectorAll('tbody tr') ?? []).map((row) => row.textContent), + ).toEqual(['Jan$10', 'Feb$25']); + }); + + it('can disable the pre-bound data table component', () => { + const { result } = renderHook(() => useChartA11y({ ...lineConfig, dataTableEnabled: false })); + const { DataTable } = result.current; + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it('returns a pre-bound announcer component and imperative announce helper', () => { + const { result } = renderHook(() => useChartA11y(lineConfig)); + + act(() => { + result.current.announce('Revenue updated'); + }); + + const { Announcer } = result.current; + const { container } = render(); + const region = container.querySelector('[role="status"]'); + + expect(region?.textContent).toBe('Revenue updated'); + }); +}); From 7f5632415b28bcbb0eea138d7e3dfbd1fb1368e0 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:28:50 -0400 Subject: [PATCH 07/17] docs(a11y): document package adoption --- MIGRATION.md | 15 +++ packages/visx-a11y/Readme.md | 187 +++++++++++++++++++++++++++++++---- 2 files changed, 181 insertions(+), 21 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 4dfd14fdfd..0e43d6b3f8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -64,6 +64,21 @@ same visual choices with `fromXYChartTheme` from `@visx/theme`. Add `@visx/theme` only when you want shared visual tokens or shadcn/ui-compatible CSS variable theming for primitive charts. +### `@visx/a11y` adds opt-in accessibility helpers for primitive charts + +visx 4.1 introduces `@visx/a11y`, an optional package for adding chart semantics to primitive SVG +charts. It provides server-safe helpers for generated ARIA props, text descriptions, and hidden data +tables, plus a client hook for pre-bound chart props, data-table fallback, and live announcements. + +These helpers do not replace the chart render tree or hide non-data elements automatically. If your +chart includes decorative chrome such as grid lines, axis ticks, background rectangles, or clipping +helpers, render those elements with `aria-hidden="true"` so assistive technology focuses on the +chart title, description, series, data marks, and table fallback. + +**What you need to do:** nothing unless you want to opt in. Existing visx charts continue to work. +Add `@visx/a11y` only when you want generated chart semantics or a data-table fallback for primitive +charts. + ### Registry-oriented hooks for primitive charts visx 4.1 adds the first hook and helper APIs used to build primitive charts for the visx chart diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 0023237086..7a2bbcd74e 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -1,32 +1,129 @@ # @visx/a11y -Accessibility helpers for primitive visx charts. +Accessibility helpers for primitive visx charts without changing the low-level visx primitive model. +The package is split into a server-safe entry for generated semantics and a client root entry for +React hooks and companion components. -`@visx/a11y` will provide spreadable chart semantics, data table fallbacks, live announcements, and -keyboard navigation helpers for charts built from low-level visx packages. The package follows the -same primitive-first model as the rest of visx: hooks and helpers own accessibility state and -generated semantics, while users keep control of their SVG render tree. +## Installation -This package is being introduced for the visx chart registry work. The initial implementation will -focus on semantic SVG annotation, server-safe description and table generators, and React helpers -that registry items can compose without adopting a new chart component. +```sh +npm install --save @visx/a11y +``` + +## Motivation + +visx is intentionally low level: primitives take props, compose freely, and stay out of your app's +state model. That is what makes visx useful for unusual charts, custom dashboards, and product +interfaces that do not fit a fixed charting API. + +The tradeoff is that primitive charts tend to rebuild the same accessibility layer: + +- chart titles and long descriptions +- series labels and point value labels +- hidden data tables for exact values +- live regions for async chart updates +- keyboard affordances for chart exploration +- rules for hiding decorative SVG chrome + +That works for one chart. It gets noisy when a registry or dashboard has many charts built from +different visx packages. Small differences creep in, dense charts can produce too much screen reader +output, and teams end up copying local helpers from chart to chart. + +`@visx/a11y` gives those charts a shared semantic layer while keeping the rendering primitives +plain. It does not ship a chart component, impose SVG structure, or hide non-data elements +automatically. Instead, it gives you spreadable props, generated descriptions, table fallbacks, and +small React helpers that you can attach to the primitives you already render. + +## Primitive-first design -## Entry points +The a11y package is a semantics layer, not a chart framework. + +- visx primitives remain prop-driven. +- Server helpers are pure functions with no DOM or React hook requirements. +- The client hook uses React `useId()` only when `id` is omitted. +- Consumers own the SVG render tree, focus ring styling, and decorative element visibility. +- Data table and announcer components are independently usable. +- Dense charts degrade to summary descriptions above `pointDescriptionThreshold`. +- `keyboardNavEnabled` is part of the API, while full keyboard traversal is added in a follow-up. + +This keeps chart registry items and product charts able to opt in one layer at a time. If a chart +needs custom descriptions, a visible data table, a separate live region, or no interactive keyboard +state yet, the primitive API still supports that. + +## Basic usage + +Use the server entry when a server-rendered chart only needs generated SVG semantics, a text +description, or an HTML table fallback. ```tsx -import type { ChartA11yConfig } from '@visx/a11y'; -import { ChartA11yDataTable, useChartA11y } from '@visx/a11y'; -import { generateChartDescription, getChartAriaProps } from '@visx/a11y/server'; +import type { ChartA11yConfig } from '@visx/a11y/server'; +import { + generateChartDescription, + generateDataTableHTML, + getChartAriaProps, +} from '@visx/a11y/server'; + +type Datum = { + month: string; + revenue: number; +}; + +const config: ChartA11yConfig = { + id: 'revenue-chart', + title: 'Revenue by month', + chartType: 'line', + data, + x: (datum) => datum.month, + y: (datum) => datum.revenue, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, +}; + +const a11y = getChartAriaProps(config); +const description = generateChartDescription(config); +const tableHtml = generateDataTableHTML(config); ``` -The root entry is reserved for React hooks and components. The `server` entry is reserved for pure -helpers that can run during server rendering. +Use `useChartA11y` from the root entry when chart components need React-bound props and companion +components. -## Semantic Hook +```tsx +'use client'; -`useChartA11y` is the lowest-friction adoption path for client-rendered charts. It returns -spreadable SVG, series, and point props, a generated description, pre-bound data table and announcer -components, and an imperative `announce` helper. +import { useChartA11y } from '@visx/a11y'; + +export function RevenueChart({ data }) { + const a11y = useChartA11y({ + id: 'revenue-chart', + title: 'Revenue by month', + chartType: 'line', + data, + x: (datum) => datum.month, + y: (datum) => datum.revenue, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + }); + + return ( + <> + + {a11y.description} + {/* chart marks */} + + + + + ); +} +``` + +## Recipes + +### Level 1: Chart semantics, description, and table fallback + +Start by spreading `svgProps`, rendering the generated description, and adding the pre-bound data +table and live announcer. This gives the chart an accessible name, a chart-level description, and an +exact-value table fallback without changing the visual chart. ```tsx const a11y = useChartA11y({ @@ -40,6 +137,41 @@ const a11y = useChartA11y({ formatY: (value) => `$${value}`, }); +return ( + <> + + {a11y.description} + + {data.map((datum) => ( + + ))} + + + + + +); +``` + +### Level 2: Series and point semantics + +After Level 1 is in place, spread `getSeriesProps` on each rendered series group and `getPointProps` +on each rendered data mark. This adds per-series labels and per-point value labels for charts below +the configured `pointDescriptionThreshold`. + +```tsx +const a11y = useChartA11y({ + id: 'revenue-chart', + title: 'Revenue by month', + chartType: 'line', + data, + x: (datum) => datum.month, + y: (datum) => datum.revenue, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], +}); + return ( <> @@ -56,6 +188,19 @@ return ( ); ``` -The hook uses React `useId()` only when `id` is omitted. Server-safe helpers remain pure and derive -ids from explicit `id` / `idPrefix` inputs. `keyboardNavEnabled` is part of the hook API, but full -keyboard traversal is intentionally left to the dedicated keyboard-navigation follow-up. +### Decorative chart chrome + +`@visx/a11y` annotates the chart elements it receives through the hook. Consumers are responsible +for hiding decorative chart chrome from assistive technology. Grid lines, axis ticks, background +rects, clipping helpers, and other non-data elements should usually be rendered with +`aria-hidden="true"` so screen readers focus on the title, description, series, points, and data +table. + +```tsx + + + + + {/* data marks */} + +``` From 86336f4fcbe42e9f44da1b4dcab3301c719696fb Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:39:41 -0400 Subject: [PATCH 08/17] feat(a11y): add keyboard navigation state machine --- .../visx-a11y/src/keyboard/stateMachine.ts | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 packages/visx-a11y/src/keyboard/stateMachine.ts diff --git a/packages/visx-a11y/src/keyboard/stateMachine.ts b/packages/visx-a11y/src/keyboard/stateMachine.ts new file mode 100644 index 0000000000..f92483529a --- /dev/null +++ b/packages/visx-a11y/src/keyboard/stateMachine.ts @@ -0,0 +1,231 @@ +export type ChartA11yMode = 'chart' | 'data'; + +export type ChartA11yPointFocus = { + seriesIndex: number; + index: number; +}; + +export type ChartA11yKeyboardState = { + mode: ChartA11yMode; + focusedPoint: ChartA11yPointFocus | null; + lastFocusedPoint: ChartA11yPointFocus | null; +}; + +export type ChartA11yKeyboardIntent = + | 'enter' + | 'exit' + | 'nextPoint' + | 'previousPoint' + | 'nextSeries' + | 'previousSeries' + | 'firstPoint' + | 'lastPoint' + | 'firstChartPoint' + | 'lastChartPoint'; + +export const initialChartA11yKeyboardState: ChartA11yKeyboardState = { + mode: 'chart', + focusedPoint: null, + lastFocusedPoint: null, +}; + +function getSeriesLength(seriesLengths: readonly number[], seriesIndex: number) { + return seriesLengths[seriesIndex] ?? 0; +} + +function getPointCount(seriesLengths: readonly number[]) { + return seriesLengths.reduce((total, length) => total + length, 0); +} + +function getFirstPoint(seriesLengths: readonly number[]): ChartA11yPointFocus | null { + const seriesIndex = seriesLengths.findIndex((length) => length > 0); + + return seriesIndex < 0 ? null : { seriesIndex, index: 0 }; +} + +function getLastPoint(seriesLengths: readonly number[]): ChartA11yPointFocus | null { + for (let seriesIndex = seriesLengths.length - 1; seriesIndex >= 0; seriesIndex -= 1) { + const length = getSeriesLength(seriesLengths, seriesIndex); + + if (length > 0) { + return { seriesIndex, index: length - 1 }; + } + } + + return null; +} + +function arePointsEqual( + firstPoint: ChartA11yPointFocus | null, + secondPoint: ChartA11yPointFocus | null, +) { + return ( + firstPoint?.seriesIndex === secondPoint?.seriesIndex && firstPoint?.index === secondPoint?.index + ); +} + +function clampPoint( + seriesLengths: readonly number[], + point: ChartA11yPointFocus | null, +): ChartA11yPointFocus | null { + if (!point || getPointCount(seriesLengths) === 0) { + return getFirstPoint(seriesLengths); + } + + const length = getSeriesLength(seriesLengths, point.seriesIndex); + + if (length > 0) { + return { + seriesIndex: point.seriesIndex, + index: Math.min(Math.max(point.index, 0), length - 1), + }; + } + + return getFirstPoint(seriesLengths); +} + +function setFocusedPoint( + state: ChartA11yKeyboardState, + focusedPoint: ChartA11yPointFocus | null, +): ChartA11yKeyboardState { + if (arePointsEqual(state.focusedPoint, focusedPoint)) { + return state; + } + + return { + mode: focusedPoint ? 'data' : state.mode, + focusedPoint, + lastFocusedPoint: focusedPoint ?? state.lastFocusedPoint, + }; +} + +function movePoint( + seriesLengths: readonly number[], + point: ChartA11yPointFocus | null, + direction: 1 | -1, +): ChartA11yPointFocus | null { + const focusedPoint = clampPoint(seriesLengths, point); + + if (!focusedPoint) { + return null; + } + + const length = getSeriesLength(seriesLengths, focusedPoint.seriesIndex); + + if (length <= 1) { + return focusedPoint; + } + + return { + seriesIndex: focusedPoint.seriesIndex, + index: (focusedPoint.index + direction + length) % length, + }; +} + +function moveSeries( + seriesLengths: readonly number[], + point: ChartA11yPointFocus | null, + direction: 1 | -1, +): ChartA11yPointFocus | null { + const focusedPoint = clampPoint(seriesLengths, point); + const seriesCount = seriesLengths.length; + + if (!focusedPoint || seriesCount <= 1) { + return focusedPoint; + } + + for (let offset = 1; offset < seriesCount; offset += 1) { + const seriesIndex = (focusedPoint.seriesIndex + direction * offset + seriesCount) % seriesCount; + const length = getSeriesLength(seriesLengths, seriesIndex); + + if (length > 0) { + return { + seriesIndex, + index: Math.min(focusedPoint.index, length - 1), + }; + } + } + + return focusedPoint; +} + +export function getChartA11ySeriesLengths(seriesData: readonly (readonly unknown[])[]) { + return seriesData.map((series) => series.length); +} + +export function focusChartA11yPoint( + state: ChartA11yKeyboardState, + seriesLengths: readonly number[], + point: ChartA11yPointFocus | null, +): ChartA11yKeyboardState { + return setFocusedPoint(state, clampPoint(seriesLengths, point)); +} + +export function transitionChartA11yKeyboardState( + state: ChartA11yKeyboardState, + seriesLengths: readonly number[], + intent: ChartA11yKeyboardIntent, +): ChartA11yKeyboardState { + if (getPointCount(seriesLengths) === 0) { + return initialChartA11yKeyboardState; + } + + if (intent === 'enter') { + return setFocusedPoint(state, clampPoint(seriesLengths, state.lastFocusedPoint)); + } + + if (intent === 'exit') { + return state.mode === 'chart' + ? state + : { + mode: 'chart', + focusedPoint: null, + lastFocusedPoint: state.focusedPoint ?? state.lastFocusedPoint, + }; + } + + if (state.mode !== 'data') { + return state; + } + + if (intent === 'nextPoint') { + return setFocusedPoint(state, movePoint(seriesLengths, state.focusedPoint, 1)); + } + + if (intent === 'previousPoint') { + return setFocusedPoint(state, movePoint(seriesLengths, state.focusedPoint, -1)); + } + + if (intent === 'nextSeries') { + return setFocusedPoint(state, moveSeries(seriesLengths, state.focusedPoint, 1)); + } + + if (intent === 'previousSeries') { + return setFocusedPoint(state, moveSeries(seriesLengths, state.focusedPoint, -1)); + } + + if (intent === 'firstPoint') { + const focusedPoint = clampPoint(seriesLengths, state.focusedPoint); + + return setFocusedPoint( + state, + focusedPoint ? { seriesIndex: focusedPoint.seriesIndex, index: 0 } : null, + ); + } + + if (intent === 'lastPoint') { + const focusedPoint = clampPoint(seriesLengths, state.focusedPoint); + const length = focusedPoint ? getSeriesLength(seriesLengths, focusedPoint.seriesIndex) : 0; + + return setFocusedPoint( + state, + focusedPoint ? { seriesIndex: focusedPoint.seriesIndex, index: length - 1 } : null, + ); + } + + if (intent === 'firstChartPoint') { + return setFocusedPoint(state, getFirstPoint(seriesLengths)); + } + + return setFocusedPoint(state, getLastPoint(seriesLengths)); +} From f437e4c1f6000c07ebb42b62b321afde58b5644a Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:44:03 -0400 Subject: [PATCH 09/17] feat(a11y): wire point focus navigation --- packages/visx-a11y/src/index.ts | 12 + packages/visx-a11y/src/useChartA11y.tsx | 83 +++--- packages/visx-a11y/src/useChartKeyboardNav.ts | 238 ++++++++++++++++++ packages/visx-a11y/test/index.test.ts | 2 + packages/visx-a11y/test/useChartA11y.test.tsx | 14 +- 5 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 packages/visx-a11y/src/useChartKeyboardNav.ts diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index 272880132b..8d7a903b52 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -11,16 +11,28 @@ export { getChartAriaProps } from './generators/ariaProps'; export { generateDataTableHTML } from './generators/dataTableHTML'; export { generateChartDescription } from './generators/description'; export { useChartA11y } from './useChartA11y'; +export { useChartKeyboardNav } from './useChartKeyboardNav'; export { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; export { ChartA11yDataTable } from './components/ChartA11yDataTable'; export type { + ChartA11yKeyboardIntent, + ChartA11yKeyboardState, ChartA11yMode, + ChartA11yPointFocus, +} from './keyboard/stateMachine'; +export type { UseChartA11yAnnouncerProps, UseChartA11yDataTableProps, UseChartA11yPointProps, UseChartA11yResult, UseChartA11ySvgProps, } from './useChartA11y'; +export type { + UseChartKeyboardNavConfig, + UseChartKeyboardNavPointProps, + UseChartKeyboardNavResult, + UseChartKeyboardNavSvgProps, +} from './useChartKeyboardNav'; export type { ChartA11yAnnouncerProps, ChartA11yLivePoliteness, diff --git a/packages/visx-a11y/src/useChartA11y.tsx b/packages/visx-a11y/src/useChartA11y.tsx index aae073056a..cbb6499eb7 100644 --- a/packages/visx-a11y/src/useChartA11y.tsx +++ b/packages/visx-a11y/src/useChartA11y.tsx @@ -1,5 +1,5 @@ -import type { ComponentType, KeyboardEvent, RefCallback } from 'react'; -import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import type { ComponentType } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; import type { ChartA11yAnnouncerProps } from './components/ChartA11yAnnouncer'; @@ -7,26 +7,25 @@ import { ChartA11yDataTable } from './components/ChartA11yDataTable'; import type { ChartA11yDataTableProps } from './components/ChartA11yDataTable'; import { getChartAriaProps } from './generators/ariaProps'; import { generateChartDescription } from './generators/description'; +import type { ChartA11yMode, ChartA11yPointFocus } from './keyboard/stateMachine'; import type { ChartA11yConfig, ChartA11yPointProps, ChartA11ySeriesProps, ChartA11ySvgProps, } from './types'; +import { useChartKeyboardNav } from './useChartKeyboardNav'; +import type { + UseChartKeyboardNavPointProps, + UseChartKeyboardNavSvgProps, +} from './useChartKeyboardNav'; import { DEFAULT_CHART_A11Y_ID_PREFIX } from './utils/data'; -export type ChartA11yMode = 'chart' | 'data'; +export type { ChartA11yMode } from './keyboard/stateMachine'; -export type UseChartA11ySvgProps = ChartA11ySvgProps & { - tabIndex?: 0; - onKeyDown?: (event: KeyboardEvent) => void; - ref: RefCallback; -}; +export type UseChartA11ySvgProps = ChartA11ySvgProps & UseChartKeyboardNavSvgProps; -export type UseChartA11yPointProps = ChartA11yPointProps & { - tabIndex: -1; - 'data-a11y-focused'?: boolean; -}; +export type UseChartA11yPointProps = ChartA11yPointProps & UseChartKeyboardNavPointProps; export type UseChartA11yDataTableProps = Pick< ChartA11yDataTableProps, @@ -45,7 +44,7 @@ export type UseChartA11yResult = { Announcer: ComponentType; announce: (message: string) => void; mode: ChartA11yMode; - focusedPoint: { seriesIndex: number; index: number } | null; + focusedPoint: ChartA11yPointFocus | null; }; function formatReactId(id: string) { @@ -54,45 +53,35 @@ function formatReactId(id: string) { export function useChartA11y(config: ChartA11yConfig): UseChartA11yResult { const reactId = useId(); - const svgRef = useRef(null); const [announcement, setAnnouncement] = useState(''); - const keyboardNavEnabled = config.keyboardNavEnabled ?? true; const id = config.id ?? `${config.idPrefix ?? DEFAULT_CHART_A11Y_ID_PREFIX}-${formatReactId(reactId)}`; const hookConfig = useMemo(() => ({ ...config, id }), [config, id]); const ariaProps = useMemo(() => getChartAriaProps(hookConfig), [hookConfig]); const description = useMemo(() => generateChartDescription(hookConfig), [hookConfig]); - const focusedPoint = null; - - const setSvgRef = useCallback>((node) => { - svgRef.current = node; - }, []); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === '?' || event.key === 'F1') { - setAnnouncement( - hookConfig.locale?.keyboardHelp ?? 'Keyboard navigation is not yet available.', - ); - } - }, - [hookConfig.locale], - ); - - const svgProps = useMemo(() => { - const interactionProps = keyboardNavEnabled - ? { - onKeyDown: handleKeyDown, - tabIndex: 0 as const, - } - : {}; + const keyboard = useChartKeyboardNav({ + data: hookConfig.data, + keyboardNavEnabled: hookConfig.keyboardNavEnabled, + locale: hookConfig.locale, + onKeyboardHelp: setAnnouncement, + onPointFocus: hookConfig.onPointFocus, + pointDescriptionThreshold: hookConfig.pointDescriptionThreshold, + series: hookConfig.series, + }); + const { + focusedPoint, + getPointProps: getKeyboardPointProps, + mode, + svgProps: keyboardSvgProps, + } = keyboard; - return { + const svgProps = useMemo( + () => ({ ...ariaProps.svg, - ...interactionProps, - ref: setSvgRef, - }; - }, [ariaProps.svg, handleKeyDown, keyboardNavEnabled, setSvgRef]); + ...keyboardSvgProps, + }), + [ariaProps.svg, keyboardSvgProps], + ); const getSeriesProps = useCallback( (seriesIndex: number) => @@ -114,10 +103,10 @@ export function useChartA11y(config: ChartA11yConfig): UseChartA11 'aria-roledescription': hookConfig.locale?.pointRoleDescription ?? 'data point', 'aria-label': '', }), - tabIndex: -1, + ...getKeyboardPointProps(seriesIndex, index), }; }, - [ariaProps.points, hookConfig.locale], + [ariaProps.points, getKeyboardPointProps, hookConfig.locale], ); const DataTable = useCallback( @@ -150,7 +139,7 @@ export function useChartA11y(config: ChartA11yConfig): UseChartA11 DataTable, Announcer, announce, - mode: 'chart', + mode, focusedPoint, }; } diff --git a/packages/visx-a11y/src/useChartKeyboardNav.ts b/packages/visx-a11y/src/useChartKeyboardNav.ts new file mode 100644 index 0000000000..d2f822e63c --- /dev/null +++ b/packages/visx-a11y/src/useChartKeyboardNav.ts @@ -0,0 +1,238 @@ +import type { KeyboardEvent, RefCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { ChartA11yConfig, ChartA11yFocusedPoint } from './types'; +import { DEFAULT_POINT_DESCRIPTION_THRESHOLD, normalizeChartA11yData } from './utils/data'; +import { + focusChartA11yPoint, + getChartA11ySeriesLengths, + initialChartA11yKeyboardState, + transitionChartA11yKeyboardState, +} from './keyboard/stateMachine'; +import type { + ChartA11yKeyboardIntent, + ChartA11yKeyboardState, + ChartA11yPointFocus, +} from './keyboard/stateMachine'; + +const defaultKeyboardHelp = + 'Press Enter to explore chart data, arrow keys to move, Home and End for boundaries, and Escape to exit.'; + +export type UseChartKeyboardNavConfig = Pick< + ChartA11yConfig, + 'data' | 'keyboardNavEnabled' | 'locale' | 'onPointFocus' | 'pointDescriptionThreshold' | 'series' +> & { + onKeyboardHelp?: (message: string) => void; +}; + +export type UseChartKeyboardNavSvgProps = { + tabIndex?: 0; + onKeyDown?: (event: KeyboardEvent) => void; + ref: RefCallback; +}; + +export type UseChartKeyboardNavPointProps = { + ref: RefCallback; + tabIndex: 0 | -1; + onFocus: () => void; + 'data-a11y-focused'?: boolean; +}; + +export type UseChartKeyboardNavResult = { + svgProps: UseChartKeyboardNavSvgProps; + getPointProps: (seriesIndex: number, index: number) => UseChartKeyboardNavPointProps; + mode: ChartA11yKeyboardState['mode']; + focusedPoint: ChartA11yPointFocus | null; + focusPoint: (point: ChartA11yPointFocus | null) => void; +}; + +function getPointKey({ seriesIndex, index }: ChartA11yPointFocus) { + return `${seriesIndex}:${index}`; +} + +function getKeyboardIntent( + event: KeyboardEvent, + mode: ChartA11yKeyboardState['mode'], +): ChartA11yKeyboardIntent | null { + if (mode === 'chart') { + return event.key === 'Enter' || event.key === ' ' ? 'enter' : null; + } + + if (event.key === 'ArrowRight') { + return 'nextPoint'; + } + + if (event.key === 'ArrowLeft') { + return 'previousPoint'; + } + + if (event.key === 'ArrowDown') { + return 'nextSeries'; + } + + if (event.key === 'ArrowUp') { + return 'previousSeries'; + } + + if (event.key === 'Home') { + return event.ctrlKey ? 'firstChartPoint' : 'firstPoint'; + } + + if (event.key === 'End') { + return event.ctrlKey ? 'lastChartPoint' : 'lastPoint'; + } + + if (event.key === 'Escape') { + return 'exit'; + } + + return null; +} + +function focusElement(element: SVGElement | null | undefined) { + const focusableElement = element as (SVGElement & { focus?: () => void }) | null | undefined; + + focusableElement?.focus?.(); +} + +export function useChartKeyboardNav({ + data, + keyboardNavEnabled = true, + locale, + onKeyboardHelp, + onPointFocus, + pointDescriptionThreshold = DEFAULT_POINT_DESCRIPTION_THRESHOLD, + series, +}: UseChartKeyboardNavConfig): UseChartKeyboardNavResult { + const svgRef = useRef(null); + const pointElementsRef = useRef(new Map()); + const normalized = useMemo(() => normalizeChartA11yData({ data, series }), [data, series]); + const seriesLengths = useMemo( + () => getChartA11ySeriesLengths(normalized.series.map(({ data: seriesData }) => seriesData)), + [normalized.series], + ); + const navigationEnabled = + keyboardNavEnabled && + normalized.pointCount > 0 && + normalized.pointCount <= pointDescriptionThreshold; + const [state, setState] = useState(initialChartA11yKeyboardState); + const { focusedPoint } = state; + + useEffect(() => { + if (!navigationEnabled) { + setState(initialChartA11yKeyboardState); + } + }, [navigationEnabled]); + + useEffect(() => { + if (!navigationEnabled || state.mode !== 'data' || !focusedPoint) { + return; + } + + const focusedElement = pointElementsRef.current.get(getPointKey(focusedPoint)); + const focusedSeries = normalized.series[focusedPoint.seriesIndex]; + const datum = focusedSeries?.data[focusedPoint.index]; + + focusElement(focusedElement); + + if (datum != null) { + onPointFocus?.({ + seriesIndex: focusedPoint.seriesIndex, + index: focusedPoint.index, + datum, + } satisfies ChartA11yFocusedPoint); + } + }, [focusedPoint, navigationEnabled, normalized.series, onPointFocus, state.mode]); + + const setSvgRef = useCallback>((node) => { + svgRef.current = node; + }, []); + + const focusPoint = useCallback( + (point: ChartA11yPointFocus | null) => { + if (!navigationEnabled) { + return; + } + + setState((currentState) => focusChartA11yPoint(currentState, seriesLengths, point)); + }, + [navigationEnabled, seriesLengths], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!navigationEnabled) { + return; + } + + if (event.key === '?' || event.key === 'F1') { + event.preventDefault(); + onKeyboardHelp?.(locale?.keyboardHelp ?? defaultKeyboardHelp); + return; + } + + const intent = getKeyboardIntent(event, state.mode); + + if (!intent) { + return; + } + + event.preventDefault(); + setState((currentState) => + transitionChartA11yKeyboardState(currentState, seriesLengths, intent), + ); + + if (intent === 'exit') { + svgRef.current?.focus(); + } + }, + [locale?.keyboardHelp, navigationEnabled, onKeyboardHelp, seriesLengths, state.mode], + ); + + const svgProps = useMemo( + () => ({ + ...(navigationEnabled + ? { + onKeyDown: handleKeyDown, + tabIndex: 0 as const, + } + : {}), + ref: setSvgRef, + }), + [handleKeyDown, navigationEnabled, setSvgRef], + ); + + const getPointProps = useCallback( + (seriesIndex: number, index: number): UseChartKeyboardNavPointProps => { + const point = { seriesIndex, index }; + const isFocused = + state.mode === 'data' && + focusedPoint?.seriesIndex === seriesIndex && + focusedPoint?.index === index; + + return { + ref: (node) => { + const key = getPointKey(point); + + if (node) { + pointElementsRef.current.set(key, node); + } else { + pointElementsRef.current.delete(key); + } + }, + tabIndex: isFocused ? 0 : -1, + onFocus: () => focusPoint(point), + 'data-a11y-focused': isFocused || undefined, + }; + }, + [focusPoint, focusedPoint, state.mode], + ); + + return { + svgProps, + getPointProps, + mode: state.mode, + focusedPoint, + focusPoint, + }; +} diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index ff808c609f..c324fc4bba 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -3,12 +3,14 @@ import { ChartA11yDataTable, normalizeChartA11yData, useChartA11y, + useChartKeyboardNav, } from '../src'; describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { expect(normalizeChartA11yData).toBeDefined(); expect(useChartA11y).toBeDefined(); + expect(useChartKeyboardNav).toBeDefined(); expect(ChartA11yAnnouncer).toBeDefined(); expect(ChartA11yDataTable).toBeDefined(); }); diff --git a/packages/visx-a11y/test/useChartA11y.test.tsx b/packages/visx-a11y/test/useChartA11y.test.tsx index 13ad0812bc..61f7aa5f24 100644 --- a/packages/visx-a11y/test/useChartA11y.test.tsx +++ b/packages/visx-a11y/test/useChartA11y.test.tsx @@ -49,12 +49,14 @@ describe('useChartA11y', () => { 'aria-roledescription': 'series', 'aria-label': 'Revenue', }); - expect(result.current.getPointProps(0, 1)).toEqual({ - role: 'graphics-symbol', - 'aria-roledescription': 'data point', - 'aria-label': 'Revenue, Feb, $25', - tabIndex: -1, - }); + expect(result.current.getPointProps(0, 1)).toEqual( + expect.objectContaining({ + role: 'graphics-symbol', + 'aria-roledescription': 'data point', + 'aria-label': 'Revenue, Feb, $25', + tabIndex: -1, + }), + ); expect(result.current.mode).toBe('chart'); expect(result.current.focusedPoint).toBeNull(); }); From 3d547a8a06ba7950e76eb3bc5af4ec3d7d5cdeac Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:46:34 -0400 Subject: [PATCH 10/17] test(a11y): cover keyboard chart navigation --- packages/visx-a11y/test/keyboard.test.tsx | 192 ++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 packages/visx-a11y/test/keyboard.test.tsx diff --git a/packages/visx-a11y/test/keyboard.test.tsx b/packages/visx-a11y/test/keyboard.test.tsx new file mode 100644 index 0000000000..586d57feee --- /dev/null +++ b/packages/visx-a11y/test/keyboard.test.tsx @@ -0,0 +1,192 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { normalizeChartA11yData, useChartA11y } from '../src'; +import type { ChartA11yConfig } from '../src'; + +type Datum = { + category: string; + value: number; +}; + +const monthlyData: Datum[] = [ + { category: 'Jan', value: 10 }, + { category: 'Feb', value: 25 }, + { category: 'Mar', value: 18 }, +]; + +function ChartHarness({ config }: { config: ChartA11yConfig }) { + const a11y = useChartA11y(config); + const normalized = normalizeChartA11yData(config); + + return ( + + {a11y.description} + {normalized.series.map((series, seriesIndex) => ( + + {series.data.map((datum, index) => ( + + ))} + + ))} + + ); +} + +function renderKeyboardChart(config: ChartA11yConfig) { + const view = render(); + const chart = screen.getByTestId('chart'); + + chart.focus(); + + return { ...view, chart }; +} + +function expectFocusedPoint(seriesIndex: number, index: number) { + const point = screen.getByTestId(`point-${seriesIndex}-${index}`); + + expect(point.getAttribute('data-a11y-focused')).toBe('true'); + expect(point.getAttribute('tabindex')).toBe('0'); + expect(document.activeElement).toBe(point); +} + +const lineConfig: ChartA11yConfig = { + id: 'line-chart', + title: 'Revenue', + chartType: 'line', + data: monthlyData, + x: (datum) => datum.category, + y: (datum) => datum.value, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], +}; + +describe('chart keyboard navigation', () => { + it('supports line chart point traversal and exiting data mode', () => { + expect.hasAssertions(); + + const onPointFocus = vi.fn(); + const { chart } = renderKeyboardChart({ ...lineConfig, onPointFocus }); + + fireEvent.keyDown(chart, { key: 'Enter' }); + + expectFocusedPoint(0, 0); + expect(onPointFocus).toHaveBeenLastCalledWith({ + seriesIndex: 0, + index: 0, + datum: monthlyData[0], + }); + + fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expectFocusedPoint(0, 1); + expect(onPointFocus).toHaveBeenLastCalledWith({ + seriesIndex: 0, + index: 1, + datum: monthlyData[1], + }); + + fireEvent.keyDown(chart, { key: 'End' }); + expectFocusedPoint(0, 2); + + fireEvent.keyDown(chart, { key: 'Home' }); + expectFocusedPoint(0, 0); + + fireEvent.keyDown(chart, { key: 'Escape' }); + expect(document.activeElement).toBe(chart); + expect(screen.getByTestId('point-0-0').getAttribute('data-a11y-focused')).toBeNull(); + }); + + it('wraps bar chart category focus with horizontal arrows', () => { + expect.hasAssertions(); + + const { chart } = renderKeyboardChart({ + ...lineConfig, + id: 'bar-chart', + title: 'Quarterly revenue', + chartType: 'bar', + }); + + fireEvent.keyDown(chart, { key: 'Enter' }); + fireEvent.keyDown(chart, { key: 'ArrowLeft' }); + expectFocusedPoint(0, 2); + + fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expectFocusedPoint(0, 0); + }); + + it('wraps pie slice focus and preserves point labels', () => { + expect.hasAssertions(); + + const { chart } = renderKeyboardChart({ + id: 'pie-chart', + title: 'Traffic share', + chartType: 'pie', + data: [ + { category: 'Organic', value: 60 }, + { category: 'Paid', value: 40 }, + ], + x: (datum) => datum.category, + y: (datum) => datum.value, + formatY: (value) => `${value}%`, + series: [{ label: 'Traffic' }], + }); + + fireEvent.keyDown(chart, { key: 'Enter' }); + expectFocusedPoint(0, 0); + expect(screen.getByTestId('point-0-0').getAttribute('aria-label')).toBe('Organic, 60% (60%)'); + + fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expectFocusedPoint(0, 1); + + fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expectFocusedPoint(0, 0); + }); + + it('moves across multi-series charts and supports chart boundary keys', () => { + expect.hasAssertions(); + + const desktopData = monthlyData; + const mobileData: Datum[] = [ + { category: 'Jan', value: 6 }, + { category: 'Feb', value: 15 }, + ]; + const onPointFocus = vi.fn(); + const { chart } = renderKeyboardChart({ + id: 'multi-series-chart', + title: 'Revenue by device', + chartType: 'line', + data: [desktopData, mobileData], + x: (datum) => datum.category, + y: (datum) => datum.value, + formatY: (value) => `$${value}`, + series: [{ label: 'Desktop' }, { label: 'Mobile' }], + onPointFocus, + }); + + fireEvent.keyDown(chart, { key: 'Enter' }); + fireEvent.keyDown(chart, { key: 'ArrowRight' }); + expectFocusedPoint(0, 1); + + fireEvent.keyDown(chart, { key: 'ArrowDown' }); + expectFocusedPoint(1, 1); + expect(onPointFocus).toHaveBeenLastCalledWith({ + seriesIndex: 1, + index: 1, + datum: mobileData[1], + }); + + fireEvent.keyDown(chart, { key: 'ArrowUp' }); + expectFocusedPoint(0, 1); + + fireEvent.keyDown(chart, { key: 'End', ctrlKey: true }); + expectFocusedPoint(1, 1); + + fireEvent.keyDown(chart, { key: 'Home', ctrlKey: true }); + expectFocusedPoint(0, 0); + }); +}); From f201e78b8b167c60cb8450f2d6eeac65f11029db Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:48:01 -0400 Subject: [PATCH 11/17] docs(a11y): document keyboard interaction model --- MIGRATION.md | 7 +-- packages/visx-a11y/Readme.md | 86 +++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 0e43d6b3f8..5502d42df6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -68,7 +68,8 @@ theming for primitive charts. visx 4.1 introduces `@visx/a11y`, an optional package for adding chart semantics to primitive SVG charts. It provides server-safe helpers for generated ARIA props, text descriptions, and hidden data -tables, plus a client hook for pre-bound chart props, data-table fallback, and live announcements. +tables, plus a client hook for pre-bound chart props, roving keyboard focus, data-table fallback, +and live announcements. These helpers do not replace the chart render tree or hide non-data elements automatically. If your chart includes decorative chrome such as grid lines, axis ticks, background rectangles, or clipping @@ -76,8 +77,8 @@ helpers, render those elements with `aria-hidden="true"` so assistive technology chart title, description, series, data marks, and table fallback. **What you need to do:** nothing unless you want to opt in. Existing visx charts continue to work. -Add `@visx/a11y` only when you want generated chart semantics or a data-table fallback for primitive -charts. +Add `@visx/a11y` only when you want generated chart semantics, keyboard exploration, or a data-table +fallback for primitive charts. ### Registry-oriented hooks for primitive charts diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 7a2bbcd74e..17eb755d8c 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -44,7 +44,7 @@ The a11y package is a semantics layer, not a chart framework. - Consumers own the SVG render tree, focus ring styling, and decorative element visibility. - Data table and announcer components are independently usable. - Dense charts degrade to summary descriptions above `pointDescriptionThreshold`. -- `keyboardNavEnabled` is part of the API, while full keyboard traversal is added in a follow-up. +- `keyboardNavEnabled` controls roving focus for point-by-point chart exploration. This keeps chart registry items and product charts able to opt in one layer at a time. If a chart needs custom descriptions, a visible data table, a separate live region, or no interactive keyboard @@ -188,6 +188,90 @@ return ( ); ``` +### Level 3: Keyboard navigation and tooltip bridge + +Keep `keyboardNavEnabled` enabled when a chart should support point-by-point keyboard exploration. +`useChartA11y` adds roving focus props to each data mark and calls `onPointFocus` when keyboard +focus moves to a point. Use that callback to mirror the same tooltip, crosshair, or active-point +state that pointer interaction uses. + +```tsx +const a11y = useChartA11y({ + id: 'revenue-chart', + title: 'Revenue by month', + chartType: 'line', + data, + x: (datum) => datum.month, + y: (datum) => datum.revenue, + yLabel: 'Revenue', + formatY: (value) => `$${value}`, + series: [{ label: 'Revenue' }], + onPointFocus: ({ datum, index, seriesIndex }) => { + showTooltip({ + datum, + index, + seriesIndex, + }); + }, +}); + +return ( + + {a11y.description} + + {data.map((datum, index) => ( + + ))} + + +); +``` + +Use `useChartKeyboardNav` directly when a custom chart already owns its generated ARIA semantics but +still wants the same roving-focus behavior, point focus state, and `onPointFocus` callback. + +## Keyboard interaction model + +`useChartA11y` starts in chart mode. The chart root is tabbable, and data marks become tabbable only +while keyboard exploration is active. Navigation is enabled by default for charts with at least one +point and no more than `pointDescriptionThreshold` points. + +| Key | Result | +| ------------------ | -------------------------------------------------------------------------------------------- | +| `Tab` | Moves focus to or away from the chart root through normal page tab order. | +| `Enter` or `Space` | Enters data mode and focuses the first data point, or the last focused point when returning. | +| `ArrowRight` | Moves to the next point in the current series, wrapping at the end. | +| `ArrowLeft` | Moves to the previous point in the current series, wrapping at the beginning. | +| `ArrowDown` | Moves to the next non-empty series and keeps the closest point index. | +| `ArrowUp` | Moves to the previous non-empty series and keeps the closest point index. | +| `Home` | Moves to the first point in the current series. | +| `End` | Moves to the last point in the current series. | +| `Control` + `Home` | Moves to the first point in the chart. | +| `Control` + `End` | Moves to the last point in the chart. | +| `Escape` | Exits data mode and returns focus to the chart root. | +| `?` or `F1` | Announces the configured keyboard help text through `Announcer`. | + +## Assistive technology validation + +Automated tests cover the keyboard state machine, roving-focus DOM props, generated labels, and the +`onPointFocus` bridge across line, bar, pie, and multi-series charts. Before release, validate the +same flows manually with representative assistive technology: + +- NVDA with Chrome +- JAWS with Chrome +- VoiceOver with Safari on macOS +- VoiceOver with Safari on iOS +- TalkBack with Chrome on Android +- Narrator with Edge + +The manual pass should verify tabbing to the chart, entering and exiting data mode, arrow navigation +through points, up/down navigation across multi-series charts, tooltip announcements triggered from +`onPointFocus`, and fallback table navigation. + ### Decorative chart chrome `@visx/a11y` annotates the chart elements it receives through the hook. Consumers are responsible From 3dd051bd8a1421c2ead70d63da0a6675030ad109 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:54:53 -0400 Subject: [PATCH 12/17] test(a11y): cover keyboard validation flows --- packages/visx-a11y/test/keyboard.test.tsx | 97 +++++++++++++++++++---- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/packages/visx-a11y/test/keyboard.test.tsx b/packages/visx-a11y/test/keyboard.test.tsx index 586d57feee..808c2b229e 100644 --- a/packages/visx-a11y/test/keyboard.test.tsx +++ b/packages/visx-a11y/test/keyboard.test.tsx @@ -15,30 +15,46 @@ const monthlyData: Datum[] = [ { category: 'Mar', value: 18 }, ]; -function ChartHarness({ config }: { config: ChartA11yConfig }) { +function ChartHarness({ + config, + includeAnnouncer = false, + includeDataTable = false, +}: { + config: ChartA11yConfig; + includeAnnouncer?: boolean; + includeDataTable?: boolean; +}) { const a11y = useChartA11y(config); const normalized = normalizeChartA11yData(config); + const { Announcer, DataTable } = a11y; return ( - - {a11y.description} - {normalized.series.map((series, seriesIndex) => ( - - {series.data.map((datum, index) => ( - - ))} - - ))} - + <> + + {a11y.description} + {normalized.series.map((series, seriesIndex) => ( + + {series.data.map((datum, index) => ( + + ))} + + ))} + + {includeDataTable ? : null} + {includeAnnouncer ? : null} + ); } -function renderKeyboardChart(config: ChartA11yConfig) { - const view = render(); +function renderKeyboardChart( + config: ChartA11yConfig, + options: Pick[0], 'includeAnnouncer' | 'includeDataTable'> = {}, +) { + const view = render(); const chart = screen.getByTestId('chart'); chart.focus(); @@ -67,6 +83,33 @@ const lineConfig: ChartA11yConfig = { }; describe('chart keyboard navigation', () => { + it('exposes a tabbable chart root and keyboard help announcement', () => { + expect.hasAssertions(); + + const { chart } = renderKeyboardChart( + { + ...lineConfig, + locale: { + keyboardHelp: 'Use arrow keys to inspect revenue points.', + }, + }, + { includeAnnouncer: true }, + ); + + expect(chart.getAttribute('tabindex')).toBe('0'); + expect(document.activeElement).toBe(chart); + + fireEvent.keyDown(chart, { key: '?' }); + expect(screen.getByRole('status').textContent).toBe( + 'Use arrow keys to inspect revenue points.', + ); + + fireEvent.keyDown(chart, { key: 'F1' }); + expect(screen.getByRole('status').textContent).toBe( + 'Use arrow keys to inspect revenue points.', + ); + }); + it('supports line chart point traversal and exiting data mode', () => { expect.hasAssertions(); @@ -189,4 +232,24 @@ describe('chart keyboard navigation', () => { fireEvent.keyDown(chart, { key: 'Home', ctrlKey: true }); expectFocusedPoint(0, 0); }); + + it('keeps the fallback table available alongside keyboard exploration', () => { + expect.hasAssertions(); + + const { chart, container } = renderKeyboardChart(lineConfig, { includeDataTable: true }); + const table = container.querySelector('table'); + + expect(table?.id).toBe('line-chart-table'); + expect(table?.querySelector('caption')?.textContent).toBe('Revenue'); + expect( + Array.from(table?.querySelectorAll('thead th') ?? []).map((cell) => cell.textContent), + ).toEqual(['Category', 'Revenue']); + expect( + Array.from(table?.querySelectorAll('tbody tr') ?? []).map((row) => row.textContent), + ).toEqual(['Jan$10', 'Feb$25', 'Mar$18']); + + fireEvent.keyDown(chart, { key: 'Enter' }); + expectFocusedPoint(0, 0); + expect(table?.querySelectorAll('tbody tr')).toHaveLength(3); + }); }); From c0ca57c8dd85e129967ac64c7c23983185ae3c2f Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 11:54:54 -0400 Subject: [PATCH 13/17] docs(a11y): remove manual assistive technology checklist --- packages/visx-a11y/Readme.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 17eb755d8c..9d15a57760 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -255,23 +255,6 @@ point and no more than `pointDescriptionThreshold` points. | `Escape` | Exits data mode and returns focus to the chart root. | | `?` or `F1` | Announces the configured keyboard help text through `Announcer`. | -## Assistive technology validation - -Automated tests cover the keyboard state machine, roving-focus DOM props, generated labels, and the -`onPointFocus` bridge across line, bar, pie, and multi-series charts. Before release, validate the -same flows manually with representative assistive technology: - -- NVDA with Chrome -- JAWS with Chrome -- VoiceOver with Safari on macOS -- VoiceOver with Safari on iOS -- TalkBack with Chrome on Android -- Narrator with Edge - -The manual pass should verify tabbing to the chart, entering and exiting data mode, arrow navigation -through points, up/down navigation across multi-series charts, tooltip announcements triggered from -`onPointFocus`, and fallback table navigation. - ### Decorative chart chrome `@visx/a11y` annotates the chart elements it receives through the hook. Consumers are responsible From 64a44acba5cdbb6c7e03fd1ebdaa895ccf9994af Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 12:44:31 -0400 Subject: [PATCH 14/17] fix(a11y): address keyboard review findings --- packages/visx-a11y/src/useChartA11y.tsx | 10 +++------- packages/visx-a11y/src/utils/data.ts | 19 +++++++++++++++---- packages/visx-a11y/test/data.test.ts | 14 ++++++++++++++ packages/visx-a11y/test/useChartA11y.test.tsx | 12 ++++++++++++ 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/visx-a11y/src/useChartA11y.tsx b/packages/visx-a11y/src/useChartA11y.tsx index cbb6499eb7..2817968f1b 100644 --- a/packages/visx-a11y/src/useChartA11y.tsx +++ b/packages/visx-a11y/src/useChartA11y.tsx @@ -25,7 +25,7 @@ export type { ChartA11yMode } from './keyboard/stateMachine'; export type UseChartA11ySvgProps = ChartA11ySvgProps & UseChartKeyboardNavSvgProps; -export type UseChartA11yPointProps = ChartA11yPointProps & UseChartKeyboardNavPointProps; +export type UseChartA11yPointProps = Partial & UseChartKeyboardNavPointProps; export type UseChartA11yDataTableProps = Pick< ChartA11yDataTableProps, @@ -98,15 +98,11 @@ export function useChartA11y(config: ChartA11yConfig): UseChartA11 const pointProps = ariaProps.points[seriesIndex]?.[index]; return { - ...(pointProps ?? { - role: 'graphics-symbol', - 'aria-roledescription': hookConfig.locale?.pointRoleDescription ?? 'data point', - 'aria-label': '', - }), + ...pointProps, ...getKeyboardPointProps(seriesIndex, index), }; }, - [ariaProps.points, getKeyboardPointProps, hookConfig.locale], + [ariaProps.points, getKeyboardPointProps], ); const DataTable = useCallback( diff --git a/packages/visx-a11y/src/utils/data.ts b/packages/visx-a11y/src/utils/data.ts index 48c334338a..83b9f19ac8 100644 --- a/packages/visx-a11y/src/utils/data.ts +++ b/packages/visx-a11y/src/utils/data.ts @@ -19,8 +19,19 @@ export type NormalizedChartA11yData = { function isNestedSeriesData( data: ChartA11yConfig['data'], + seriesConfig: readonly ChartA11ySeriesConfig[] | undefined, ): data is readonly (readonly Datum[])[] { - return Array.isArray(data[0]); + const firstDatum = data[0]; + + if (!Array.isArray(firstDatum)) { + return false; + } + + if ((seriesConfig?.length ?? 0) > 1) { + return true; + } + + return Array.isArray(firstDatum[0]); } function getSeriesLabel( @@ -45,10 +56,10 @@ export function normalizeChartA11yData({ }: Pick, 'data' | 'series'>): NormalizedChartA11yData { let seriesData: readonly (readonly Datum[])[]; - if (isNestedSeriesData(data)) { - seriesData = data; - } else if (seriesConfig?.some((series) => series.data)) { + if (seriesConfig?.some((series) => series.data)) { seriesData = seriesConfig.map((series) => series.data ?? []); + } else if (isNestedSeriesData(data, seriesConfig)) { + seriesData = data; } else { seriesData = [data]; } diff --git a/packages/visx-a11y/test/data.test.ts b/packages/visx-a11y/test/data.test.ts index e221b57645..d00ba6b62e 100644 --- a/packages/visx-a11y/test/data.test.ts +++ b/packages/visx-a11y/test/data.test.ts @@ -62,6 +62,20 @@ describe('normalizeChartA11yData', () => { maxSeriesLength: 2, }); }); + + it('keeps flat tuple data as one series', () => { + const tupleData: [number, number][] = [ + [0, 10], + [1, 20], + ]; + + expect(normalizeChartA11yData({ data: tupleData })).toEqual({ + series: [{ index: 0, label: 'Data', data: tupleData }], + isMultiSeries: false, + pointCount: 2, + maxSeriesLength: 2, + }); + }); }); describe('@visx/a11y constants', () => { diff --git a/packages/visx-a11y/test/useChartA11y.test.tsx b/packages/visx-a11y/test/useChartA11y.test.tsx index 61f7aa5f24..11b76b48bb 100644 --- a/packages/visx-a11y/test/useChartA11y.test.tsx +++ b/packages/visx-a11y/test/useChartA11y.test.tsx @@ -83,6 +83,18 @@ describe('useChartA11y', () => { expect(result.current.svgProps.onKeyDown).toBeUndefined(); }); + it('preserves omitted point semantics above the point description threshold', () => { + const { result } = renderHook(() => + useChartA11y({ ...lineConfig, pointDescriptionThreshold: 1 }), + ); + const pointProps = result.current.getPointProps(0, 0); + + expect(pointProps.role).toBeUndefined(); + expect(pointProps['aria-roledescription']).toBeUndefined(); + expect(pointProps['aria-label']).toBeUndefined(); + expect(pointProps.tabIndex).toBe(-1); + }); + it('returns a pre-bound data table component', () => { const { result } = renderHook(() => useChartA11y(lineConfig)); const { DataTable } = result.current; From 5e98b360fd52e81cc62f6ad9c71a9e882b1fdd73 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 12:51:27 -0400 Subject: [PATCH 15/17] fix(a11y): type y formatters as numeric --- packages/visx-a11y/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/visx-a11y/src/types.ts b/packages/visx-a11y/src/types.ts index e0530f7c60..a0cc5601b4 100644 --- a/packages/visx-a11y/src/types.ts +++ b/packages/visx-a11y/src/types.ts @@ -85,7 +85,7 @@ export type ChartA11yConfig = { description?: string; chartType?: ChartType; formatX?: ChartA11yFormatter; - formatY?: ChartA11yFormatter; + formatY?: ChartA11yFormatter; pointDescriptionThreshold?: number; dataTableEnabled?: boolean; keyboardNavEnabled?: boolean; From dde710671f1c8c74f5d04b4620dcb9dc0cc5adf4 Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 13:04:52 -0400 Subject: [PATCH 16/17] fix(a11y): update generated type refs --- packages/visx-a11y/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/visx-a11y/tsconfig.json b/packages/visx-a11y/tsconfig.json index 7712d9e02c..b2c72804cc 100644 --- a/packages/visx-a11y/tsconfig.json +++ b/packages/visx-a11y/tsconfig.json @@ -15,4 +15,4 @@ "../../types/**/*" ], "references": [] -} +} \ No newline at end of file From 95262837a1a447188095415b17c3be39bfce5ffd Mon Sep 17 00:00:00 2001 From: hshoff Date: Mon, 15 Jun 2026 13:25:13 -0400 Subject: [PATCH 17/17] fix(a11y): address copilot review feedback --- MIGRATION.md | 4 +- packages/visx-a11y/Readme.md | 8 +-- packages/visx-a11y/package.json | 5 ++ .../visx-a11y/src/generators/description.ts | 13 ++-- packages/visx-a11y/src/index.ts | 26 +------- packages/visx-a11y/src/react/index.ts | 59 +++++++++++++++++++ packages/visx-a11y/src/server.ts | 2 + packages/visx-a11y/src/types.ts | 16 ++++- packages/visx-a11y/src/utils/data.ts | 6 +- packages/visx-a11y/test/announcer.test.tsx | 2 +- packages/visx-a11y/test/dataTable.test.tsx | 2 +- packages/visx-a11y/test/generators.test.ts | 17 ++++++ packages/visx-a11y/test/index.test.ts | 22 ++++++- packages/visx-a11y/test/keyboard.test.tsx | 2 +- packages/visx-a11y/test/useChartA11y.test.tsx | 2 +- packages/visx-a11y/vitest.config.ts | 1 + 16 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 packages/visx-a11y/src/react/index.ts diff --git a/MIGRATION.md b/MIGRATION.md index 5502d42df6..984196b669 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -68,8 +68,8 @@ theming for primitive charts. visx 4.1 introduces `@visx/a11y`, an optional package for adding chart semantics to primitive SVG charts. It provides server-safe helpers for generated ARIA props, text descriptions, and hidden data -tables, plus a client hook for pre-bound chart props, roving keyboard focus, data-table fallback, -and live announcements. +tables, plus a `@visx/a11y/react` entry for pre-bound chart props, roving keyboard focus, data-table +fallback, and live announcements. These helpers do not replace the chart render tree or hide non-data elements automatically. If your chart includes decorative chrome such as grid lines, axis ticks, background rectangles, or clipping diff --git a/packages/visx-a11y/Readme.md b/packages/visx-a11y/Readme.md index 9d15a57760..7b1e99ba1b 100644 --- a/packages/visx-a11y/Readme.md +++ b/packages/visx-a11y/Readme.md @@ -1,8 +1,8 @@ # @visx/a11y Accessibility helpers for primitive visx charts without changing the low-level visx primitive model. -The package is split into a server-safe entry for generated semantics and a client root entry for -React hooks and companion components. +The package is split into server-safe entries for generated semantics and a client React entry for +hooks and companion components. ## Installation @@ -84,13 +84,13 @@ const description = generateChartDescription(config); const tableHtml = generateDataTableHTML(config); ``` -Use `useChartA11y` from the root entry when chart components need React-bound props and companion +Use `useChartA11y` from the React entry when chart components need React-bound props and companion components. ```tsx 'use client'; -import { useChartA11y } from '@visx/a11y'; +import { useChartA11y } from '@visx/a11y/react'; export function RevenueChart({ data }) { const a11y = useChartA11y({ diff --git a/packages/visx-a11y/package.json b/packages/visx-a11y/package.json index 8ae1523f80..fd14386a30 100644 --- a/packages/visx-a11y/package.json +++ b/packages/visx-a11y/package.json @@ -12,6 +12,11 @@ "import": "./esm/index.js", "require": "./lib/index.js" }, + "./react": { + "types": "./lib/react/index.d.ts", + "import": "./esm/react/index.js", + "require": "./lib/react/index.js" + }, "./server": { "types": "./lib/server.d.ts", "import": "./esm/server.js", diff --git a/packages/visx-a11y/src/generators/description.ts b/packages/visx-a11y/src/generators/description.ts index 35ebb2565a..56013d8299 100644 --- a/packages/visx-a11y/src/generators/description.ts +++ b/packages/visx-a11y/src/generators/description.ts @@ -34,10 +34,15 @@ function generateDenseDescription(config: ChartA11yConfig) { return `${getChartTypeLabel(config)} "${config.title}" has no numeric values.`; } - const values = points.map(({ y }) => y); - const min = Math.min(...values); - const max = Math.max(...values); - const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const { min, max, sum } = points.reduce( + (stats, { y }) => ({ + min: Math.min(stats.min, y), + max: Math.max(stats.max, y), + sum: stats.sum + y, + }), + { min: points[0].y, max: points[0].y, sum: 0 }, + ); + const mean = sum / points.length; return `${getChartTypeLabel(config)} of ${config.title}. ${ points.length diff --git a/packages/visx-a11y/src/index.ts b/packages/visx-a11y/src/index.ts index 8d7a903b52..a48577cad9 100644 --- a/packages/visx-a11y/src/index.ts +++ b/packages/visx-a11y/src/index.ts @@ -1,5 +1,3 @@ -'use client'; - // @visx/a11y export { DEFAULT_CHART_A11Y_ID_PREFIX, @@ -10,42 +8,22 @@ export { export { getChartAriaProps } from './generators/ariaProps'; export { generateDataTableHTML } from './generators/dataTableHTML'; export { generateChartDescription } from './generators/description'; -export { useChartA11y } from './useChartA11y'; -export { useChartKeyboardNav } from './useChartKeyboardNav'; -export { ChartA11yAnnouncer } from './components/ChartA11yAnnouncer'; -export { ChartA11yDataTable } from './components/ChartA11yDataTable'; export type { ChartA11yKeyboardIntent, ChartA11yKeyboardState, ChartA11yMode, ChartA11yPointFocus, } from './keyboard/stateMachine'; -export type { - UseChartA11yAnnouncerProps, - UseChartA11yDataTableProps, - UseChartA11yPointProps, - UseChartA11yResult, - UseChartA11ySvgProps, -} from './useChartA11y'; -export type { - UseChartKeyboardNavConfig, - UseChartKeyboardNavPointProps, - UseChartKeyboardNavResult, - UseChartKeyboardNavSvgProps, -} from './useChartKeyboardNav'; -export type { - ChartA11yAnnouncerProps, - ChartA11yLivePoliteness, -} from './components/ChartA11yAnnouncer'; -export type { ChartA11yDataTableProps } from './components/ChartA11yDataTable'; export type { A11yLocale, ChartA11yAccessor, ChartA11yConfig, + ChartA11yFlatConfig, ChartA11yFocusedPoint, ChartA11yFormatter, ChartA11yIds, + ChartA11yNestedConfig, ChartA11yPointProps, ChartA11yProps, ChartA11ySeriesProps, diff --git a/packages/visx-a11y/src/react/index.ts b/packages/visx-a11y/src/react/index.ts new file mode 100644 index 0000000000..d3d3f4c33c --- /dev/null +++ b/packages/visx-a11y/src/react/index.ts @@ -0,0 +1,59 @@ +'use client'; + +export { + DEFAULT_CHART_A11Y_ID_PREFIX, + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + DEFAULT_SINGLE_SERIES_LABEL, + generateChartDescription, + generateDataTableHTML, + getChartAriaProps, + normalizeChartA11yData, +} from '..'; +export { useChartA11y } from '../useChartA11y'; +export { useChartKeyboardNav } from '../useChartKeyboardNav'; +export { ChartA11yAnnouncer } from '../components/ChartA11yAnnouncer'; +export { ChartA11yDataTable } from '../components/ChartA11yDataTable'; + +export type { + ChartA11yKeyboardIntent, + ChartA11yKeyboardState, + ChartA11yMode, + ChartA11yPointFocus, +} from '../keyboard/stateMachine'; +export type { + UseChartA11yAnnouncerProps, + UseChartA11yDataTableProps, + UseChartA11yPointProps, + UseChartA11yResult, + UseChartA11ySvgProps, +} from '../useChartA11y'; +export type { + UseChartKeyboardNavConfig, + UseChartKeyboardNavPointProps, + UseChartKeyboardNavResult, + UseChartKeyboardNavSvgProps, +} from '../useChartKeyboardNav'; +export type { + ChartA11yAnnouncerProps, + ChartA11yLivePoliteness, +} from '../components/ChartA11yAnnouncer'; +export type { ChartA11yDataTableProps } from '../components/ChartA11yDataTable'; +export type { + A11yLocale, + ChartA11yAccessor, + ChartA11yConfig, + ChartA11yFlatConfig, + ChartA11yFocusedPoint, + ChartA11yFormatter, + ChartA11yIds, + ChartA11yNestedConfig, + ChartA11yPointProps, + ChartA11yProps, + ChartA11ySeriesProps, + ChartA11ySeriesConfig, + ChartA11ySvgProps, + ChartA11yValue, + ChartType, + NormalizedChartA11yData, + NormalizedChartA11ySeries, +} from '..'; diff --git a/packages/visx-a11y/src/server.ts b/packages/visx-a11y/src/server.ts index a5a47fea17..10c7870c60 100644 --- a/packages/visx-a11y/src/server.ts +++ b/packages/visx-a11y/src/server.ts @@ -13,9 +13,11 @@ export type { A11yLocale, ChartA11yAccessor, ChartA11yConfig, + ChartA11yFlatConfig, ChartA11yFocusedPoint, ChartA11yFormatter, ChartA11yIds, + ChartA11yNestedConfig, ChartA11yPointProps, ChartA11yProps, ChartA11ySeriesProps, diff --git a/packages/visx-a11y/src/types.ts b/packages/visx-a11y/src/types.ts index a0cc5601b4..d9bfdb16ff 100644 --- a/packages/visx-a11y/src/types.ts +++ b/packages/visx-a11y/src/types.ts @@ -70,16 +70,14 @@ export type A11yLocale = { keyboardHelp: string; }; -export type ChartA11yConfig = { +type ChartA11yBaseConfig = { /** Stable id for generated description, table, and live-region relationships. */ id?: string; /** Prefix used when an id is generated by client or server helpers. */ idPrefix?: string; - data: readonly Datum[] | readonly (readonly Datum[])[]; title: string; x: ChartA11yAccessor; y: ChartA11yAccessor; - series?: readonly ChartA11ySeriesConfig[]; xLabel?: string; yLabel?: string; description?: string; @@ -92,3 +90,15 @@ export type ChartA11yConfig = { onPointFocus?: (point: ChartA11yFocusedPoint) => void; locale?: Partial; }; + +export type ChartA11yFlatConfig = ChartA11yBaseConfig & { + data: readonly Datum[]; + series?: readonly ChartA11ySeriesConfig[]; +}; + +export type ChartA11yNestedConfig = ChartA11yBaseConfig & { + data: readonly (readonly Datum[])[]; + series: readonly [ChartA11ySeriesConfig, ...ChartA11ySeriesConfig[]]; +}; + +export type ChartA11yConfig = ChartA11yFlatConfig | ChartA11yNestedConfig; diff --git a/packages/visx-a11y/src/utils/data.ts b/packages/visx-a11y/src/utils/data.ts index 83b9f19ac8..a1df222c1b 100644 --- a/packages/visx-a11y/src/utils/data.ts +++ b/packages/visx-a11y/src/utils/data.ts @@ -27,11 +27,7 @@ function isNestedSeriesData( return false; } - if ((seriesConfig?.length ?? 0) > 1) { - return true; - } - - return Array.isArray(firstDatum[0]); + return (seriesConfig?.length ?? 0) > 1; } function getSeriesLabel( diff --git a/packages/visx-a11y/test/announcer.test.tsx b/packages/visx-a11y/test/announcer.test.tsx index 35259cbea3..bc54dc53c2 100644 --- a/packages/visx-a11y/test/announcer.test.tsx +++ b/packages/visx-a11y/test/announcer.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react'; import { renderToString } from 'react-dom/server'; -import { ChartA11yAnnouncer } from '../src'; +import { ChartA11yAnnouncer } from '../src/react'; describe('ChartA11yAnnouncer', () => { it('renders a visually hidden polite live region by default', () => { diff --git a/packages/visx-a11y/test/dataTable.test.tsx b/packages/visx-a11y/test/dataTable.test.tsx index 18b2a56ad0..2390b4ab12 100644 --- a/packages/visx-a11y/test/dataTable.test.tsx +++ b/packages/visx-a11y/test/dataTable.test.tsx @@ -1,8 +1,8 @@ import { render } from '@testing-library/react'; import { renderToString } from 'react-dom/server'; -import { ChartA11yDataTable } from '../src'; import type { ChartA11yConfig } from '../src'; +import { ChartA11yDataTable } from '../src/react'; type Datum = { label: string; diff --git a/packages/visx-a11y/test/generators.test.ts b/packages/visx-a11y/test/generators.test.ts index 0c1e4da73e..06f0ec2dd9 100644 --- a/packages/visx-a11y/test/generators.test.ts +++ b/packages/visx-a11y/test/generators.test.ts @@ -101,6 +101,23 @@ describe('generateChartDescription', () => { ); }); + it('summarizes large dense charts without spreading every value into Math.min or Math.max', () => { + const denseData = Array.from({ length: 10000 }, (_, index) => ({ + label: `Point ${index}`, + value: index, + })); + + expect( + generateChartDescription({ + ...lineConfig, + data: denseData, + pointDescriptionThreshold: 1, + }), + ).toBe( + 'line chart of Revenue. 10000 data points. Values range from $0 to $9999 with an average of $4999.5.', + ); + }); + it('summarizes pie charts by segment share', () => { expect( generateChartDescription({ diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts index c324fc4bba..b9c908eb78 100644 --- a/packages/visx-a11y/test/index.test.ts +++ b/packages/visx-a11y/test/index.test.ts @@ -1,17 +1,35 @@ +import { readFileSync } from 'fs'; + +import { generateChartDescription, normalizeChartA11yData } from '../src'; import { ChartA11yAnnouncer, ChartA11yDataTable, - normalizeChartA11yData, useChartA11y, useChartKeyboardNav, -} from '../src'; +} from '../src/react'; + +function readSource(path: string) { + return readFileSync(path, 'utf8'); +} describe('@visx/a11y scaffold', () => { it('exposes package entry points', () => { expect(normalizeChartA11yData).toBeDefined(); + expect(generateChartDescription).toBeDefined(); expect(useChartA11y).toBeDefined(); expect(useChartKeyboardNav).toBeDefined(); expect(ChartA11yAnnouncer).toBeDefined(); expect(ChartA11yDataTable).toBeDefined(); }); + + it('keeps the root entry server-safe and marks the react entry as client-only', () => { + const rootEntry = readSource('packages/visx-a11y/src/index.ts'); + const reactEntry = readSource('packages/visx-a11y/src/react/index.ts'); + + expect(rootEntry).not.toContain("'use client'"); + expect(rootEntry).not.toContain('"use client"'); + expect(rootEntry).not.toContain('useChartA11y'); + expect(rootEntry).not.toContain('ChartA11yAnnouncer'); + expect(reactEntry.trimStart().startsWith("'use client';")).toBe(true); + }); }); diff --git a/packages/visx-a11y/test/keyboard.test.tsx b/packages/visx-a11y/test/keyboard.test.tsx index 808c2b229e..68bd349c89 100644 --- a/packages/visx-a11y/test/keyboard.test.tsx +++ b/packages/visx-a11y/test/keyboard.test.tsx @@ -1,8 +1,8 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { vi } from 'vitest'; -import { normalizeChartA11yData, useChartA11y } from '../src'; import type { ChartA11yConfig } from '../src'; +import { normalizeChartA11yData, useChartA11y } from '../src/react'; type Datum = { category: string; diff --git a/packages/visx-a11y/test/useChartA11y.test.tsx b/packages/visx-a11y/test/useChartA11y.test.tsx index 11b76b48bb..e5afba6954 100644 --- a/packages/visx-a11y/test/useChartA11y.test.tsx +++ b/packages/visx-a11y/test/useChartA11y.test.tsx @@ -1,7 +1,7 @@ import { act, render, renderHook } from '@testing-library/react'; -import { useChartA11y } from '../src'; import type { ChartA11yConfig } from '../src'; +import { useChartA11y } from '../src/react'; type Datum = { label: string; diff --git a/packages/visx-a11y/vitest.config.ts b/packages/visx-a11y/vitest.config.ts index 4278752926..8495b5e611 100644 --- a/packages/visx-a11y/vitest.config.ts +++ b/packages/visx-a11y/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ resolve: { alias: { '@visx/a11y': path.resolve(__dirname, './src'), + '@visx/a11y/react': path.resolve(__dirname, './src/react'), '@visx/a11y/server': path.resolve(__dirname, './src/server.ts'), }, },