Skip to content
Closed
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
6 changes: 6 additions & 0 deletions packages/mobile-visualization/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 3.7.1 (5/3/2026 PST)

#### 🐞 Fixes

- Gate `aria-live` announcements on chart focus. [[#663](https://github.com/coinbase/cds/pull/663)]

## 3.7.0 (4/20/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/mobile-visualization/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-mobile-visualization",
"version": "3.7.0",
"version": "3.7.1",
"description": "Coinbase Design System - Mobile Visualization Native",
"repository": {
"type": "git",
Expand Down
4 changes: 2 additions & 2 deletions packages/mobile-visualization/src/chart/CartesianChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const ChartCanvas = memo(
style,
accessible = true,
accessibilityLabel,
accessibilityLiveRegion = 'polite',
accessibilityLiveRegion = 'none',
}: ChartCanvasProps) => {
const ContextBridge = useChartContextBridge();
const isAccessible = accessible && accessibilityLabel !== null;
Expand Down Expand Up @@ -196,7 +196,7 @@ export const CartesianChart = memo(
collapsable = false,
accessible = true,
accessibilityLabel,
accessibilityLiveRegion = 'polite',
accessibilityLiveRegion = 'none',
...props
},
ref,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers';
import { render, screen } from '@testing-library/react-native';

import { CartesianChart } from '../CartesianChart';

jest.mock('@shopify/react-native-skia', () => {
const React = require('react');
const { View } = require('react-native');
return {
Canvas: ({
children,
style,
accessible,
accessibilityLabel,
accessibilityLiveRegion,
}: {
children?: React.ReactNode;
style?: unknown;
accessible?: boolean;
accessibilityLabel?: string;
accessibilityLiveRegion?: string;
}) =>
React.createElement(
View,
{ style, testID: 'skia-canvas', accessible, accessibilityLabel, accessibilityLiveRegion },
children,
),
Group: ({ children }: { children?: React.ReactNode }) => children ?? null,
Path: () => null,
ClipOp: { Intersect: 0 },
Skia: {
Path: {
Make: jest.fn(() => ({
type: 'SkPath',
addRect: jest.fn(),
addRRect: jest.fn(),
interpolate: jest.fn(),
toSVGString: jest.fn(() => ''),
copy: jest.fn(),
})),
MakeFromSVGString: jest.fn(),
},
TypefaceFontProvider: { Make: jest.fn(() => ({})) },
},
usePathInterpolation: jest.fn(() => null),
notifyChange: jest.fn(),
};
});

jest.mock('react-native-reanimated', () => ({
...jest.requireActual('react-native-reanimated/mock'),
useSharedValue: jest.fn((v: number) => ({ value: v })),
}));

jest.mock('../ChartContextBridge', () => {
const React = require('react');
return {
ChartBridgeProvider: ({ children }: { children: React.ReactNode }) => children,
useChartContextBridge:
() =>
({ children }: { children: React.ReactNode }) =>
children,
};
});

jest.mock('@coinbase/cds-mobile/hooks/useLayout', () => ({
useLayout: jest.fn(() => [{ width: 400, height: 300, x: 0, y: 0 }, jest.fn()]),
}));

const renderChart = (overrides: Partial<React.ComponentProps<typeof CartesianChart>> = {}) =>
render(
<DefaultThemeProvider>
<CartesianChart
accessibilityLabel="Test chart"
series={[{ id: 'a', data: [1, 2, 3], color: 'blue' }]}
xAxis={{ data: [1, 2, 3] }}
{...overrides}
/>
</DefaultThemeProvider>,
);

describe('CartesianChart (mobile)', () => {
describe('accessibilityLiveRegion', () => {
it('defaults accessibilityLiveRegion to "none" on the canvas', () => {
renderChart();
expect(screen.getByTestId('skia-canvas').props.accessibilityLiveRegion).toBe('none');
});

it('respects an explicit accessibilityLiveRegion="polite" prop', () => {
renderChart({ accessibilityLiveRegion: 'polite' });
expect(screen.getByTestId('skia-canvas').props.accessibilityLiveRegion).toBe('polite');
});
});
});
6 changes: 6 additions & 0 deletions packages/web-visualization/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file.

<!-- template-start -->

## 3.7.1 (5/3/2026 PST)

#### 🐞 Fixes

- Gate `aria-live` announcements on chart focus. [[#663](https://github.com/coinbase/cds/pull/663)]

## 3.7.0 (4/20/2026 PST)

#### 🚀 Updates
Expand Down
2 changes: 1 addition & 1 deletion packages/web-visualization/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@coinbase/cds-web-visualization",
"version": "3.7.0",
"version": "3.7.1",
"description": "Coinbase Design System - Web Sparkline",
"repository": {
"type": "git",
Expand Down
1 change: 0 additions & 1 deletion packages/web-visualization/src/chart/CartesianChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,6 @@ export const CartesianChart = memo(
}
}}
accessibilityLabel={accessibilityLabel}
aria-live="polite"
as="svg"
className={cx(enableScrubbing && focusStylesCss, classNames?.chart)}
height="100%"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,14 @@ describe('CartesianChart', () => {
expect(svg).toBeInTheDocument();
expect(svg?.getAttribute('tabindex')).toBeNull();
});

it('does not set aria-live on the chart SVG', () => {
const root = renderCartesianChart({
testID: 'cartesian-no-aria-live',
chartProps: { enableScrubbing: true },
});
expect(root.querySelector('svg')).not.toHaveAttribute('aria-live');
});
});

describe('compositions', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/web-visualization/src/chart/scrubber/Scrubber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ export const Scrubber = memo(
) => {
const beaconGroupRef = React.useRef<ScrubberBeaconGroupRef>(null);

const { scrubberPosition } = useScrubberContext();
const { scrubberPosition, isChartFocused } = useScrubberContext();
const {
layout,
getXScale,
Expand Down Expand Up @@ -425,7 +425,7 @@ export const Scrubber = memo(
<motion.g
aria-atomic="true"
aria-label={resolvedAccessibilityLabel}
aria-live="polite"
aria-live={isChartFocused ? 'polite' : 'off'}
data-component="scrubber-group"
data-testid={testID}
role="status"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({

const { layout, getXScale, getYScale, getXAxis, getYAxis, series } = chartContext;
const [scrubberPosition, setScrubberPosition] = useState<number | undefined>(undefined);
const [isChartFocused, setIsChartFocused] = useState(false);

const getDataIndexFromPosition = useCallback(
(mousePosition: number): number => {
Expand Down Expand Up @@ -248,7 +249,12 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({
],
);

const handleFocus = useCallback(() => {
setIsChartFocused(true);
}, []);

const handleBlur = useCallback(() => {
setIsChartFocused(false);
if (!enableScrubbing || scrubberPosition === undefined) return;
setScrubberPosition(undefined);
onScrubberPositionChange?.(undefined);
Expand All @@ -268,6 +274,7 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({
svg.addEventListener('touchend', handleTouchEnd);
svg.addEventListener('touchcancel', handleTouchEnd);
svg.addEventListener('keydown', handleKeyDown);
svg.addEventListener('focus', handleFocus);
svg.addEventListener('blur', handleBlur);

return () => {
Expand All @@ -278,6 +285,7 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({
svg.removeEventListener('touchend', handleTouchEnd);
svg.removeEventListener('touchcancel', handleTouchEnd);
svg.removeEventListener('keydown', handleKeyDown);
svg.removeEventListener('focus', handleFocus);
svg.removeEventListener('blur', handleBlur);
};
}, [
Expand All @@ -289,6 +297,7 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({
handleTouchMove,
handleTouchEnd,
handleKeyDown,
handleFocus,
handleBlur,
]);

Expand All @@ -297,8 +306,9 @@ export const ScrubberProvider: React.FC<ScrubberProviderProps> = ({
enableScrubbing: !!enableScrubbing,
scrubberPosition,
onScrubberPositionChange: setScrubberPosition,
isChartFocused,
}),
[enableScrubbing, scrubberPosition],
[enableScrubbing, scrubberPosition, isChartFocused],
);

return <ScrubberContext.Provider value={contextValue}>{children}</ScrubberContext.Provider>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';

import { CartesianChart } from '../../CartesianChart';
import { Line } from '../../line/Line';
Expand Down Expand Up @@ -148,6 +148,34 @@ describe('Scrubber', () => {
});
});

describe('aria-live focus gating', () => {
it('has aria-live="off" when the chart is not focused', () => {
renderChartWithScrubber({ testID: 'scrubber' });
const chartRoot = screen.getByTestId('test-chart');
const group = chartRoot.querySelector('[data-component="scrubber-group"]');
expect(group).toHaveAttribute('aria-live', 'off');
});

it('has aria-live="polite" when the chart SVG receives focus', () => {
renderChartWithScrubber({ testID: 'scrubber' });
const chartRoot = screen.getByTestId('test-chart');
const svgEl = chartRoot.querySelector('svg')!;
fireEvent.focus(svgEl);
const group = chartRoot.querySelector('[data-component="scrubber-group"]');
expect(group).toHaveAttribute('aria-live', 'polite');
});

it('returns to aria-live="off" when the chart SVG loses focus', () => {
renderChartWithScrubber({ testID: 'scrubber' });
const chartRoot = screen.getByTestId('test-chart');
const svgEl = chartRoot.querySelector('svg')!;
fireEvent.focus(svgEl);
fireEvent.blur(svgEl);
const group = chartRoot.querySelector('[data-component="scrubber-group"]');
expect(group).toHaveAttribute('aria-live', 'off');
});
});

describe('hideOverlay', () => {
it('does not render overlay when hideOverlay is true', () => {
renderChartWithScrubber({ hideOverlay: true, testID: 'scrubber' });
Expand Down
6 changes: 6 additions & 0 deletions packages/web-visualization/src/chart/utils/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ export type ScrubberContextValue = {
* Receives the dataIndex of the scrubber or undefined when not scrubbing.
*/
onScrubberPositionChange: (index: number | undefined) => void;
/**
* True while the chart SVG has DOM focus. Used to gate aria-live announcements
* so the screen reader only announces value changes while the user is navigating
* the chart, not when focus is elsewhere on the page.
*/
isChartFocused: boolean;
};

export const ScrubberContext = createContext<ScrubberContextValue | undefined>(undefined);
Expand Down
Loading