Skip to content
Draft
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
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('ArrowRight');
cy.focused().should('have.attr', 'data-sector-index', '1');
cy.realPress('ArrowLeft');
cy.focused().should('have.attr', 'data-sector-index', '0');

// Wraps from first to last
cy.realPress('ArrowLeft');
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('ArrowRight');
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('ArrowRight');
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('ArrowRight');
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('ArrowRight');
}
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;
}

[data-active-legend] {
background: color-mix(in srgb, var(--sapSelectedColor), transparent 87%);
:global(.recharts-legend-item-text) {
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