diff --git a/MIGRATION.md b/MIGRATION.md index 4dfd14fdf..984196b66 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -64,6 +64,22 @@ 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 `@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 +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, keyboard exploration, 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/.npmrc b/packages/visx-a11y/.npmrc new file mode 100644 index 000000000..43c97e719 --- /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 000000000..7b1e99ba1 --- /dev/null +++ b/packages/visx-a11y/Readme.md @@ -0,0 +1,273 @@ +# @visx/a11y + +Accessibility helpers for primitive visx charts without changing the low-level visx primitive model. +The package is split into server-safe entries for generated semantics and a client React entry for +hooks and companion components. + +## Installation + +```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 + +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` 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 +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/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); +``` + +Use `useChartA11y` from the React entry when chart components need React-bound props and companion +components. + +```tsx +'use client'; + +import { useChartA11y } from '@visx/a11y/react'; + +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({ + 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) => ( + + ))} + + + + + +); +``` + +### 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 ( + <> + + {a11y.description} + + {data.map((datum, index) => ( + + ))} + + + + + +); +``` + +### 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`. | + +### 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 */} + +``` diff --git a/packages/visx-a11y/package.json b/packages/visx-a11y/package.json new file mode 100644 index 000000000..fd14386a3 --- /dev/null +++ b/packages/visx-a11y/package.json @@ -0,0 +1,63 @@ +{ + "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" + }, + "./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", + "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/components/ChartA11yAnnouncer.tsx b/packages/visx-a11y/src/components/ChartA11yAnnouncer.tsx new file mode 100644 index 000000000..6c2ef8e37 --- /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/components/ChartA11yDataTable.tsx b/packages/visx-a11y/src/components/ChartA11yDataTable.tsx new file mode 100644 index 000000000..54b4aa432 --- /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 ( + + + + + {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/ariaProps.ts b/packages/visx-a11y/src/generators/ariaProps.ts new file mode 100644 index 000000000..6f9c8d9bf --- /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 000000000..55804c6d8 --- /dev/null +++ b/packages/visx-a11y/src/generators/dataTableHTML.ts @@ -0,0 +1,24 @@ +import type { ChartA11yConfig } from '../types'; +import { escapeHtml } from '../utils/format'; +import { getChartA11yTable, VISUALLY_HIDDEN_STYLE_STRING } from '../utils/table'; + +export const VISUALLY_HIDDEN_TABLE_STYLE = VISUALLY_HIDDEN_STYLE_STRING; + +export function generateDataTableHTML(config: ChartA11yConfig): string { + const table = getChartA11yTable(config); + const headerHtml = table.headers + .map((header) => `${escapeHtml(header)}`) + .join(''); + const rowHtml = table.rows + .map( + ([xCell, ...yCells]) => + `${escapeHtml(xCell)}${yCells + .map((cell) => `${escapeHtml(cell)}`) + .join('')}`, + ) + .join(''); + const tableIdHtml = escapeHtml(table.id); + const captionHtml = escapeHtml(table.caption); + + 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 000000000..56013d829 --- /dev/null +++ b/packages/visx-a11y/src/generators/description.ts @@ -0,0 +1,125 @@ +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 { 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 + } 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 new file mode 100644 index 000000000..a48577cad --- /dev/null +++ b/packages/visx-a11y/src/index.ts @@ -0,0 +1,35 @@ +// @visx/a11y +export { + DEFAULT_CHART_A11Y_ID_PREFIX, + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + 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 { + ChartA11yKeyboardIntent, + ChartA11yKeyboardState, + ChartA11yMode, + ChartA11yPointFocus, +} from './keyboard/stateMachine'; + +export type { + A11yLocale, + ChartA11yAccessor, + ChartA11yConfig, + ChartA11yFlatConfig, + ChartA11yFocusedPoint, + ChartA11yFormatter, + ChartA11yIds, + ChartA11yNestedConfig, + ChartA11yPointProps, + ChartA11yProps, + ChartA11ySeriesProps, + ChartA11ySeriesConfig, + ChartA11ySvgProps, + ChartA11yValue, + ChartType, +} from './types'; +export type { NormalizedChartA11yData, NormalizedChartA11ySeries } from './utils/data'; diff --git a/packages/visx-a11y/src/keyboard/stateMachine.ts b/packages/visx-a11y/src/keyboard/stateMachine.ts new file mode 100644 index 000000000..f92483529 --- /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)); +} diff --git a/packages/visx-a11y/src/react/index.ts b/packages/visx-a11y/src/react/index.ts new file mode 100644 index 000000000..d3d3f4c33 --- /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 new file mode 100644 index 000000000..10c7870c6 --- /dev/null +++ b/packages/visx-a11y/src/server.ts @@ -0,0 +1,29 @@ +// @visx/a11y/server +export { + DEFAULT_CHART_A11Y_ID_PREFIX, + DEFAULT_POINT_DESCRIPTION_THRESHOLD, + 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, + ChartA11yAccessor, + ChartA11yConfig, + ChartA11yFlatConfig, + ChartA11yFocusedPoint, + ChartA11yFormatter, + ChartA11yIds, + ChartA11yNestedConfig, + ChartA11yPointProps, + ChartA11yProps, + ChartA11ySeriesProps, + ChartA11ySeriesConfig, + ChartA11ySvgProps, + 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 000000000..d9bfdb16f --- /dev/null +++ b/packages/visx-a11y/src/types.ts @@ -0,0 +1,104 @@ +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 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; + pointRoleDescription: string; + chartTypes: Partial>; + dataTableCaption: (title: string) => string; + keyboardHelp: string; +}; + +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; + title: string; + x: ChartA11yAccessor; + y: ChartA11yAccessor; + xLabel?: string; + yLabel?: string; + description?: string; + chartType?: ChartType; + formatX?: ChartA11yFormatter; + formatY?: ChartA11yFormatter; + pointDescriptionThreshold?: number; + dataTableEnabled?: boolean; + keyboardNavEnabled?: boolean; + 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/useChartA11y.tsx b/packages/visx-a11y/src/useChartA11y.tsx new file mode 100644 index 000000000..2817968f1 --- /dev/null +++ b/packages/visx-a11y/src/useChartA11y.tsx @@ -0,0 +1,141 @@ +import type { ComponentType } from 'react'; +import { useCallback, useId, useMemo, 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 { 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 } from './keyboard/stateMachine'; + +export type UseChartA11ySvgProps = ChartA11ySvgProps & UseChartKeyboardNavSvgProps; + +export type UseChartA11yPointProps = Partial & UseChartKeyboardNavPointProps; + +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: ChartA11yPointFocus | null; +}; + +function formatReactId(id: string) { + return id.replace(/[^\w-]/g, '') || 'chart'; +} + +export function useChartA11y(config: ChartA11yConfig): UseChartA11yResult { + const reactId = useId(); + const [announcement, setAnnouncement] = useState(''); + 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 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; + + const svgProps = useMemo( + () => ({ + ...ariaProps.svg, + ...keyboardSvgProps, + }), + [ariaProps.svg, keyboardSvgProps], + ); + + 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, + ...getKeyboardPointProps(seriesIndex, index), + }; + }, + [ariaProps.points, getKeyboardPointProps], + ); + + 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, + focusedPoint, + }; +} diff --git a/packages/visx-a11y/src/useChartKeyboardNav.ts b/packages/visx-a11y/src/useChartKeyboardNav.ts new file mode 100644 index 000000000..d2f822e63 --- /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/src/utils/data.ts b/packages/visx-a11y/src/utils/data.ts new file mode 100644 index 000000000..a1df222c1 --- /dev/null +++ b/packages/visx-a11y/src/utils/data.ts @@ -0,0 +1,80 @@ +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'], + seriesConfig: readonly ChartA11ySeriesConfig[] | undefined, +): data is readonly (readonly Datum[])[] { + const firstDatum = data[0]; + + if (!Array.isArray(firstDatum)) { + return false; + } + + return (seriesConfig?.length ?? 0) > 1; +} + +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 (seriesConfig?.some((series) => series.data)) { + seriesData = seriesConfig.map((series) => series.data ?? []); + } else if (isNestedSeriesData(data, seriesConfig)) { + seriesData = 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/src/utils/format.ts b/packages/visx-a11y/src/utils/format.ts new file mode 100644 index 000000000..308ae48fb --- /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/src/utils/table.ts b/packages/visx-a11y/src/utils/table.ts new file mode 100644 index 000000000..e31758092 --- /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/announcer.test.tsx b/packages/visx-a11y/test/announcer.test.tsx new file mode 100644 index 000000000..bc54dc53c --- /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/react'; + +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/data.test.ts b/packages/visx-a11y/test/data.test.ts new file mode 100644 index 000000000..d00ba6b62 --- /dev/null +++ b/packages/visx-a11y/test/data.test.ts @@ -0,0 +1,86 @@ +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, + }); + }); + + 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', () => { + 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/dataTable.test.tsx b/packages/visx-a11y/test/dataTable.test.tsx new file mode 100644 index 000000000..2390b4ab1 --- /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 type { ChartA11yConfig } from '../src'; +import { ChartA11yDataTable } from '../src/react'; + +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/generators.test.ts b/packages/visx-a11y/test/generators.test.ts new file mode 100644 index 000000000..06f0ec2dd --- /dev/null +++ b/packages/visx-a11y/test/generators.test.ts @@ -0,0 +1,159 @@ +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 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({ + ...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(''); + }); +}); diff --git a/packages/visx-a11y/test/index.test.ts b/packages/visx-a11y/test/index.test.ts new file mode 100644 index 000000000..b9c908eb7 --- /dev/null +++ b/packages/visx-a11y/test/index.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'fs'; + +import { generateChartDescription, normalizeChartA11yData } from '../src'; +import { + ChartA11yAnnouncer, + ChartA11yDataTable, + useChartA11y, + useChartKeyboardNav, +} 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 new file mode 100644 index 000000000..68bd349c8 --- /dev/null +++ b/packages/visx-a11y/test/keyboard.test.tsx @@ -0,0 +1,255 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import type { ChartA11yConfig } from '../src'; +import { normalizeChartA11yData, useChartA11y } from '../src/react'; + +type Datum = { + category: string; + value: number; +}; + +const monthlyData: Datum[] = [ + { category: 'Jan', value: 10 }, + { category: 'Feb', value: 25 }, + { category: 'Mar', value: 18 }, +]; + +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) => ( + + ))} + + ))} + + {includeDataTable ? : null} + {includeAnnouncer ? : null} + + ); +} + +function renderKeyboardChart( + config: ChartA11yConfig, + options: Pick[0], 'includeAnnouncer' | 'includeDataTable'> = {}, +) { + 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('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(); + + 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); + }); + + 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); + }); +}); diff --git a/packages/visx-a11y/test/useChartA11y.test.tsx b/packages/visx-a11y/test/useChartA11y.test.tsx new file mode 100644 index 000000000..e5afba695 --- /dev/null +++ b/packages/visx-a11y/test/useChartA11y.test.tsx @@ -0,0 +1,132 @@ +import { act, render, renderHook } from '@testing-library/react'; + +import type { ChartA11yConfig } from '../src'; +import { useChartA11y } from '../src/react'; + +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( + 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(); + }); + + 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('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; + 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'); + }); +}); diff --git a/packages/visx-a11y/tsconfig.json b/packages/visx-a11y/tsconfig.json new file mode 100644 index 000000000..b2c72804c --- /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": [] +} \ No newline at end of file diff --git a/packages/visx-a11y/vitest.config.ts b/packages/visx-a11y/vitest.config.ts new file mode 100644 index 000000000..8495b5e61 --- /dev/null +++ b/packages/visx-a11y/vitest.config.ts @@ -0,0 +1,25 @@ +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/react': path.resolve(__dirname, './src/react'), + '@visx/a11y/server': path.resolve(__dirname, './src/server.ts'), + }, + }, +}); diff --git a/packages/visx-visx/package.json b/packages/visx-visx/package.json index 2958c8dbd..9bc9c5adc 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 381f1d7ae..15d6b0d1a 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 5def37d19..df7010699 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 6173d685e..543da00e7 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 a87fc0f89..9b156eea1 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:*"
RevenueJan & Feb$10DesktopMobileJan$10$8Feb$25