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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions packages/react-spectrum-charts-s2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
62 changes: 46 additions & 16 deletions packages/react-spectrum-charts-s2/src/RscChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,6 +39,7 @@ interface ChartDialogProps {

export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref<ChartHandle> }) => {
const {
accessibleNavigation,
backgroundColor,
data,
chartWidth,
Expand Down Expand Up @@ -69,6 +72,7 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref<ChartHan

// THE MAGIC, builds our spec
const spec = useSpec({
accessibleNavigation,
backgroundColor,
children: sanitizedChildren,
colors,
Expand Down Expand Up @@ -115,6 +119,17 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref<ChartHan
[onNewView, onVegaViewReady]
);

const navContainerRef = useRef<HTMLDivElement>(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 (
<>
<div
Expand All @@ -123,20 +138,35 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref<ChartHan
ref={popoverAnchorRef}
style={targetStyle}
/>
<VegaChart
spec={spec}
config={chartConfig}
data={data}
debug={debug}
renderer={renderer}
width={chartWidth}
height={chartHeight}
locale={locale}
padding={padding}
signals={signals}
tooltip={inspectOptions} // legend show/hide relies on this
onNewView={handleNewView}
/>
<div id={`${chartId}-dn-root`} ref={navContainerRef} style={{ position: 'relative' }}>
<VegaChart
spec={spec}
config={chartConfig}
data={data}
debug={debug}
renderer={renderer}
width={chartWidth}
height={chartHeight}
locale={locale}
padding={padding}
signals={signals}
tooltip={inspectOptions} // legend show/hide relies on this
onNewView={handleNewView}
/>
{accessibleNavigation && navChartType && (
<Navigator
chartType={navChartType}
data={data as SimpleData[]}
dimension={navFields?.dimension}
color={navColor}
metric={navFields?.metric}
title={title}
containerRef={navContainerRef}
chartId={chartId}
getView={getView}
/>
)}
</div>
{popovers.map((popover) => (
<ChartDialog
key={popover.key}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 { ReactElement, useRef } from 'react';

import { render } from '@testing-library/react';
import { View } from 'vega';

import { SimpleData } from '@spectrum-charts/vega-spec-builder-s2';

import { Navigator } from './Navigator';

const data: SimpleData[] = [
{ browser: 'Chrome', downloads: 27000 },
{ browser: 'Firefox', downloads: 8000 },
];

const getView = () => ({ signal: jest.fn(), runAsync: jest.fn() }) as unknown as View;

const Harness = ({ chartData }: { chartData: SimpleData[] }): ReactElement => {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} data-testid="dn-container" style={{ position: 'relative' }}>
<Navigator
chartType="bar"
data={chartData}
dimension="browser"
chartId="navigator-test"
containerRef={ref}
getView={getView}
/>
</div>
);
};

describe('Navigator', () => {
test('renders no DOM of its own', () => {
const { container } = render(<Navigator chartType="bar" data={data} dimension="browser" chartId="t" containerRef={{ current: null }} getView={getView} />);
expect(container).toBeEmptyDOMElement();
});

test('attaches the data-navigator entry button into the container', () => {
const { getByTestId } = render(<Harness chartData={data} />);
expect(getByTestId('dn-container').querySelector('button')).toBeTruthy();
});

test('does not attach navigation when there is no data', () => {
const { getByTestId } = render(<Harness chartData={[]} />);
expect(getByTestId('dn-container').querySelector('button')).toBeFalsy();
});
});
63 changes: 63 additions & 0 deletions packages/react-spectrum-charts-s2/src/dataNavigator/Navigator.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>;
/** 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';
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading