diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index 0b60b7b1d..eaf58ccb1 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -129,6 +129,11 @@ export const SELECTED_SERIES = 'selectedSeries'; // series export const SELECTED_GROUP = 'selectedGroup'; // data point export const FIRST_RSC_SERIES_ID = 'firstRscSeriesId'; // first series for dual y-axis export const LAST_RSC_SERIES_ID = 'lastRscSeriesId'; // last series for dual y-axis +export const FOCUSED_ITEM = 'focusedItem'; // data point focused via keyboard navigation (data-navigator) +export const FOCUSED_REGION = 'focusedRegion'; // chart region focused via keyboard navigation (data-navigator) +export const FOCUSED_DIMENSION = 'focusedDimension'; // dimension group (e.g. a whole stack) focused via keyboard navigation +/** Separator joining fields into a unique data-navigator node id (e.g. stacked segment = dimension + series). */ +export const NAVIGATION_ID_SEPARATOR = '__rsc__'; // scale names export const COLOR_SCALE = 'color'; diff --git a/packages/react-spectrum-charts-s2/package.json b/packages/react-spectrum-charts-s2/package.json index 1ca225023..14fc9f8b5 100644 --- a/packages/react-spectrum-charts-s2/package.json +++ b/packages/react-spectrum-charts-s2/package.json @@ -82,6 +82,7 @@ "@spectrum-charts/utils": "1.43.0", "@spectrum-charts/vega-spec-builder-s2": "0.2.0", "d3-format": "^3.1.0", + "data-navigator": "^2.4.1", "deepmerge": ">= 4.0.0", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", diff --git a/packages/react-spectrum-charts-s2/src/RscChart.tsx b/packages/react-spectrum-charts-s2/src/RscChart.tsx index 8863a8102..0b77069b2 100644 --- a/packages/react-spectrum-charts-s2/src/RscChart.tsx +++ b/packages/react-spectrum-charts-s2/src/RscChart.tsx @@ -9,15 +9,17 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { CSSProperties, RefObject, Ref, useCallback, useEffect, useMemo, useState } from 'react'; +import { CSSProperties, RefObject, Ref, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Popover } from '@react-spectrum/s2'; import { View as VegaView } from 'vega'; import { COMPONENT_NAME, DEFAULT_SYMBOL_SHAPES, DEFAULT_SYMBOL_SIZES } from '@spectrum-charts/constants'; -import { ChartHandle, Datum, SymbolSize, getChartConfig } from '@spectrum-charts/vega-spec-builder-s2'; +import { ChartHandle, Datum, SimpleData, SymbolSize, getChartConfig } from '@spectrum-charts/vega-spec-builder-s2'; import './Chart.css'; import { VegaChart } from './VegaChart'; +import { Navigator } from './dataNavigator/Navigator'; +import { getNavigableChartType } from './dataNavigator/navigableMarks'; import { useChartContext } from './context/RscChartContext'; import useChartImperativeHandle from './hooks/useChartImperativeHandle'; import { useChartInteractions } from './hooks/useChartInteractions'; @@ -37,6 +39,7 @@ interface ChartDialogProps { export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref }) => { const { + accessibleNavigation, backgroundColor, data, chartWidth, @@ -69,6 +72,7 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref(null); + const navChild = sanitizedChildren.find( + (child) => 'displayName' in child.type && getNavigableChartType(child.type.displayName) + ); + const navChartType = + navChild && 'displayName' in navChild.type ? getNavigableChartType(navChild.type.displayName) : undefined; + const navFields = navChild?.props as { dimension?: string; metric?: string; color?: unknown } | undefined; + const navColor = typeof navFields?.color === 'string' ? navFields.color : undefined; + + const getView = useCallback(() => chartView.current ?? undefined, [chartView]); + return ( <>
- +
+ + {accessibleNavigation && navChartType && ( + + )} +
{popovers.map((popover) => ( ({ signal: jest.fn(), runAsync: jest.fn() }) as unknown as View; + +const Harness = ({ chartData }: { chartData: SimpleData[] }): ReactElement => { + const ref = useRef(null); + return ( +
+ +
+ ); +}; + +describe('Navigator', () => { + test('renders no DOM of its own', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test('attaches the data-navigator entry button into the container', () => { + const { getByTestId } = render(); + expect(getByTestId('dn-container').querySelector('button')).toBeTruthy(); + }); + + test('does not attach navigation when there is no data', () => { + const { getByTestId } = render(); + expect(getByTestId('dn-container').querySelector('button')).toBeFalsy(); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/Navigator.tsx b/packages/react-spectrum-charts-s2/src/dataNavigator/Navigator.tsx new file mode 100644 index 000000000..9ea8fde42 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/Navigator.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { RefObject, useEffect } from 'react'; + +import { View } from 'vega'; + +import { SimpleData } from '@spectrum-charts/vega-spec-builder-s2'; + +import { NavigableChartType } from './buildChartStructure'; +import { attachDataNavigator } from './dataNavigatorAdapter'; + +export interface NavigatorProps { + /** The chart type to build navigation for. */ + chartType: NavigableChartType; + /** The chart data (plain objects). */ + data: SimpleData[]; + /** Primary categorical / x-axis field. */ + dimension?: string; + /** Series / color field (set for stacked bars). */ + color?: string; + /** Primary metric / y-axis field. */ + metric?: string; + /** Optional chart title for the accessible description. */ + title?: string; + /** Ref to the positioned container that wraps the chart. */ + containerRef: RefObject; + /** Stable id used to namespace the rendered nav elements. */ + chartId: string; + /** Accessor for the live Vega view. */ + getView: () => View | undefined; +} + +export const Navigator = ({ + chartType, + data, + dimension, + color, + metric, + title, + containerRef, + chartId, + getView, +}: NavigatorProps): null => { + useEffect(() => { + const container = containerRef.current; + if (!container || data.length === 0) { + return; + } + attachDataNavigator({ container, chartType, data, dimension, color, metric, title, chartId, getView }); + }, [chartType, data, dimension, color, metric, title, chartId, containerRef, getView]); + + return null; +}; +Navigator.displayName = 'Navigator'; diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.test.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.test.ts new file mode 100644 index 000000000..71f389a2d --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { NodeObject } from 'data-navigator'; + +import { buildBarStructure, buildChartDescription, buildNodeLabel, segmentId } from './buildBarStructure'; + +const data = [ + { browser: 'Chrome', downloads: 27000 }, + { browser: 'Firefox', downloads: 8000 }, + { browser: 'Safari', downloads: 4000 }, +]; + +const stackedData = [ + { browser: 'Chrome', os: 'Windows', downloads: 18000 }, + { browser: 'Chrome', os: 'Mac', downloads: 9000 }, + { browser: 'Firefox', os: 'Windows', downloads: 5000 }, + { browser: 'Firefox', os: 'Mac', downloads: 3000 }, +]; + +describe('buildBarStructure()', () => { + test('keys leaf nodes by the dimension value', () => { + const { structure } = buildBarStructure({ data, dimension: 'browser' }); + expect(structure.nodes.Chrome).toBeDefined(); + expect(structure.nodes.Chrome.data).toHaveProperty('browser', 'Chrome'); + expect(structure.nodes.Firefox).toBeDefined(); + expect(structure.nodes.Safari).toBeDefined(); + }); + + test('returns the dimension root as the entry point', () => { + const { structure, entryPoint } = buildBarStructure({ data, dimension: 'browser' }); + expect(entryPoint).toBeDefined(); + expect(structure.nodes[entryPoint as string]).toBeDefined(); + // dimension root nodes carry a dimensionLevel; leaf nodes do not + expect(structure.nodes[entryPoint as string].dimensionLevel).not.toBeUndefined(); + }); + + test('sets a chart description on the entry point node', () => { + const { structure, entryPoint } = buildBarStructure({ data, dimension: 'browser', title: 'Browser downloads' }); + expect(structure.nodes[entryPoint as string].semantics?.label).toContain('Browser downloads'); + expect(structure.nodes[entryPoint as string].semantics?.label).toContain('3 bars'); + }); + + test('ensures every node has a semantics label', () => { + const { structure } = buildBarStructure({ data, dimension: 'browser' }); + Object.values(structure.nodes).forEach((node) => { + expect(node.semantics?.label).toBeTruthy(); + }); + }); + + describe('stacked (color series present)', () => { + test('keys leaf segments by the dimension + series composite', () => { + const { structure } = buildBarStructure({ data: stackedData, dimension: 'browser', color: 'os' }); + expect(structure.nodes[segmentId('Chrome', 'Windows')]).toBeDefined(); + expect(structure.nodes[segmentId('Chrome', 'Mac')]).toBeDefined(); + expect(structure.nodes[segmentId('Firefox', 'Mac')]).toBeDefined(); + }); + + test('keeps one division per column (not compressed), each with multiple segments', () => { + const { structure } = buildBarStructure({ data: stackedData, dimension: 'browser', color: 'os' }); + // dimensionLevel === 2 are division (per-stack) nodes; basic bars compress these away + const divisions = Object.values(structure.nodes).filter((node) => node.dimensionLevel === 2); + expect(divisions).toHaveLength(2); // Chrome, Firefox + }); + }); +}); + +describe('buildChartDescription()', () => { + test('describes a basic bar chart and pluralizes the bar count', () => { + const label = buildChartDescription(data, 'browser'); + expect(label).toContain('Bar chart'); + expect(label).toContain('3 bars'); + }); + + test('uses the singular form for a single bar', () => { + expect(buildChartDescription([{ browser: 'Chrome' }], 'browser')).toContain('1 bar.'); + }); + + test('describes a stacked bar chart when a series field is present', () => { + const label = buildChartDescription(stackedData, 'browser', 'os', 'My title'); + expect(label).toContain('My title'); + expect(label).toContain('Stacked bar chart'); + expect(label).toContain('stacked by os'); + expect(label).toContain('2 stacks'); + }); +}); + +describe('buildNodeLabel()', () => { + test('falls back to the node id when there is no data', () => { + expect(buildNodeLabel({ id: 'lonely' } as NodeObject)).toBe('lonely'); + }); + + test('describes a dimension node by its division count', () => { + const node = { id: 'browser', data: { dimensionKey: 'browser', divisions: { a: {}, b: {} } } } as unknown as NodeObject; + expect(buildNodeLabel(node)).toBe('browser dimension. Contains 2 divisions.'); + }); + + test('describes a division (stack) node by its child bar count', () => { + const node = { id: 'Chrome', data: { values: { x: {}, y: {}, z: {} } } } as unknown as NodeObject; + expect(buildNodeLabel(node)).toBe('Chrome. Contains 3 bars.'); + }); + + test('describes a leaf node by its scalar fields', () => { + const node = { id: 'Chrome', data: { browser: 'Chrome', downloads: 27000, _dnId: 'skip-me' } } as unknown as NodeObject; + const label = buildNodeLabel(node); + expect(label).toContain('browser: Chrome'); + expect(label).toContain('downloads: 27000'); + expect(label).not.toContain('_dnId'); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.ts new file mode 100644 index 000000000..3528d84c3 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/buildBarStructure.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import dataNavigator, { NavigationRules, NodeObject, Structure, StructureOptions } from 'data-navigator'; + +import { DEFAULT_CATEGORICAL_DIMENSION, NAVIGATION_ID_SEPARATOR } from '@spectrum-charts/constants'; +import { SimpleData } from '@spectrum-charts/vega-spec-builder-s2'; + +export interface BuildBarStructureOptions { + /** The chart data (plain objects). */ + data: SimpleData[]; + /** The bar's category field (the stack/column for a stacked bar). Defaults to the standard categorical dimension. */ + dimension?: string; + /** The series/color field. When set, the bar is multi-series (each column holds multiple segments). */ + color?: string; + /** Optional chart title used to open the accessible description. */ + title?: string; +} + +/** Data field that carries the composite leaf id for multi-series (stacked/dodged) bars. */ +const SEGMENT_ID_KEY = '_dnId'; + +export const segmentId = (dimensionValue: unknown, seriesValue: unknown): string => + `${dimensionValue}${NAVIGATION_ID_SEPARATOR}${seriesValue}`; + +export interface BarStructure { + structure: Structure; + entryPoint: string | undefined; +} + +/** + * The keyboard navigation rules for a basic bar chart: left/right between bars, + * Enter to drill in, Escape to drill out (and exit once past the chart root). + */ +const baseNavigationRules: NavigationRules = { + left: { key: 'ArrowLeft', direction: 'source' }, + right: { key: 'ArrowRight', direction: 'target' }, + child: { key: 'Enter', direction: 'target' }, + parent: { key: 'Escape', direction: 'source' }, +}; + +export const buildBarStructure = ({ + data, + dimension = DEFAULT_CATEGORICAL_DIMENSION, + color, + title, +}: BuildBarStructureOptions): BarStructure => { + const isMultiSeries = color !== undefined; + const idKey = isMultiSeries ? SEGMENT_ID_KEY : dimension; + const structureData = color + ? data.map((d) => ({ ...d, [SEGMENT_ID_KEY]: segmentId(d[dimension], d[color]) })) + : data; + + const structureOptions: StructureOptions = { + data: structureData, + idKey, + navigationRules: baseNavigationRules, + dimensions: { + values: [ + { + dimensionKey: dimension, + type: 'categorical', + behavior: isMultiSeries ? { extents: 'bridgedCousins' } : { extents: 'circular' }, + operations: { compressSparseDivisions: !isMultiSeries }, + navigationRules: { + sibling_sibling: ['left', 'right'], + parent_child: ['parent', 'child'], + }, + }, + ], + }, + }; + + const structure = dataNavigator.structure(structureOptions); + + let entryPoint: string | undefined; + if (structure.dimensions) { + const firstKey = Object.keys(structure.dimensions)[0]; + const rootNodeId = structure.dimensions[firstKey]?.nodeId; + entryPoint = rootNodeId; + const rootNode = rootNodeId ? structure.nodes[rootNodeId] : undefined; + if (rootNode) { + rootNode.semantics = { label: buildChartDescription(data, dimension, color, title) }; + } + } + + // Every node rendered in keyboard mode needs an aria-label. + prepareNodeSemantics(structure); + + return { structure, entryPoint }; +}; + +// Placeholder for initial implementation +export const buildChartDescription = (data: SimpleData[], dimension: string, color?: string, title?: string): string => { + const count = new Set(data.map((d) => d[dimension])).size; + const opening = title ? `${title}. ` : ''; + if (color) { + return `${opening}Stacked bar chart. ${dimension} along the category axis, stacked by ${color}. Contains ${count} stack${count === 1 ? '' : 's'}. Use the left and right arrow keys to move between stacks and Enter to drill into a stack's segments.`; + } return `${opening}Bar chart. ${dimension} along the category axis. Contains ${count} bar${count === 1 ? '' : 's'}. Use the left and right arrow keys to navigate.`; +}; + +// Placeholder for initial implementation +export const buildNodeLabel = (node: NodeObject): string => { + const data = node.data as Record | undefined; + if (!data) return String(node.id); + + if (typeof data.dimensionKey === 'string' && data.divisions != null) { + const divisionCount = Object.keys(data.divisions).length; + return `${data.dimensionKey} dimension. Contains ${divisionCount} division${divisionCount === 1 ? '' : 's'}.`; + } + + if (data.values != null && typeof data.values === 'object' && !Array.isArray(data.values)) { + const childCount = Object.keys(data.values).length; + return `${String(node.id)}. Contains ${childCount} bar${childCount === 1 ? '' : 's'}.`; + } + + const parts = Object.entries(data) + .filter(([key, value]) => !key.startsWith('_') && value != null && typeof value !== 'object' && typeof value !== 'function') + .map(([key, value]) => `${key}: ${value}`); + return parts.length > 0 ? `${parts.join('. ')}.` : String(node.id); +}; + +export const prepareNodeSemantics = (structure: Structure): void => { + for (const node of Object.values(structure.nodes)) { + if (!node.semantics?.label) { + node.semantics = { ...node.semantics, label: buildNodeLabel(node) }; + } + } +}; diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.test.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.test.ts new file mode 100644 index 000000000..324f42565 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { buildBarStructure } from './buildBarStructure'; +import { buildChartStructure } from './buildChartStructure'; +import { getNavigableChartType } from './navigableMarks'; + +const data = [ + { browser: 'Chrome', downloads: 27000 }, + { browser: 'Firefox', downloads: 8000 }, + { browser: 'Safari', downloads: 4000 }, +]; + +describe('getNavigableChartType()', () => { + test('resolves the Bar mark to the bar chart type', () => { + expect(getNavigableChartType('Bar')).toBe('bar'); + }); + test('returns undefined for non-navigable marks', () => { + expect(getNavigableChartType('Axis')).toBeUndefined(); + expect(getNavigableChartType('Line')).toBeUndefined(); + }); + test('returns undefined when there is no displayName', () => { + expect(getNavigableChartType(undefined)).toBeUndefined(); + }); +}); + +describe('buildChartStructure()', () => { + test('delegates the bar chart type to buildBarStructure', () => { + const viaDispatch = buildChartStructure({ chartType: 'bar', data, dimension: 'browser' }); + const direct = buildBarStructure({ data, dimension: 'browser' }); + + expect(viaDispatch).toBeDefined(); + expect(viaDispatch?.entryPoint).toBe(direct.entryPoint); + expect(Object.keys(viaDispatch?.structure.nodes ?? {}).sort()).toEqual( + Object.keys(direct.structure.nodes).sort() + ); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.ts new file mode 100644 index 000000000..6429cde07 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/buildChartStructure.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Structure } from 'data-navigator'; + +import { SimpleData } from '@spectrum-charts/vega-spec-builder-s2'; + +import { buildBarStructure } from './buildBarStructure'; + +export type NavigableChartType = 'bar'; + +export interface ChartStructureOptions { + /** The chart type to build a navigation structure for. */ + chartType: NavigableChartType; + /** Chart data (plain objects). */ + data: SimpleData[]; + /** Primary categorical / x-axis field (e.g. bar category). */ + dimension?: string; + /** Series / color field. When set on a bar, the chart is stacked. */ + color?: string; + /** Primary metric / y-axis field. */ + metric?: string; + /** Optional chart title for the accessible description. */ + title?: string; +} + +export interface ChartStructure { + structure: Structure; + entryPoint: string | undefined; +} + +const structureBuilders: Record ChartStructure> = { + bar: buildBarStructure, +}; + +export const buildChartStructure = (options: ChartStructureOptions): ChartStructure | undefined => + structureBuilders[options.chartType]?.(options); diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigator.css b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigator.css new file mode 100644 index 000000000..4900b0069 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigator.css @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* + data-navigator keyboard navigation elements. + The visible focus indicator is drawn on the Vega canvas (focus-ring marks), so these DOM + elements are invisible overlays that exist only for keyboard focus and assistive technology. +*/ +.dn-wrapper { + position: absolute; + top: 0; + left: 0; +} +.dn-node { + position: absolute; + padding: 0; + margin: 0; + overflow: visible; + pointer-events: none; +} +.dn-node:focus, +.dn-node:focus-visible { + outline: none; +} +.dn-entry-button { + position: absolute; + height: 1px; + transform: translate(-9999px, -9999px); +} +.dn-entry-button:focus { + height: auto; + top: -21px; + transform: translate(0, 0); +} diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.test.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.test.ts new file mode 100644 index 000000000..e183113db --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { fireEvent } from '@testing-library/react'; +import { View } from 'vega'; + +import { FOCUSED_DIMENSION, FOCUSED_ITEM, FOCUSED_REGION } from '@spectrum-charts/constants'; + +import { NavigableChartType } from './buildChartStructure'; +import { attachDataNavigator } from './dataNavigatorAdapter'; + +const data = [ + { browser: 'Chrome', downloads: 27000 }, + { browser: 'Firefox', downloads: 8000 }, + { browser: 'Safari', downloads: 4000 }, +]; + +const stackedData = [ + { browser: 'Chrome', os: 'Windows', downloads: 18000 }, + { browser: 'Chrome', os: 'Mac', downloads: 9000 }, + { browser: 'Firefox', os: 'Windows', downloads: 5000 }, + { browser: 'Firefox', os: 'Mac', downloads: 3000 }, +]; + +let container: HTMLElement; +let signal: jest.Mock; +let view: View; + +const mockView = () => { + signal = jest.fn(); + return { signal, runAsync: jest.fn() } as unknown as View; +}; + +const signaledWith = (name: string, value: unknown): boolean => + signal.mock.calls.some(([n, v]) => n === name && v === value); + +const entryButton = (): HTMLButtonElement => container.querySelector('button') as HTMLButtonElement; +// data-navigator renders exactly one node element (class `dn-node`) at a time; it carries the +// keydown listener. jsdom does not reliably track activeElement for it, so target it directly. +const focused = (): HTMLElement => container.querySelector('.dn-node') as HTMLElement; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + view = mockView(); +}); + +afterEach(() => { + container.remove(); +}); + +describe('attachDataNavigator()', () => { + const attach = (overrides = {}) => + attachDataNavigator({ + container, + chartType: 'bar', + data, + dimension: 'browser', + chartId: 'test-chart', + getView: () => view, + ...overrides, + }); + + test('renders an entry button into the container', () => { + attach(); + expect(entryButton()).toBeTruthy(); + }); + + test('does nothing for an unsupported chart type', () => { + attach({ chartType: 'pie' as unknown as NavigableChartType }); + expect(entryButton()).toBeFalsy(); + expect(signal).not.toHaveBeenCalled(); + }); + + test('namespaces the container id when one is not already set', () => { + attach(); + expect(container.id).toBe('dn-root-test-chart'); + }); + + test('leaves an existing container id untouched', () => { + container.id = 'preset-id'; + attach(); + expect(container.id).toBe('preset-id'); + }); + + test('entering the navigation focuses the chart region', () => { + attach(); + entryButton().click(); + expect(signaledWith(FOCUSED_REGION, 'chart')).toBe(true); + }); + + test('does not throw when there is no live view to signal', () => { + attach({ getView: () => undefined }); + expect(() => entryButton().click()).not.toThrow(); + }); + + test('drilling in and arrowing focuses individual bars', () => { + attach(); + entryButton().click(); + + // data-navigator's keydownValidator matches on event.code, not event.key. + fireEvent.keyDown(focused(), { key: 'Enter', code: 'Enter' }); + const itemCall = signal.mock.calls.find(([n, v]) => n === FOCUSED_ITEM && v !== null); + expect(itemCall).toBeDefined(); + + signal.mockClear(); + fireEvent.keyDown(focused(), { key: 'ArrowRight', code: 'ArrowRight' }); + expect(signal.mock.calls.some(([n, v]) => n === FOCUSED_ITEM && v !== null)).toBe(true); + }); + + test('Escape at the chart root drills out and clears focus', () => { + attach(); + entryButton().click(); + // entry focuses the chart root, which is also the entry point, so Escape exits. + expect(focused()).toBeTruthy(); + + signal.mockClear(); + fireEvent.keyDown(focused(), { key: 'Escape', code: 'Escape' }); + + // the focused node is removed on drill-out and every focus signal is cleared + expect(focused()).toBeNull(); + expect(signaledWith(FOCUSED_REGION, null)).toBe(true); + expect(signaledWith(FOCUSED_ITEM, null)).toBe(true); + }); + + describe('stacked bars (series present)', () => { + const attachStacked = () => + attachDataNavigator({ + container, + chartType: 'bar', + data: stackedData, + dimension: 'browser', + color: 'os', + chartId: 'stacked-chart', + getView: () => view, + }); + + test('drilling into a stack focuses the dimension group, then a segment', () => { + attachStacked(); + entryButton().click(); + expect(signaledWith(FOCUSED_REGION, 'chart')).toBe(true); + + // Enter the chart root → a per-column stack (dimension group). + fireEvent.keyDown(focused(), { key: 'Enter', code: 'Enter' }); + const dimensionCall = signal.mock.calls.find(([n, v]) => n === FOCUSED_DIMENSION && v !== null); + expect(dimensionCall).toBeDefined(); + + // Enter the stack → an individual segment. + signal.mockClear(); + fireEvent.keyDown(focused(), { key: 'Enter', code: 'Enter' }); + expect(signal.mock.calls.some(([n, v]) => n === FOCUSED_ITEM && v !== null)).toBe(true); + }); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.ts new file mode 100644 index 000000000..61e538b94 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/dataNavigatorAdapter.ts @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import dataNavigator, { NodeObject } from 'data-navigator'; +import { View } from 'vega'; + +import { FOCUSED_DIMENSION, FOCUSED_ITEM, FOCUSED_REGION } from '@spectrum-charts/constants'; +import { SimpleData } from '@spectrum-charts/vega-spec-builder-s2'; + +import { NavigableChartType, buildChartStructure } from './buildChartStructure'; +import './dataNavigator.css'; + +/* + * data-navigator's `rendering()` and `input()` factories are typed as `() => any`. + */ +interface DataNavigatorRenderer { + initialize: () => void; + render: (node: { renderId: string; datum: NodeObject }) => HTMLElement | undefined; + remove: (renderId: string) => void; + wrapper?: HTMLElement; + exitElement?: HTMLElement; +} + +interface DataNavigatorInput { + enter: () => NodeObject | undefined; + move: (current: string | null, direction: string) => NodeObject | undefined; + keydownValidator: (event: KeyboardEvent) => string | undefined; + focus: (renderId: string) => void; +} + +export interface AttachDataNavigatorOptions { + /** Positioned element the navigation overlay is rendered into. */ + container: HTMLElement; + /** The chart type to build navigation for. */ + chartType: NavigableChartType; + /** Chart data (plain objects). */ + data: SimpleData[]; + /** Primary categorical / x-axis field. */ + dimension?: string; + /** Series / color field (set for stacked bars). */ + color?: string; + /** Primary metric / y-axis field. */ + metric?: string; + /** Optional chart title for the accessible description. */ + title?: string; + /** Stable id used to namespace the rendered nav elements. */ + chartId: string; + /** Accessor for the live Vega view; focus signals are set on it as the user navigates. */ + getView: () => View | undefined; +} + +interface FocusSignals { + item: string | null; // a single bar / stacked segment + region: string | null; // the whole chart (entry/root) + dimension: string | null; // a dimension group, e.g. a whole stack +} + +/** + * Maps the focused node to the chart's focus signals by its level: + * - leaf (no dimensionLevel) → a single bar/segment (`item` = node id) + * - dimension root (level 1) → the chart overview (`region` = 'chart') + * - division (level 2) → a dimension group / stack (`dimension` = the column value) + */ +const nodeFocusSignals = (node: NodeObject): FocusSignals => { + if (node.dimensionLevel == null) { + return { item: node.id, region: null, dimension: null }; + } + if (node.dimensionLevel === 1) { + return { item: null, region: 'chart', dimension: null }; + } + const dimensionValue = node.derivedNode ? node.data?.[node.derivedNode] : undefined; + const dimension = dimensionValue == null ? null : String(dimensionValue); + return { item: null, region: null, dimension }; +}; + +const applyFocusSignals = (view: View | undefined, { item, region, dimension }: FocusSignals): void => { + if (!view) return; + + view.signal(FOCUSED_ITEM, item); + view.signal(FOCUSED_REGION, region); + view.signal(FOCUSED_DIMENSION, dimension); + view.runAsync(); +}; + +const CLEARED_FOCUS: FocusSignals = { item: null, region: null, dimension: null }; + +/** + * Builds the navigation structure and drives data-navigator's rendering + + * input modules. The visible focus indicator is drawn on the Vega canvas (focus-ring marks); the + * elements created here are invisible overlays used only for keyboard focus and assistive tech. + */ +export const attachDataNavigator = ({ + container, + chartType, + data, + dimension, + color, + metric, + title, + chartId, + getView, +}: AttachDataNavigatorOptions): void => { + const built = buildChartStructure({ chartType, data, dimension, color, metric, title }); + if (!built) return; + const { structure, entryPoint } = built; + + if (!container.id) { + container.id = `dn-root-${chartId}`; + } + + container.querySelectorAll('.dn-wrapper, .dn-exit-position, .dn-exit').forEach((node) => node.remove()); + + let current: string | null = null; + let previous: string | null = null; + const width = container.clientWidth || 400; + const height = container.clientHeight || 300; + + const rendering: DataNavigatorRenderer = dataNavigator.rendering({ + elementData: structure.nodes, + defaults: { + cssClass: 'dn-node', + spatialProperties: { x: 0, y: 0, width, height }, + }, + suffixId: chartId, + root: { + id: container.id, + description: 'Accessible chart navigation', + width: '100%', + height: 0, + }, + entryButton: { include: true, callbacks: { click: enter } }, + exitElement: { include: true }, + }); + + rendering.initialize(); + + const input: DataNavigatorInput = dataNavigator.input({ + structure, + navigationRules: structure.navigationRules ?? {}, + entryPoint, + exitPoint: rendering.exitElement?.id, + }); + + function enter() { + const node = input.enter(); + if (node) { + navigate(node); + } + } + + function navigate(node: NodeObject) { + const renderId = node.renderId || node.id; + node.renderId = renderId; + + if (previous) rendering.remove(previous); + + const el = rendering.render({ renderId, datum: node }); + if (!el) return; + + // Visual focus comes from the Vega ring, so overlay the element across the whole container. + el.style.width = '100%'; + el.style.height = '100%'; + el.style.top = '0'; + el.style.left = '0'; + + el.addEventListener('keydown', (event) => { + const direction = input.keydownValidator(event); + if (!direction) return; + event.preventDefault(); + // Final exit behavior is undecided, this is the current placeholder + if (direction === 'parent' && current === entryPoint) { + if (rendering.exitElement) { + rendering.exitElement.style.display = 'block'; + input.focus(rendering.exitElement.id); + } + return; + } + const next = input.move(current, direction); + if (next) navigate(next); + }); + + el.addEventListener('focus', () => applyFocusSignals(getView(), nodeFocusSignals(node))); + + input.focus(renderId); + previous = current; + current = node.id; + } + + if (rendering.exitElement) { + rendering.exitElement.addEventListener('focus', () => { + if (current) rendering.remove(current); + current = null; + applyFocusSignals(getView(), CLEARED_FOCUS); + }); + } +}; diff --git a/packages/react-spectrum-charts-s2/src/dataNavigator/navigableMarks.ts b/packages/react-spectrum-charts-s2/src/dataNavigator/navigableMarks.ts new file mode 100644 index 000000000..0c2729ce0 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/dataNavigator/navigableMarks.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { Bar } from '../components/Bar'; +import { NavigableChartType } from './buildChartStructure'; + +export const getNavigableChartType = (displayName: unknown): NavigableChartType | undefined => { + if (displayName === Bar.displayName) return 'bar'; + return undefined; +}; diff --git a/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx b/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx index fbfedd378..6fc3345cd 100644 --- a/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx +++ b/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx @@ -20,6 +20,7 @@ import { rscPropsToSpecBuilderOptions } from '../rscToSbAdapter'; import { SanitizedSpecProps } from '../types'; export default function useSpec({ + accessibleNavigation, backgroundColor, children, colors, @@ -51,6 +52,7 @@ export default function useSpec({ } const chartOptions = rscPropsToSpecBuilderOptions({ + accessibleNavigation, backgroundColor, children, colors, @@ -72,6 +74,7 @@ export default function useSpec({ return buildSpec(chartOptions); }, [ UNSAFE_vegaSpec, + accessibleNavigation, backgroundColor, children, colors, diff --git a/packages/react-spectrum-charts-s2/src/rscToSbAdapter/chartAdapter.test.ts b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/chartAdapter.test.ts index 11ec285b0..495e2cc75 100644 --- a/packages/react-spectrum-charts-s2/src/rscToSbAdapter/chartAdapter.test.ts +++ b/packages/react-spectrum-charts-s2/src/rscToSbAdapter/chartAdapter.test.ts @@ -55,6 +55,17 @@ describe('rscPropsToSpecBuilderOptions()', () => { }); }); + describe('accessibleNavigation', () => { + test('should pass accessibleNavigation through to the spec builder options', () => { + const options = rscPropsToSpecBuilderOptions({ ...chartProps, accessibleNavigation: true }); + expect(options).toHaveProperty('accessibleNavigation', true); + }); + test('should be omitted when not provided', () => { + const options = rscPropsToSpecBuilderOptions(chartProps); + expect(options.accessibleNavigation).toBeUndefined(); + }); + }); + describe('marks', () => { test('should return provided marks in the marks array', () => { const options = rscPropsToSpecBuilderOptions({ diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Bar/Bar.story.tsx b/packages/react-spectrum-charts-s2/src/stories/components/Bar/Bar.story.tsx index 1252e5980..3345a1fe3 100644 --- a/packages/react-spectrum-charts-s2/src/stories/components/Bar/Bar.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/components/Bar/Bar.story.tsx @@ -91,6 +91,17 @@ const BarStory: StoryFn = (args): ReactElement => { ); }; +const AccessibleNavigationStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ data: barData, width: 600, height: 600, accessibleNavigation: true }); + return ( + + + + + + ); +}; + const BarWithInspectStory: StoryFn = (args): ReactElement => { const chartProps = useChartProps({ data: barData, width: 600, height: 600 }); return ( @@ -211,7 +222,13 @@ InspectOnDimensionArea.args = { ...defaultProps, }; +const AccessibleNavigation = bindWithProps(AccessibleNavigationStory); +AccessibleNavigation.args = { + ...defaultProps, +}; + export { + AccessibleNavigation, BarWithUTCDatetimeFormat, Basic, HasSquareCorners, diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Bar/StackedBar.story.tsx b/packages/react-spectrum-charts-s2/src/stories/components/Bar/StackedBar.story.tsx index 13a483306..d7fd2c795 100644 --- a/packages/react-spectrum-charts-s2/src/stories/components/Bar/StackedBar.story.tsx +++ b/packages/react-spectrum-charts-s2/src/stories/components/Bar/StackedBar.story.tsx @@ -76,6 +76,18 @@ const NegativeBarStory: StoryFn = (args): ReactElement => { ); }; +const AccessibleNavigationStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ data: barSeriesData, colors, width: 800, height: 600, accessibleNavigation: true }); + return ( + + + + + + + ); +}; + const defaultProps: BarProps = { dimension: 'browser', order: 'order', @@ -123,4 +135,17 @@ InspectOnDimensionArea.args = { ...defaultProps, }; -export { Basic, NegativeStack, OnClick, StackedBarWithUTCDatetimeFormat, InspectOnDimensionArea, WithBarLabels }; +const AccessibleNavigation = bindWithProps(AccessibleNavigationStory); +AccessibleNavigation.args = { + ...defaultProps, +}; + +export { + AccessibleNavigation, + Basic, + NegativeStack, + OnClick, + StackedBarWithUTCDatetimeFormat, + InspectOnDimensionArea, + WithBarLabels, +}; diff --git a/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.test.ts b/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.test.ts new file mode 100644 index 000000000..ff57b1e58 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { FOCUSED_DIMENSION, FOCUSED_REGION, NAVIGATION_ID_SEPARATOR } from '@spectrum-charts/constants'; + +import { defaultBarOptions, defaultBarOptionsWithSecondayColor } from './barTestUtils'; +import { getBarFocusRing, getChartFocusRing, getStackFocusRing } from './barFocusRingUtils'; + +const { dimension, metric } = defaultBarOptions; + +describe('getBarFocusRing()', () => { + test('sources the ring from the bar mark', () => { + const ring = getBarFocusRing(defaultBarOptions); + expect(ring).toHaveProperty('name', 'bar0_focusRing'); + expect(ring.from).toEqual({ data: 'bar0' }); + expect(ring.interactive).toBe(false); + }); + + test('keys a single-color (string) bar on the dimension + series composite', () => { + // defaultBarOptions.color is a string series field → composite leaf id + const opacity = JSON.stringify(getBarFocusRing(defaultBarOptions).encode?.update?.opacity); + expect(opacity).toContain(NAVIGATION_ID_SEPARATOR); + }); + + test('keys a multi-color (array) bar on the dimension value only', () => { + const opacity = JSON.stringify(getBarFocusRing(defaultBarOptionsWithSecondayColor).encode?.update?.opacity); + expect(opacity).not.toContain(NAVIGATION_ID_SEPARATOR); + expect(opacity).toContain(`datum.datum.${dimension}`); + }); + + test('uses metric-sign corner tests for dodged (non-stacked) bars', () => { + const corners = JSON.stringify(getBarFocusRing({ ...defaultBarOptions, type: 'dodged' }).encode?.enter); + expect(corners).toContain(`datum.datum.${metric} > 0`); + }); + + test('uses stack-extent corner tests for stacked bars', () => { + const corners = JSON.stringify(getBarFocusRing(defaultBarOptions).encode?.enter); + expect(corners).toContain('_stacks'); + }); + + test('flattens the rounded corner radius when square corners are requested', () => { + const ring = getBarFocusRing({ ...defaultBarOptions, hasSquareCorners: true }); + // the "rounded" arm of each corner rule collapses to the flat radius (2) + expect(ring.encode?.enter?.cornerRadiusTopLeft).toEqual([{ test: expect.any(String), value: 2 }, { value: 2 }]); + }); +}); + +describe('getChartFocusRing()', () => { + test('covers the full plot area and keys opacity on the chart region', () => { + const ring = getChartFocusRing(defaultBarOptions); + expect(ring).toHaveProperty('name', 'chartFocusRing'); + expect(ring.encode?.update?.x).toEqual({ value: 0 }); + expect(ring.encode?.update?.x2).toEqual({ signal: 'width' }); + expect(ring.encode?.update?.opacity).toEqual([ + { test: `${FOCUSED_REGION} === 'chart'`, value: 1 }, + { value: 0 }, + ]); + }); +}); + +describe('getStackFocusRing()', () => { + test('sources from the per-stack data and keys opacity on the focused dimension', () => { + const ring = getStackFocusRing(defaultBarOptions); + expect(ring).toHaveProperty('name', 'bar0_stackFocusRing'); + expect(ring.from).toEqual({ data: 'bar0_stacks' }); + expect(ring.encode?.update?.opacity).toEqual([ + { test: `${FOCUSED_DIMENSION} === datum.${dimension}`, value: 1 }, + { value: 0 }, + ]); + }); + + test('positions a vertical stack ring along the y axis', () => { + const ring = getStackFocusRing(defaultBarOptions); + // vertical: top edge follows the stack-top metric scale + expect(JSON.stringify(ring.encode?.update?.y)).toContain(`max_${metric}1`); + }); + + test('positions a horizontal stack ring along the x axis', () => { + const ring = getStackFocusRing({ ...defaultBarOptions, orientation: 'horizontal' }); + expect(JSON.stringify(ring.encode?.update?.x2)).toContain(`max_${metric}1`); + }); + + test('rounds the metric-end corners unless square corners are requested', () => { + expect(getStackFocusRing(defaultBarOptions).encode?.enter?.cornerRadiusTopLeft).toEqual({ value: 6 }); + expect(getStackFocusRing({ ...defaultBarOptions, hasSquareCorners: true }).encode?.enter?.cornerRadiusTopLeft).toEqual({ + value: 2, + }); + }); + + test('rounds the trailing corner for a horizontal stack', () => { + const ring = getStackFocusRing({ ...defaultBarOptions, orientation: 'horizontal' }); + expect(ring.encode?.enter?.cornerRadiusTopRight).toEqual({ value: 6 }); + }); +}); diff --git a/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.ts b/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.ts new file mode 100644 index 000000000..236e892b5 --- /dev/null +++ b/packages/vega-spec-builder-s2/src/bar/barFocusRingUtils.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { RectEncodeEntry, RectMark } from 'vega'; + +import { FOCUSED_DIMENSION, FOCUSED_ITEM, FOCUSED_REGION, NAVIGATION_ID_SEPARATOR, STACK_ID } from '@spectrum-charts/constants'; +import { getS2ColorValue } from '@spectrum-charts/themes'; + +import { BarSpecOptions } from '../types'; +import { getOrientationProperties, isDodgedAndStacked, rotateRectClockwiseIfNeeded } from './barUtils'; + +const FOCUS_RING_STROKE_WIDTH = 2; +const FOCUS_RING_ROUNDED_RADIUS = 6; +const FOCUS_RING_FLAT_RADIUS = 2; +const FOCUS_RING_OFFSET = 3; + +/** Ring corners for a single bar or whole stack treated as one shape: rounded metric end, flat base. */ +const getStaticFocusRingCorners = ({ hasSquareCorners, orientation }: BarSpecOptions): RectEncodeEntry => { + const rounded = hasSquareCorners ? FOCUS_RING_FLAT_RADIUS : FOCUS_RING_ROUNDED_RADIUS; + const flat = FOCUS_RING_FLAT_RADIUS; + return orientation === 'vertical' + ? { + cornerRadiusTopLeft: { value: rounded }, + cornerRadiusTopRight: { value: rounded }, + cornerRadiusBottomRight: { value: flat }, + cornerRadiusBottomLeft: { value: flat }, + } + : { + cornerRadiusTopLeft: { value: flat }, + cornerRadiusTopRight: { value: rounded }, + cornerRadiusBottomRight: { value: rounded }, + cornerRadiusBottomLeft: { value: flat }, + }; +}; + +/** + * Handle square corners for the inner stacked bar segments. + */ +const getDynamicFocusRingCorners = (options: BarSpecOptions): RectEncodeEntry => { + const { hasSquareCorners, metric, name, type } = options; + const rounded = hasSquareCorners ? FOCUS_RING_FLAT_RADIUS : FOCUS_RING_ROUNDED_RADIUS; + const flat = FOCUS_RING_FLAT_RADIUS; + let topTest: string; + let bottomTest: string; + if (type === 'dodged' && !isDodgedAndStacked(options)) { + topTest = `datum.datum.${metric} > 0`; + bottomTest = `datum.datum.${metric} < 0`; + } else { + const stacks = `data('${name}_stacks')`; + const stackIndex = `indexof(pluck(${stacks}, '${STACK_ID}'), datum.datum.${STACK_ID})`; + topTest = `datum.datum.${metric}1 > 0 && ${stacks}[${stackIndex}].max_${metric}1 === datum.datum.${metric}1`; + bottomTest = `datum.datum.${metric}1 < 0 && ${stacks}[${stackIndex}].min_${metric}1 === datum.datum.${metric}1`; + } + const rect: RectEncodeEntry = { + cornerRadiusTopLeft: [{ test: topTest, value: rounded }, { value: flat }], + cornerRadiusTopRight: [{ test: topTest, value: rounded }, { value: flat }], + cornerRadiusBottomLeft: [{ test: bottomTest, value: rounded }, { value: flat }], + cornerRadiusBottomRight: [{ test: bottomTest, value: rounded }, { value: flat }], + }; + return rotateRectClockwiseIfNeeded(rect, options); +}; + +export const getBarFocusRing = (options: BarSpecOptions): RectMark => { + const { color, colorScheme, dimension, name } = options; + const focusedItemId = + typeof color === 'string' + ? `datum.datum.${dimension} + "${NAVIGATION_ID_SEPARATOR}" + datum.datum.${color}` + : `datum.datum.${dimension}`; + return { + name: `${name}_focusRing`, + type: 'rect', + from: { data: name }, + interactive: false, + encode: { + enter: { + fill: { value: 'transparent' }, + strokeWidth: { value: FOCUS_RING_STROKE_WIDTH }, + stroke: { value: getS2ColorValue('blue-800', colorScheme) }, + // Mirror the segment's own corners so stacked middle segments stay square. + ...getDynamicFocusRingCorners(options), + }, + update: { + x: { signal: `datum.bounds.x1 - ${FOCUS_RING_OFFSET}` }, + x2: { signal: `datum.bounds.x2 + ${FOCUS_RING_OFFSET}` }, + y: { signal: `datum.bounds.y1 - ${FOCUS_RING_OFFSET}` }, + y2: { signal: `datum.bounds.y2 + ${FOCUS_RING_OFFSET}` }, + opacity: [{ test: `${FOCUSED_ITEM} === ${focusedItemId}`, value: 1 }, { value: 0 }], + }, + }, + }; +}; + +export const getChartFocusRing = (options: BarSpecOptions): RectMark => { + const { colorScheme } = options; + return { + name: 'chartFocusRing', + type: 'rect', + interactive: false, + encode: { + enter: { + fill: { value: 'transparent' }, + strokeWidth: { value: FOCUS_RING_STROKE_WIDTH }, + stroke: { value: getS2ColorValue('blue-800', colorScheme) }, + cornerRadius: { value: FOCUS_RING_ROUNDED_RADIUS }, + }, + update: { + x: { value: 0 }, + x2: { signal: 'width' }, + y: { value: 0 }, + y2: { signal: 'height' }, + opacity: [{ test: `${FOCUSED_REGION} === 'chart'`, value: 1 }, { value: 0 }], + }, + }, + }; +}; + +/** + * Focus ring around a whole stack (column) when that stack is focused. Sourced from the per-column + */ +export const getStackFocusRing = (options: BarSpecOptions): RectMark => { + const { colorScheme, dimension, metric, name, orientation } = options; + const { dimensionScaleKey, metricScaleKey } = getOrientationProperties(orientation); + const dimStart = `scale('${dimensionScaleKey}', datum.${dimension}) - ${FOCUS_RING_OFFSET}`; + const dimEnd = `scale('${dimensionScaleKey}', datum.${dimension}) + bandwidth('${dimensionScaleKey}') + ${FOCUS_RING_OFFSET}`; + const stackTop = `scale('${metricScaleKey}', datum.max_${metric}1)`; + const baseline = `scale('${metricScaleKey}', 0)`; + const update: RectEncodeEntry = + orientation === 'vertical' + ? + { + x: { signal: dimStart }, + x2: { signal: dimEnd }, + y: { signal: `${stackTop} - ${FOCUS_RING_OFFSET}` }, + y2: { signal: `${baseline} + ${FOCUS_RING_OFFSET}` }, + } + : + { + y: { signal: dimStart }, + y2: { signal: dimEnd }, + x: { signal: `${baseline} - ${FOCUS_RING_OFFSET}` }, + x2: { signal: `${stackTop} + ${FOCUS_RING_OFFSET}` }, + }; + return { + name: `${name}_stackFocusRing`, + type: 'rect', + from: { data: `${name}_stacks` }, + interactive: false, + encode: { + enter: { + fill: { value: 'transparent' }, + strokeWidth: { value: FOCUS_RING_STROKE_WIDTH }, + stroke: { value: getS2ColorValue('blue-800', colorScheme) }, + ...getStaticFocusRingCorners(options), + }, + update: { + ...update, + opacity: [{ test: `${FOCUSED_DIMENSION} === datum.${dimension}`, value: 1 }, { value: 0 }], + }, + }, + }; +}; diff --git a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts index 1812ee21d..1c2a80088 100644 --- a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts @@ -32,6 +32,9 @@ import { DEFAULT_SECONDARY_COLOR, DIMENSION_HOVER_AREA, FILTERED_TABLE, + FOCUSED_DIMENSION, + FOCUSED_ITEM, + FOCUSED_REGION, HOVERED_ITEM, LINE_TYPE_SCALE, MARK_ID, @@ -276,6 +279,17 @@ describe('barSpecBuilder', () => { expect(signals).toHaveLength(defaultSignals.length + 1); expect(signals.at(-1)).toHaveProperty('name', 'paddingInner'); }); + test('should add focus signals when accessibleNavigation is enabled', () => { + const signals = addSignals(defaultSignals, { ...defaultBarOptions, accessibleNavigation: true }); + expect(signals.find((signal) => signal.name === FOCUSED_ITEM)).toBeDefined(); + expect(signals.find((signal) => signal.name === FOCUSED_REGION)).toBeDefined(); + expect(signals.find((signal) => signal.name === FOCUSED_DIMENSION)).toBeDefined(); + }); + test('should not add focus signals by default', () => { + const signals = addSignals(defaultSignals, defaultBarOptions); + expect(signals.find((signal) => signal.name === FOCUSED_ITEM)).toBeUndefined(); + expect(signals.find((signal) => signal.name === FOCUSED_REGION)).toBeUndefined(); + }); test('should add hover events if inspect is present', () => { const signals = addSignals(defaultSignals, { ...defaultBarOptions, chartInspects: [{}] }); expect(signals.at(-1)).toHaveProperty('on'); @@ -753,6 +767,33 @@ describe('barSpecBuilder', () => { }); }); + describe('accessibleNavigation', () => { + test('should add chart and bar focus rings when enabled (stacked/basic bar)', () => { + const marks = addMarks([], { ...defaultBarOptions, accessibleNavigation: true }); + expect(marks.find((mark) => mark.name === 'chartFocusRing')).toBeDefined(); + expect(marks.find((mark) => mark.name === 'bar0_focusRing')).toBeDefined(); + }); + test('should add the bar focus ring inside the dodge group when enabled (dodged bar)', () => { + const marks = addMarks([], { ...defaultBarOptions, type: 'dodged', accessibleNavigation: true }); + const group = marks.find((mark) => mark.name === 'bar0_group') as GroupMark; + expect(group.marks?.find((mark) => mark.name === 'bar0_focusRing')).toBeDefined(); + }); + test('should not add focus rings by default', () => { + const marks = addMarks([], defaultBarOptions); + expect(marks.find((mark) => mark.name === 'chartFocusRing')).toBeUndefined(); + expect(marks.find((mark) => mark.name === 'bar0_focusRing')).toBeUndefined(); + }); + test('should add the per-stack group focus ring when enabled on a stacked bar', () => { + const marks = addMarks([], { ...defaultBarOptions, type: 'stacked', accessibleNavigation: true, color: 'series' }); + expect(marks.find((mark) => mark.name === 'bar0_stackFocusRing')).toBeDefined(); + }); + test('should not add the stack group focus ring on a basic (single-series) bar', () => { + // a static {value} color (no series field) → basic bar, so no per-stack group ring + const marks = addMarks([], { ...defaultBarOptions, accessibleNavigation: true, color: { value: 'categorical-100' } }); + expect(marks.find((mark) => mark.name === 'bar0_stackFocusRing')).toBeUndefined(); + }); + }); + describe('with annotations', () => { test('default options', () => { const marks = addMarks([], { diff --git a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts index 1b88cc38f..a28a2ddc5 100644 --- a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts @@ -19,6 +19,9 @@ import { DEFAULT_METRIC, DIMENSION_HOVER_AREA, FILTERED_TABLE, + FOCUSED_DIMENSION, + FOCUSED_ITEM, + FOCUSED_REGION, LAST_RSC_SERIES_ID, LINE_TYPE_SCALE, OPACITY_SCALE, @@ -58,6 +61,7 @@ import { addUserMetaInteractiveMark, getFacetsFromOptions } from '../specUtils'; import { getBarDirectLabelMarks, getBarDirectLabelSpecOptions } from '../barDirectLabel/barDirectLabelUtils'; import { addTrendlineData, getTrendlineMarks, setTrendlineSignals } from '../trendline'; import { BarOptions, BarSpecOptions, ColorScheme, HighlightedItem, ScSpec } from '../types'; +import { getChartFocusRing } from './barFocusRingUtils'; import { getBarPadding, getBaseScaleName, @@ -75,6 +79,7 @@ export const addBar = produce< ScSpec, [ BarOptions & { + accessibleNavigation?: boolean; colorScheme?: ColorScheme; highlightedItem?: HighlightedItem; index?: number; @@ -175,6 +180,14 @@ export const addSignals = produce((signals, options) const { paddingInner } = getBarPadding(paddingRatio, barPaddingOuter); signals.push(getGenericValueSignal('paddingInner', paddingInner)); + if (options.accessibleNavigation) { + signals.push( + getGenericValueSignal(FOCUSED_ITEM), + getGenericValueSignal(FOCUSED_REGION), + getGenericValueSignal(FOCUSED_DIMENSION) + ); + } + if (isDualMetricAxis(options)) { signals.push(getFirstRscSeriesIdSignal(), getLastRscSeriesIdSignal()); } @@ -416,6 +429,10 @@ export const addMarks = produce((marks, options) => { for (const [i, label] of options.barDirectLabels.entries()) { marks.push(...getBarDirectLabelMarks(getBarDirectLabelSpecOptions(label, i, options), options)); } + + if (options.accessibleNavigation) { + marks.push(getChartFocusRing(options)); + } }); export const getRepeatedScale = (options: BarSpecOptions): Scale => { diff --git a/packages/vega-spec-builder-s2/src/bar/dodgedBarUtils.ts b/packages/vega-spec-builder-s2/src/bar/dodgedBarUtils.ts index 942f12039..aaa9429fe 100644 --- a/packages/vega-spec-builder-s2/src/bar/dodgedBarUtils.ts +++ b/packages/vega-spec-builder-s2/src/bar/dodgedBarUtils.ts @@ -16,6 +16,7 @@ import { BACKGROUND_COLOR } from '@spectrum-charts/constants'; import { hasInspectWithDimensionAreaTarget } from '../chartInspect/chartInspectUtils'; import { isInteractive } from '../marks/markUtils'; import { BarSpecOptions } from '../types'; +import { getBarFocusRing } from './barFocusRingUtils'; import { getAnnotationMarks } from './barAnnotationUtils'; import { getBarDimensionHoverArea, @@ -67,6 +68,8 @@ export const getDodgedMarks = (options: BarSpecOptions): (GroupMark | RectMark)[ }, }, ...getAnnotationMarks(options, `${name}_facet`, `${name}_position`, `${name}_dodgeGroup`), + // focus ring for keyboard navigation (experimental); inside the group so it can read the bar mark bounds + ...(options.accessibleNavigation ? [getBarFocusRing(options)] : []), ], }, ]; diff --git a/packages/vega-spec-builder-s2/src/bar/stackedBarUtils.ts b/packages/vega-spec-builder-s2/src/bar/stackedBarUtils.ts index 071031cb2..0cceda637 100644 --- a/packages/vega-spec-builder-s2/src/bar/stackedBarUtils.ts +++ b/packages/vega-spec-builder-s2/src/bar/stackedBarUtils.ts @@ -17,6 +17,7 @@ import { hasInspectWithDimensionAreaTarget } from '../chartInspect/chartInspectU import { isInteractive } from '../marks/markUtils'; import { BarSpecOptions } from '../types'; import { getAnnotationMarks } from './barAnnotationUtils'; +import { getBarFocusRing, getStackFocusRing } from './barFocusRingUtils'; import { getBarDimensionHoverArea, getBarEnterEncodings, @@ -51,6 +52,15 @@ export const getStackedBarMarks = (options: BarSpecOptions): Mark[] => { ) ); + // focus rings for keyboard navigation (experimental): a per-segment ring always, plus a per-stack + // group ring when the bar is actually stacked (a series/color field is present). + if (options.accessibleNavigation) { + marks.push(getBarFocusRing(options)); + if (typeof options.color === 'string') { + marks.push(getStackFocusRing(options)); + } + } + return marks; }; diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts index dba354fd2..9dbfa11cf 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts @@ -92,6 +92,7 @@ import { import { addVenn } from './venn/vennSpecBuilder'; export function buildSpec({ + accessibleNavigation = false, axes = [], backgroundColor = DEFAULT_BACKGROUND_COLOR, chartHeight, @@ -144,7 +145,7 @@ export function buildSpec({ let { areaCount, barCount, bulletCount, comboCount, donutCount, lineCount, scatterCount, vennCount } = initializeComponentCounts(); const legendHighlightSignals = getLegendHighlightSignals(legends); - const specOptions = { backgroundColor, colorScheme, idKey, highlightedItem, legendHighlightSignals }; + const specOptions = { accessibleNavigation, backgroundColor, colorScheme, idKey, highlightedItem, legendHighlightSignals }; spec = [...marks].reduce((acc: ScSpec, mark) => { switch (mark.markType) { case 'area': diff --git a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts b/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts index 292d34733..e156fcab7 100644 --- a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts @@ -101,6 +101,12 @@ export interface ChartOptions { idKey?: string; /** Width of chart */ chartWidth?: number; + /** + * Enables experimental accessible keyboard navigation of the chart via data-navigator. + * When enabled, supported marks emit focus signals and render focus rings driven by keyboard navigation. + * Currently only basic bar charts are supported. Defaults to `false`. + */ + accessibleNavigation?: boolean; // children marks: MarkOptions[]; diff --git a/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts b/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts index bfcf3dbd6..043ea8ea3 100644 --- a/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts @@ -102,6 +102,8 @@ type BarOptionsWithDefaults = | 'type'; export interface BarSpecOptions extends PartiallyRequired { + /** Experimental: keyboard navigation focus rings/signals are emitted when true. @see ChartOptions.accessibleNavigation */ + accessibleNavigation?: boolean; colorScheme: ColorScheme; comboSiblingNames?: string[]; dimensionScaleType: 'band'; diff --git a/yarn.lock b/yarn.lock index 3b5639454..672fb97c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9371,6 +9371,11 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-navigator@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.4.1.tgz#46ee7c17e35fcd7a54bf998fc77101a802d224c1" + integrity sha512-JYoSq2Wt1PTNuFRj+eUuUVfGTOOpc/XgJYiDMri+c35NRuCDDY/H+WeFr+SxZYmP6HlgV40VWXjBTVr1TFM0ww== + data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"