Skip to content

Commit 0f54a6b

Browse files
authored
feat(ScatterChart): enable arrow key nav via custom accessibilityLayer (#8566)
Fixes #7484
1 parent abaa696 commit 0f54a6b

9 files changed

Lines changed: 624 additions & 19 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ const style = {
127127

128128
Components must work in both SPA and SSR environments. Wrappers use `suppressHydrationWarning` because web component internal state doesn't serialize consistently.
129129

130+
### CSS Modules
131+
132+
Component styles use CSS Modules (`.module.css` files). The corresponding `.css.ts` files are **auto-generated** by the build system and **gitignored** (`src/**/*.css.ts` in each package's `.gitignore`). Never create or edit `.css.ts` files manually — only create the `.module.css` source file.
133+
130134
## Core Architecture
131135

132136
### Base Package Imports

packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx

Lines changed: 209 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,17 @@ const measures = [
1919
},
2020
];
2121

22+
function activePointLabelShould(containerSelector: string, ...matchers: string[]) {
23+
cy.get(containerSelector)
24+
.should('have.attr', 'aria-activedescendant')
25+
.then((activeId) => {
26+
let chain = cy.get(`#${CSS.escape(activeId as string)}`).should('have.attr', 'aria-label');
27+
for (const m of matchers) {
28+
chain = chain.and('contain', m);
29+
}
30+
});
31+
}
32+
2233
describe('ScatterChart', () => {
2334
it('Basic', () => {
2435
cy.mount(<ScatterChart dataset={scatterComplexDataSet} measures={measures} />);
@@ -66,7 +77,204 @@ describe('ScatterChart', () => {
6677
it('Loading Placeholder', () => {
6778
cy.mount(<ScatterChart dataset={[]} measures={[]} />);
6879
cy.get('.recharts-scatter').should('not.exist');
69-
cy.contains('Loading...').should('exist');
80+
cy.findByText('Loading...').should('exist');
81+
});
82+
83+
it('accessibilityLayer: keyboard navigation, Enter, blur/re-focus, consumer handlers', () => {
84+
const chartConfig = { accessibilityLayer: true };
85+
const containerSelector = '[aria-roledescription="chart"]';
86+
const singleDataset = [
87+
{
88+
label: 'Series A',
89+
data: [
90+
{ users: 100, sessions: 200, volume: 300 },
91+
{ users: 50, sessions: 150, volume: 250 },
92+
{ users: 200, sessions: 400, volume: 500 },
93+
],
94+
},
95+
];
96+
97+
const onDataPointClick = cy.spy().as('onDataPointClick');
98+
const onBlur = cy.spy().as('onBlur');
99+
const onFocus = cy.spy().as('onFocus');
100+
const onKeyDownCapture = cy.spy().as('onKeyDownCapture');
101+
102+
cy.mount(
103+
<>
104+
<button>before</button>
105+
<ScatterChart
106+
dataset={singleDataset}
107+
measures={measures}
108+
chartConfig={chartConfig}
109+
onDataPointClick={onDataPointClick}
110+
onBlur={onBlur}
111+
onFocus={onFocus}
112+
onKeyDownCapture={onKeyDownCapture}
113+
/>
114+
<button>after</button>
115+
</>,
116+
);
117+
cy.get('[role="img"][aria-label]').should('have.length', 3);
118+
119+
cy.findByText('before').focus();
120+
121+
// container focused, first scatter "active"
122+
cy.realPress('Tab');
123+
cy.focused()
124+
.should('have.attr', 'tabindex', '0')
125+
.should('have.attr', 'role', 'application')
126+
.should('have.attr', 'aria-roledescription', 'chart');
127+
cy.get('@onFocus').should('have.been.calledOnce');
128+
activePointLabelShould(containerSelector, 'Number: 50');
129+
cy.get('[data-point-focused]').should('have.length', 1);
130+
131+
// 2nd scatter "active" - forward by X
132+
cy.realPress('ArrowRight');
133+
activePointLabelShould(containerSelector, 'Number: 100');
134+
cy.get('@onKeyDownCapture').should('have.been.called');
135+
136+
// 3rd scatter "active"
137+
cy.realPress('ArrowRight');
138+
activePointLabelShould(containerSelector, 'Number: 200');
139+
140+
// 3rd scatter "active" -> last one
141+
cy.realPress('ArrowRight');
142+
activePointLabelShould(containerSelector, 'Number: 200');
143+
144+
// 2nd scatter "active"
145+
cy.realPress('ArrowLeft');
146+
activePointLabelShould(containerSelector, 'Number: 100');
147+
148+
cy.realPress('Enter');
149+
cy.get('@onDataPointClick').should(
150+
'have.been.calledWith',
151+
Cypress.sinon.match({
152+
detail: Cypress.sinon.match({ payload: singleDataset[0].data[0] }),
153+
}),
154+
);
155+
156+
cy.get('[role="img"][aria-label]').eq(2).click();
157+
cy.get('@onDataPointClick').should('have.been.calledTwice');
158+
activePointLabelShould(containerSelector, 'Number: 200');
159+
160+
// Leave chart
161+
cy.realPress('Tab');
162+
cy.focused().should('contain.text', 'after');
163+
cy.get(containerSelector).should('not.have.attr', 'aria-activedescendant');
164+
cy.get('[data-point-focused]').should('not.exist');
165+
cy.get('@onBlur').should('have.been.called');
166+
167+
// Reenter chart
168+
cy.realPress(['Shift', 'Tab']);
169+
cy.focused().should('have.attr', 'aria-roledescription', 'chart');
170+
activePointLabelShould(containerSelector, 'Number: 200');
171+
172+
cy.realPress('ArrowLeft');
173+
activePointLabelShould(containerSelector, 'Number: 100');
174+
cy.realPress('ArrowLeft');
175+
activePointLabelShould(containerSelector, 'Number: 50');
176+
cy.realPress('ArrowLeft');
177+
activePointLabelShould(containerSelector, 'Number: 50');
178+
});
179+
180+
it('accessibilityLayer: multi-dataset points sorted by X then datasetIndex', () => {
181+
const chartConfig = { accessibilityLayer: true };
182+
const containerSelector = '[aria-roledescription="chart"]';
183+
const multiDataset = [
184+
{
185+
label: 'Alpha',
186+
data: [{ users: 30, sessions: 100, volume: 200 }],
187+
},
188+
{
189+
label: 'Beta',
190+
data: [
191+
{ users: 30, sessions: 150, volume: 250 },
192+
{ users: 60, sessions: 300, volume: 400 },
193+
],
194+
},
195+
];
196+
197+
cy.mount(
198+
<>
199+
<button>before</button>
200+
<ScatterChart dataset={multiDataset} measures={measures} chartConfig={chartConfig} />
201+
</>,
202+
);
203+
204+
cy.get('[role="img"][aria-label]').should('have.length', 3);
205+
cy.findByText('before').focus();
206+
cy.realPress('Tab');
207+
208+
// Same X value (30): sorted by dataset index, Alpha (0) before Beta (1)
209+
activePointLabelShould(containerSelector, 'Alpha');
210+
cy.realPress('ArrowRight');
211+
activePointLabelShould(containerSelector, 'Beta', 'Number: 30');
212+
cy.realPress('ArrowRight');
213+
activePointLabelShould(containerSelector, 'Beta', 'Number: 60');
214+
});
215+
216+
it('accessibilityLayer: multiple charts', () => {
217+
const chartConfig = { accessibilityLayer: true };
218+
const singleDataset = [
219+
{
220+
label: 'Series A',
221+
data: [
222+
{ users: 100, sessions: 200, volume: 300 },
223+
{ users: 50, sessions: 150, volume: 250 },
224+
],
225+
},
226+
];
227+
228+
cy.mount(
229+
<>
230+
<button>before</button>
231+
<ScatterChart
232+
dataset={singleDataset}
233+
measures={measures}
234+
chartConfig={chartConfig}
235+
aria-roledescription="chart1"
236+
/>
237+
<ScatterChart
238+
dataset={singleDataset}
239+
measures={measures}
240+
chartConfig={chartConfig}
241+
aria-roledescription="chart2"
242+
/>
243+
<button>after</button>
244+
</>,
245+
);
246+
247+
cy.get('[role="img"][id]').then(($els) => {
248+
const ids = [...$els].map((el) => el.id);
249+
expect(new Set(ids).size).to.equal(ids.length);
250+
});
251+
252+
cy.findByText('before').focus();
253+
cy.realPress('Tab');
254+
cy.focused().should('have.attr', 'aria-roledescription', 'chart1');
255+
cy.realPress('ArrowRight');
256+
activePointLabelShould('[aria-roledescription="chart1"]:first', 'Number: 100');
257+
258+
cy.realPress('Tab');
259+
cy.focused().should('have.attr', 'aria-roledescription', 'chart2');
260+
cy.realPress('ArrowRight');
261+
activePointLabelShould('[aria-roledescription="chart2"]:first', 'Number: 100');
262+
263+
cy.realPress('Tab');
264+
cy.focused().should('contain.text', 'after');
265+
});
266+
267+
[false, true].forEach((accessibilityLayer) => {
268+
it(`empty dataset (accessibilityLayer: ${accessibilityLayer})`, () => {
269+
cy.mount(<ScatterChart dataset={[]} measures={measures} chartConfig={{ accessibilityLayer }} />);
270+
cy.get('.recharts-scatter').should('not.exist');
271+
cy.findByText('Loading...').should('exist');
272+
if (accessibilityLayer) {
273+
cy.get('[aria-roledescription="chart"]')
274+
.should('have.attr', 'tabindex', '0')
275+
.should('not.have.attr', 'role', 'application');
276+
}
277+
});
70278
});
71279

72280
testChartLegendConfig(ScatterChart, { dataset: complexDataSet, measures });

packages/charts/src/components/ScatterChart/ScatterChart.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import LegendStory from '../../resources/LegendConfig.mdx';
2525

2626
<Canvas of={ComponentStories.WithCustomColor} />
2727

28+
### With Accessibility Layer
29+
30+
<Canvas of={ComponentStories.WithAccessibilityLayer} />
31+
2832
### Loading Placeholder
2933

3034
<Canvas of={ComponentStories.LoadingPlaceholder} />
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.scatterchart {
2+
g:focus,
3+
path:focus {
4+
outline: none;
5+
}
6+
7+
:global(.recharts-scatter-symbol):focus path,
8+
:global(.recharts-scatter-symbol)[data-point-focused] path {
9+
stroke: var(--sapContent_FocusColor);
10+
stroke-width: calc(var(--sapContent_FocusWidth) * 2);
11+
paint-order: stroke;
12+
}
13+
}

packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export const WithCustomColor: Story = {
6060
},
6161
};
6262

63+
export const WithAccessibilityLayer: Story = {
64+
args: {
65+
chartConfig: { accessibilityLayer: true },
66+
},
67+
};
68+
6369
export const LoadingPlaceholder: Story = {
6470
args: {
6571
dataset: [],

packages/charts/src/components/ScatterChart/index.tsx

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
'use client';
22

3-
import { useIsRTL, useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks';
3+
import { useIsRTL, useStylesheet, useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks';
44
import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils';
55
import { ThemingParameters } from '@ui5/webcomponents-react-base/ThemingParameters';
6+
import { clsx } from 'clsx';
67
import type { CSSProperties } from 'react';
78
import { forwardRef, useCallback, useRef } from 'react';
89
import type { ReferenceLineProps } from 'recharts';
@@ -32,6 +33,8 @@ import { tickLineConfig, tooltipContentStyle, tooltipFillOpacity, xAxisPadding }
3233
import { XAxisTicks } from '../../internal/XAxisTicks.js';
3334
import { YAxisTicks } from '../../internal/YAxisTicks.js';
3435
import { ScatterChartPlaceholder } from './Placeholder.js';
36+
import { classNames, styleData } from './ScatterChart.module.css.js';
37+
import { useScatterPointFocus } from './useScatterPointFocus.js';
3538

3639
interface MeasureConfig extends Omit<IChartMeasure, 'color' | 'hideDataLabel' | 'DataLabel'> {
3740
/**
@@ -215,9 +218,43 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((props, ref)
215218
const [yAxisWidth, legendPosition] = useLongestYAxisLabel(dataset?.[0]?.data, [yMeasure], chartConfig.legendPosition);
216219
const xAxisHeights = useObserveXAxisHeights(chartRef, 1);
217220
const marginChart = useChartMargin(chartConfig.margin, chartConfig.zoomingTool);
218-
const { chartConfig: _0, measures: _1, ...propsWithoutOmitted } = rest;
221+
const {
222+
chartConfig: _0,
223+
measures: _1,
224+
onBlur: consumerOnBlur,
225+
onFocus: consumerOnFocus,
226+
onKeyDownCapture: consumerOnKeyDownCapture,
227+
...propsWithoutOmitted
228+
} = rest;
219229
const isRTL = useIsRTL(chartRef);
220230

231+
useStylesheet(styleData, ScatterChart.displayName);
232+
233+
const { containerProps: pointFocusProps, handlePointClick } = useScatterPointFocus({
234+
chartRef,
235+
enabled: !!chartConfig.accessibilityLayer,
236+
dataset: dataset ?? [],
237+
measures,
238+
consumerOnBlur,
239+
consumerOnFocus,
240+
consumerOnKeyDownCapture,
241+
onSelect: useCallback(
242+
(point, e) => {
243+
if (typeof onDataPointClick !== 'function') {
244+
return;
245+
}
246+
onDataPointClick(
247+
enrichEventWithDetails(e as unknown as CustomEvent, {
248+
value: point.raw,
249+
dataIndex: point.pointIndex,
250+
payload: point.raw,
251+
}),
252+
);
253+
},
254+
[onDataPointClick],
255+
),
256+
});
257+
221258
return (
222259
<ChartContainer
223260
dataset={dataset}
@@ -229,13 +266,16 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((props, ref)
229266
className={className}
230267
slot={slot}
231268
resizeDebounce={chartConfig.resizeDebounce}
269+
{...pointFocusProps}
232270
{...propsWithoutOmitted}
233271
>
234272
<ScatterChartLib
235273
onClick={onClickInternal}
236274
margin={marginChart}
237-
accessibilityLayer={chartConfig.accessibilityLayer}
238-
className={typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined}
275+
className={clsx(
276+
typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined,
277+
classNames.scatterchart,
278+
)}
239279
>
240280
<CartesianGrid
241281
vertical={chartConfig.gridVertical}
@@ -284,13 +324,17 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((props, ref)
284324
return (
285325
<Scatter
286326
className={typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined}
287-
onMouseDown={onDataPointClickInternal}
327+
onMouseDown={(payload, pointIndex, event) => {
328+
onDataPointClickInternal(payload, pointIndex, event);
329+
handlePointClick?.(index, pointIndex);
330+
}}
288331
opacity={dataSet.opacity}
289332
data={dataSet?.data}
290333
name={dataSet?.label}
291334
key={dataSet?.label}
292335
fill={dataSet?.color ?? `var(--sapChart_OrderedColor_${(index % 12) + 1})`}
293-
isAnimationActive={!noAnimation}
336+
// Animation recreates DOM elements, wiping a11y attributes.
337+
isAnimationActive={!noAnimation && !chartConfig.accessibilityLayer}
294338
/>
295339
);
296340
})}

0 commit comments

Comments
 (0)