Skip to content

Commit ae69404

Browse files
authored
feat(Pie- & DonutChart): enable section focus & arrow key nav (#8514)
Closes #7609
1 parent a7a8167 commit ae69404

15 files changed

Lines changed: 696 additions & 36 deletions

File tree

‎cypress/support/utils.tsx‎

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getRGBColor } from '@ui5/webcomponents-base/dist/util/ColorConversion.js';
22
import type { ComponentType } from 'react';
3+
import { useState } from 'react';
34

45
export function cypressPassThroughTestsFactory(Component: ComponentType, props?: Record<string, unknown>) {
56
it('Pass Through HTML Standard Props', () => {
@@ -101,6 +102,207 @@ export function testChartLegendConfig(Component, props) {
101102
});
102103
}
103104

105+
export function testPieSectorFocus(Component, props, { only }: { only?: boolean } = {}) {
106+
const chartConfig = { accessibilityLayer: true };
107+
const containerSelector = '[aria-roledescription="chart"]';
108+
const test = only ? it.only : it;
109+
110+
test('sector focus - keyboard navigation: Tab, arrows, Enter', () => {
111+
const onDataPointClick = cy.spy().as('onDataPointClick');
112+
cy.mount(
113+
<>
114+
<button>before</button>
115+
<Component {...props} noAnimation chartConfig={chartConfig} onDataPointClick={onDataPointClick} />
116+
<button>after</button>
117+
</>,
118+
);
119+
120+
cy.findByText('before').focus();
121+
cy.realPress('Tab');
122+
cy.focused()
123+
.should('have.attr', 'tabindex', '0')
124+
.should('have.attr', 'role', 'application')
125+
.should('have.attr', 'aria-roledescription', 'chart');
126+
127+
cy.realPress('Tab');
128+
cy.focused()
129+
.should('have.attr', 'data-sector-index', '0')
130+
.and('have.attr', 'role', 'img')
131+
.and('have.attr', 'aria-label');
132+
133+
cy.realPress('ArrowLeft');
134+
cy.focused().should('have.attr', 'data-sector-index', '1');
135+
cy.realPress('ArrowRight');
136+
cy.focused().should('have.attr', 'data-sector-index', '0');
137+
138+
// Wraps from first to last
139+
cy.realPress('ArrowRight');
140+
cy.focused().should('have.attr', 'data-sector-index', String(props.dataset.length - 1));
141+
142+
cy.realPress('Enter');
143+
cy.get('@onDataPointClick').should(
144+
'have.been.calledWith',
145+
Cypress.sinon.match({
146+
detail: Cypress.sinon.match({
147+
dataIndex: props.dataset.length - 1,
148+
}),
149+
}),
150+
);
151+
152+
cy.realPress(['Shift', 'Tab']);
153+
cy.focused().should('have.attr', 'aria-roledescription', 'chart').and('have.attr', 'tabindex', '0');
154+
});
155+
156+
test('sector focus - activeSegment with Enter and Space', () => {
157+
const onDataPointClick = cy.spy().as('onDataPointClick');
158+
const StatefulChart = () => {
159+
const [activeSegment, setActiveSegment] = useState(3);
160+
return (
161+
<>
162+
<button>before</button>
163+
<Component
164+
{...props}
165+
noAnimation
166+
chartConfig={{ ...chartConfig, activeSegment }}
167+
onDataPointClick={(e) => {
168+
onDataPointClick(e);
169+
setActiveSegment(e.detail.dataIndex);
170+
}}
171+
/>
172+
</>
173+
);
174+
};
175+
cy.mount(<StatefulChart />);
176+
cy.findByText('before').focus();
177+
cy.realPress('Tab');
178+
179+
// Tab focuses the activeSegment
180+
cy.realPress('Tab');
181+
cy.focused().should('have.attr', 'data-sector-index', '3');
182+
183+
cy.realPress('ArrowLeft');
184+
cy.focused().should('have.attr', 'data-sector-index', '4');
185+
cy.realPress('Enter');
186+
cy.get('@onDataPointClick').should(
187+
'have.been.calledWith',
188+
Cypress.sinon.match({
189+
detail: Cypress.sinon.match({
190+
dataIndex: 4,
191+
}),
192+
}),
193+
);
194+
cy.get('.recharts-active-shape').should('exist');
195+
cy.focused().should('have.attr', 'data-sector-index', '4');
196+
197+
cy.realPress('ArrowLeft');
198+
cy.focused().should('have.attr', 'data-sector-index', '5');
199+
200+
// Space activates on keyup — hold Space, arrow to next sector, then release
201+
cy.focused().then(($el) => $el[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })));
202+
cy.realPress('ArrowLeft');
203+
cy.focused().should('have.attr', 'data-sector-index', '6');
204+
cy.focused().then(($el) => $el[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })));
205+
cy.get('@onDataPointClick').should(
206+
'have.been.calledWith',
207+
Cypress.sinon.match({
208+
detail: Cypress.sinon.match({
209+
dataIndex: 6,
210+
}),
211+
}),
212+
);
213+
cy.focused().should('have.attr', 'data-sector-index', '6');
214+
});
215+
216+
test('sector focus - activeSegment out of bounds is clamped', () => {
217+
cy.mount(
218+
<>
219+
<button>before</button>
220+
<Component {...props} noAnimation chartConfig={{ ...chartConfig, activeSegment: 999 }} />
221+
</>,
222+
);
223+
cy.findByText('before').focus();
224+
cy.realPress('Tab');
225+
cy.realPress('Tab');
226+
cy.focused().should('have.attr', 'data-sector-index', String(props.dataset.length - 1));
227+
});
228+
229+
test('sector focus - empty dataset is non-interactive', () => {
230+
cy.mount(<Component {...props} dataset={[]} noAnimation chartConfig={chartConfig} />);
231+
cy.get(containerSelector)
232+
.should('have.attr', 'tabindex', '0')
233+
.should('have.attr', 'aria-roledescription', 'chart')
234+
.should('not.have.attr', 'role', 'application');
235+
});
236+
237+
test('sector focus - dataset shrink resets keyboard state', () => {
238+
const initialDataset = props.dataset;
239+
const smallDataset = initialDataset.slice(0, 3);
240+
const baseProps = { ...props, noAnimation: true, chartConfig };
241+
const StatefulChart = () => {
242+
const [ds, setDs] = useState(initialDataset);
243+
return (
244+
<>
245+
<button>before</button>
246+
<button onClick={() => setDs(smallDataset)}>shrink</button>
247+
<Component {...baseProps} dataset={ds} />
248+
</>
249+
);
250+
};
251+
cy.mount(<StatefulChart />);
252+
cy.findByText('before').focus();
253+
cy.realPress('Tab');
254+
cy.realPress('Tab');
255+
cy.realPress('Tab');
256+
257+
for (let i = 0; i < 5; i++) {
258+
cy.realPress('ArrowLeft');
259+
}
260+
cy.focused().should('have.attr', 'data-sector-index', '5');
261+
262+
cy.findByText('shrink').click();
263+
cy.get(containerSelector).should('have.attr', 'tabindex', '0');
264+
265+
cy.findByText('before').focus();
266+
cy.realPress('Tab');
267+
cy.realPress('Tab');
268+
cy.realPress('Tab');
269+
cy.focused().should('have.attr', 'data-sector-index');
270+
});
271+
272+
test('sector focus - consumer event handlers are composed with internal handlers', () => {
273+
const onBlur = cy.spy().as('onBlur');
274+
const onFocus = cy.spy().as('onFocus');
275+
const onKeyDownCapture = cy.spy().as('onKeyDownCapture');
276+
cy.mount(
277+
<>
278+
<button>before</button>
279+
<Component
280+
{...props}
281+
noAnimation
282+
chartConfig={chartConfig}
283+
onBlur={onBlur}
284+
onFocus={onFocus}
285+
onKeyDownCapture={onKeyDownCapture}
286+
/>
287+
<button>after</button>
288+
</>,
289+
);
290+
291+
cy.findByText('before').focus();
292+
cy.realPress('Tab');
293+
cy.get('@onFocus').should('have.been.calledOnce');
294+
295+
cy.realPress('Tab');
296+
cy.get('@onKeyDownCapture').should('have.been.called');
297+
cy.focused().should('have.attr', 'data-sector-index', '0');
298+
299+
cy.findByText('after').click();
300+
cy.get('@onBlur').should('have.been.called');
301+
// raf defers exitSectorMode, so wait for tabindex to flip back
302+
cy.get(containerSelector).should('have.attr', 'tabindex', '0');
303+
});
304+
}
305+
104306
export function testStackAggregateTotals(Component, props) {
105307
it('showStackAggregateTotals', () => {
106308
const { dataset, measures } = props;

‎packages/charts/CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ Charts default to `width: 100%` and `height: 400px`, so they render out of the b
218218
**Critical:**
219219

220220
- Charts are **custom-built without defined design specifications** - they use the Fiori color palette, but functionality and especially **accessibility may not meet standard app requirements**
221-
- `accessibilityLayer` is **experimental** and only supports categorical/horizontal charts with tooltips
221+
- `accessibilityLayer` is **experimental**. For categorical/horizontal charts it enables recharts' built-in accessibility with tooltip navigation. For PieChart/DonutChart it enables keyboard navigation through segments using arrow keys.
222222
- `legendPosition: "middle"` is **not supported** for: ColumnChartWithTrend, DonutChart, PieChart
223223

224224
**Data:**

‎packages/charts/src/components/DonutChart/DonutChart.cy.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js';
22
import { DonutChart } from './index.js';
3-
import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils';
3+
import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils';
44

55
const dimension = {
66
accessor: 'name',
@@ -63,4 +63,6 @@ describe('DonutChart', () => {
6363
cypressPassThroughTestsFactory(DonutChart, { dimension: {}, measure: {} });
6464

6565
testChartLegendConfig(DonutChart, { dataset: complexDataSet, dimension, measure });
66+
67+
testPieSectorFocus(DonutChart, { dataset: simpleDataSet, dimension, measure });
6668
});

‎packages/charts/src/components/DonutChart/DonutChart.mdx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ControlsWithNote, DocsHeader, Footer } from '@sb/components';
33
import TooltipStory from '../../resources/TooltipConfig.mdx';
44
import * as ComponentStories from './DonutChart.stories';
55
import LegendStory from '../../resources/LegendConfig.mdx';
6+
import KeyboardNavigationStory from '../../resources/KeyboardNavigation.mdx';
67

78
<Meta of={ComponentStories} />
89

@@ -45,6 +46,8 @@ import LegendStory from '../../resources/LegendConfig.mdx';
4546

4647
<Canvas of={ComponentStories.WithActiveShape} />
4748

49+
<KeyboardNavigationStory of={ComponentStories.KeyboardNavigation} />
50+
4851
### Hide labels
4952

5053
<Canvas of={ComponentStories.HideLabels} />

‎packages/charts/src/components/DonutChart/DonutChart.stories.tsx‎

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import { useEffect, useState } from 'react';
3-
import { legendConfig, simpleDataSet, simpleDataSetWithSmallValues, tooltipConfig } from '../../resources/DemoProps.js';
3+
import {
4+
legendConfig,
5+
simpleDataSet,
6+
simpleDataSetWithSmallValues,
7+
tooltipConfig,
8+
keyboardNavigationStory,
9+
} from '../../resources/DemoProps.js';
410
import { DonutChart } from './index.js';
511

612
const meta = {
@@ -72,28 +78,6 @@ export const WithFormatter: Story = {
7278
},
7379
};
7480

75-
export const HideLabels: Story = {
76-
args: {
77-
measure: {
78-
accessor: 'users',
79-
hideDataLabel: (chartConfig) => {
80-
if (chartConfig.percent < 0.01) {
81-
return true;
82-
}
83-
},
84-
},
85-
dataset: simpleDataSetWithSmallValues,
86-
},
87-
};
88-
89-
export const WithCustomTooltipConfig: Story = {
90-
args: tooltipConfig,
91-
};
92-
93-
export const WithCustomLegendConfig: Story = {
94-
args: legendConfig,
95-
};
96-
9781
export const WithActiveShape: Story = {
9882
args: {
9983
chartConfig: {
@@ -117,3 +101,27 @@ export const WithActiveShape: Story = {
117101
return <DonutChart {...args} chartConfig={{ ...args.chartConfig, activeSegment }} onClick={handleChartClick} />;
118102
},
119103
};
104+
105+
export const KeyboardNavigation: Story = keyboardNavigationStory(DonutChart);
106+
107+
export const HideLabels: Story = {
108+
args: {
109+
measure: {
110+
accessor: 'users',
111+
hideDataLabel: (chartConfig) => {
112+
if (chartConfig.percent < 0.01) {
113+
return true;
114+
}
115+
},
116+
},
117+
dataset: simpleDataSetWithSmallValues,
118+
},
119+
};
120+
121+
export const WithCustomTooltipConfig: Story = {
122+
args: tooltipConfig,
123+
};
124+
125+
export const WithCustomLegendConfig: Story = {
126+
args: legendConfig,
127+
};

‎packages/charts/src/components/PieChart/PieChart.cy.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Text as RechartsText } from 'recharts';
22
import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js';
33
import { PieChart } from './index.js';
4-
import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils';
4+
import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils';
55

66
const dimension = {
77
accessor: 'name',
@@ -80,4 +80,6 @@ describe('PieChart', () => {
8080
});
8181

8282
testChartLegendConfig(PieChart, { dataset: complexDataSet, dimension, measure });
83+
84+
testPieSectorFocus(PieChart, { dataset: simpleDataSet, dimension, measure });
8385
});

‎packages/charts/src/components/PieChart/PieChart.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
33
import TooltipStory from '../../resources/TooltipConfig.mdx';
44
import * as ComponentStories from './PieChart.stories';
55
import LegendStory from '../../resources/LegendConfig.mdx';
6+
import KeyboardNavigationStory from '../../resources/KeyboardNavigation.mdx';
67

78
<Meta of={ComponentStories} />
89

@@ -33,6 +34,12 @@ import LegendStory from '../../resources/LegendConfig.mdx';
3334

3435
<Canvas of={ComponentStories.WithFormatter} />
3536

37+
### With highlighted active segment
38+
39+
<Canvas of={ComponentStories.WithActiveShape} />
40+
41+
<KeyboardNavigationStory of={ComponentStories.KeyboardNavigation} />
42+
3643
### Hide labels
3744

3845
<Canvas of={ComponentStories.HideLabels} />

‎packages/charts/src/components/PieChart/PieChart.module.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
outline: none;
55
}
66

7+
:global(.recharts-pie-sector):focus path {
8+
stroke: var(--sapContent_FocusColor);
9+
stroke-width: calc(var(--sapContent_FocusWidth) * 2);
10+
paint-order: stroke;
11+
}
12+
713
:global(.recharts-legend-item-text) {
814
color: var(--sapTextColor) !important;
915
}

‎packages/charts/src/components/PieChart/PieChart.stories.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import { useEffect, useState } from 'react';
3-
import { legendConfig, simpleDataSet, simpleDataSetWithSmallValues, tooltipConfig } from '../../resources/DemoProps.js';
3+
import {
4+
legendConfig,
5+
simpleDataSet,
6+
simpleDataSetWithSmallValues,
7+
tooltipConfig,
8+
keyboardNavigationStory,
9+
} from '../../resources/DemoProps.js';
410
import { PieChart } from './index.js';
511

612
const meta = {
@@ -86,6 +92,8 @@ export const WithActiveShape: Story = {
8692
},
8793
};
8894

95+
export const KeyboardNavigation: Story = keyboardNavigationStory(PieChart);
96+
8997
export const HideLabels: Story = {
9098
args: {
9199
measure: {

0 commit comments

Comments
 (0)