Skip to content
202 changes: 202 additions & 0 deletions cypress/support/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getRGBColor } from '@ui5/webcomponents-base/dist/util/ColorConversion.js';
import type { ComponentType } from 'react';
import { useState } from 'react';

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

export function testPieSectorFocus(Component, props, { only }: { only?: boolean } = {}) {
const chartConfig = { accessibilityLayer: true };
const containerSelector = '[aria-roledescription="chart"]';
const test = only ? it.only : it;

test('sector focus - keyboard navigation: Tab, arrows, Enter', () => {
const onDataPointClick = cy.spy().as('onDataPointClick');
cy.mount(
<>
<button>before</button>
<Component {...props} noAnimation chartConfig={chartConfig} onDataPointClick={onDataPointClick} />
<button>after</button>
</>,
);

cy.findByText('before').focus();
cy.realPress('Tab');
cy.focused()
.should('have.attr', 'tabindex', '0')
.should('have.attr', 'role', 'application')
.should('have.attr', 'aria-roledescription', 'chart');

cy.realPress('Tab');
cy.focused()
.should('have.attr', 'data-sector-index', '0')
.and('have.attr', 'role', 'img')
.and('have.attr', 'aria-label');

cy.realPress('ArrowLeft');
cy.focused().should('have.attr', 'data-sector-index', '1');
cy.realPress('ArrowRight');
cy.focused().should('have.attr', 'data-sector-index', '0');

// Wraps from first to last
cy.realPress('ArrowRight');
cy.focused().should('have.attr', 'data-sector-index', String(props.dataset.length - 1));

cy.realPress('Enter');
cy.get('@onDataPointClick').should(
'have.been.calledWith',
Cypress.sinon.match({
detail: Cypress.sinon.match({
dataIndex: props.dataset.length - 1,
}),
}),
);

cy.realPress(['Shift', 'Tab']);
cy.focused().should('have.attr', 'aria-roledescription', 'chart').and('have.attr', 'tabindex', '0');
});

test('sector focus - activeSegment with Enter and Space', () => {
const onDataPointClick = cy.spy().as('onDataPointClick');
const StatefulChart = () => {
const [activeSegment, setActiveSegment] = useState(3);
return (
<>
<button>before</button>
<Component
{...props}
noAnimation
chartConfig={{ ...chartConfig, activeSegment }}
onDataPointClick={(e) => {
onDataPointClick(e);
setActiveSegment(e.detail.dataIndex);
}}
/>
</>
);
};
cy.mount(<StatefulChart />);
cy.findByText('before').focus();
cy.realPress('Tab');

// Tab focuses the activeSegment
cy.realPress('Tab');
cy.focused().should('have.attr', 'data-sector-index', '3');

cy.realPress('ArrowLeft');
cy.focused().should('have.attr', 'data-sector-index', '4');
cy.realPress('Enter');
cy.get('@onDataPointClick').should(
'have.been.calledWith',
Cypress.sinon.match({
detail: Cypress.sinon.match({
dataIndex: 4,
}),
}),
);
cy.get('.recharts-active-shape').should('exist');
cy.focused().should('have.attr', 'data-sector-index', '4');

cy.realPress('ArrowLeft');
cy.focused().should('have.attr', 'data-sector-index', '5');

// Space activates on keyup — hold Space, arrow to next sector, then release
cy.focused().then(($el) => $el[0].dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })));
cy.realPress('ArrowLeft');
cy.focused().should('have.attr', 'data-sector-index', '6');
cy.focused().then(($el) => $el[0].dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true })));
cy.get('@onDataPointClick').should(
'have.been.calledWith',
Cypress.sinon.match({
detail: Cypress.sinon.match({
dataIndex: 6,
}),
}),
);
cy.focused().should('have.attr', 'data-sector-index', '6');
});

test('sector focus - activeSegment out of bounds is clamped', () => {
cy.mount(
<>
<button>before</button>
<Component {...props} noAnimation chartConfig={{ ...chartConfig, activeSegment: 999 }} />
</>,
);
cy.findByText('before').focus();
cy.realPress('Tab');
cy.realPress('Tab');
cy.focused().should('have.attr', 'data-sector-index', String(props.dataset.length - 1));
});

test('sector focus - empty dataset is non-interactive', () => {
cy.mount(<Component {...props} dataset={[]} noAnimation chartConfig={chartConfig} />);
cy.get(containerSelector)
.should('have.attr', 'tabindex', '0')
.should('have.attr', 'aria-roledescription', 'chart')
.should('not.have.attr', 'role', 'application');
});

test('sector focus - dataset shrink resets keyboard state', () => {
const initialDataset = props.dataset;
const smallDataset = initialDataset.slice(0, 3);
const baseProps = { ...props, noAnimation: true, chartConfig };
const StatefulChart = () => {
const [ds, setDs] = useState(initialDataset);
return (
<>
<button>before</button>
<button onClick={() => setDs(smallDataset)}>shrink</button>
<Component {...baseProps} dataset={ds} />
</>
);
};
cy.mount(<StatefulChart />);
cy.findByText('before').focus();
cy.realPress('Tab');
cy.realPress('Tab');
cy.realPress('Tab');

for (let i = 0; i < 5; i++) {
cy.realPress('ArrowLeft');
}
cy.focused().should('have.attr', 'data-sector-index', '5');

cy.findByText('shrink').click();
cy.get(containerSelector).should('have.attr', 'tabindex', '0');

cy.findByText('before').focus();
cy.realPress('Tab');
cy.realPress('Tab');
cy.realPress('Tab');
cy.focused().should('have.attr', 'data-sector-index');
});

test('sector focus - consumer event handlers are composed with internal handlers', () => {
const onBlur = cy.spy().as('onBlur');
const onFocus = cy.spy().as('onFocus');
const onKeyDownCapture = cy.spy().as('onKeyDownCapture');
cy.mount(
<>
<button>before</button>
<Component
{...props}
noAnimation
chartConfig={chartConfig}
onBlur={onBlur}
onFocus={onFocus}
onKeyDownCapture={onKeyDownCapture}
/>
<button>after</button>
</>,
);

cy.findByText('before').focus();
cy.realPress('Tab');
cy.get('@onFocus').should('have.been.calledOnce');

cy.realPress('Tab');
cy.get('@onKeyDownCapture').should('have.been.called');
cy.focused().should('have.attr', 'data-sector-index', '0');

cy.findByText('after').click();
cy.get('@onBlur').should('have.been.called');
// raf defers exitSectorMode, so wait for tabindex to flip back
cy.get(containerSelector).should('have.attr', 'tabindex', '0');
});
}

export function testStackAggregateTotals(Component, props) {
it('showStackAggregateTotals', () => {
const { dataset, measures } = props;
Expand Down
2 changes: 1 addition & 1 deletion packages/charts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ Charts default to `width: 100%` and `height: 400px`, so they render out of the b
**Critical:**

- 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**
- `accessibilityLayer` is **experimental** and only supports categorical/horizontal charts with tooltips
- `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.
- `legendPosition: "middle"` is **not supported** for: ColumnChartWithTrend, DonutChart, PieChart

**Data:**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js';
import { DonutChart } from './index.js';
import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils';
import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils';

const dimension = {
accessor: 'name',
Expand Down Expand Up @@ -63,4 +63,6 @@ describe('DonutChart', () => {
cypressPassThroughTestsFactory(DonutChart, { dimension: {}, measure: {} });

testChartLegendConfig(DonutChart, { dataset: complexDataSet, dimension, measure });

testPieSectorFocus(DonutChart, { dataset: simpleDataSet, dimension, measure });
});
3 changes: 3 additions & 0 deletions packages/charts/src/components/DonutChart/DonutChart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ControlsWithNote, DocsHeader, Footer } from '@sb/components';
import TooltipStory from '../../resources/TooltipConfig.mdx';
import * as ComponentStories from './DonutChart.stories';
import LegendStory from '../../resources/LegendConfig.mdx';
import KeyboardNavigationStory from '../../resources/KeyboardNavigation.mdx';

<Meta of={ComponentStories} />

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

<Canvas of={ComponentStories.WithActiveShape} />

<KeyboardNavigationStory of={ComponentStories.KeyboardNavigation} />

### Hide labels

<Canvas of={ComponentStories.HideLabels} />
Expand Down
54 changes: 31 additions & 23 deletions packages/charts/src/components/DonutChart/DonutChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useEffect, useState } from 'react';
import { legendConfig, simpleDataSet, simpleDataSetWithSmallValues, tooltipConfig } from '../../resources/DemoProps.js';
import {
legendConfig,
simpleDataSet,
simpleDataSetWithSmallValues,
tooltipConfig,
keyboardNavigationStory,
} from '../../resources/DemoProps.js';
import { DonutChart } from './index.js';

const meta = {
Expand Down Expand Up @@ -75,28 +81,6 @@ export const WithFormatter: Story = {
},
};

export const HideLabels: Story = {
args: {
measure: {
accessor: 'users',
hideDataLabel: (chartConfig) => {
if (chartConfig.percent < 0.01) {
return true;
}
},
},
dataset: simpleDataSetWithSmallValues,
},
};

export const WithCustomTooltipConfig: Story = {
args: tooltipConfig,
};

export const WithCustomLegendConfig: Story = {
args: legendConfig,
};

export const WithActiveShape: Story = {
args: {
chartConfig: {
Expand All @@ -120,3 +104,27 @@ export const WithActiveShape: Story = {
return <DonutChart {...args} chartConfig={{ ...args.chartConfig, activeSegment }} onClick={handleChartClick} />;
},
};

export const KeyboardNavigation: Story = keyboardNavigationStory(DonutChart);

export const HideLabels: Story = {
args: {
measure: {
accessor: 'users',
hideDataLabel: (chartConfig) => {
if (chartConfig.percent < 0.01) {
return true;
}
},
},
dataset: simpleDataSetWithSmallValues,
},
};

export const WithCustomTooltipConfig: Story = {
args: tooltipConfig,
};

export const WithCustomLegendConfig: Story = {
args: legendConfig,
};
4 changes: 3 additions & 1 deletion packages/charts/src/components/PieChart/PieChart.cy.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Text as RechartsText } from 'recharts';
import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js';
import { PieChart } from './index.js';
import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils';
import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils';

const dimension = {
accessor: 'name',
Expand Down Expand Up @@ -80,4 +80,6 @@ describe('PieChart', () => {
});

testChartLegendConfig(PieChart, { dataset: complexDataSet, dimension, measure });

testPieSectorFocus(PieChart, { dataset: simpleDataSet, dimension, measure });
});
7 changes: 7 additions & 0 deletions packages/charts/src/components/PieChart/PieChart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import TooltipStory from '../../resources/TooltipConfig.mdx';
import * as ComponentStories from './PieChart.stories';
import LegendStory from '../../resources/LegendConfig.mdx';
import KeyboardNavigationStory from '../../resources/KeyboardNavigation.mdx';

<Meta of={ComponentStories} />

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

<Canvas of={ComponentStories.WithFormatter} />

### With highlighted active segment

<Canvas of={ComponentStories.WithActiveShape} />

<KeyboardNavigationStory of={ComponentStories.KeyboardNavigation} />

### Hide labels

<Canvas of={ComponentStories.HideLabels} />
Expand Down
6 changes: 6 additions & 0 deletions packages/charts/src/components/PieChart/PieChart.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
outline: none;
}

:global(.recharts-pie-sector):focus path {
stroke: var(--sapContent_FocusColor);
stroke-width: calc(var(--sapContent_FocusWidth) * 2);
paint-order: stroke;
}

:global(.recharts-legend-item-text) {
color: var(--sapTextColor) !important;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/charts/src/components/PieChart/PieChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useEffect, useState } from 'react';
import { legendConfig, simpleDataSet, simpleDataSetWithSmallValues, tooltipConfig } from '../../resources/DemoProps.js';
import {
legendConfig,
simpleDataSet,
simpleDataSetWithSmallValues,
tooltipConfig,
keyboardNavigationStory,
} from '../../resources/DemoProps.js';
import { PieChart } from './index.js';

const meta = {
Expand Down Expand Up @@ -91,6 +97,8 @@ export const WithActiveShape: Story = {
},
};

export const KeyboardNavigation: Story = keyboardNavigationStory(PieChart);

export const HideLabels: Story = {
args: {
measure: {
Expand Down
Loading
Loading