diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx index 4c35c2ac5f2..fd1ec6b36ee 100644 --- a/cypress/support/utils.tsx +++ b/cypress/support/utils.tsx @@ -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) { it('Pass Through HTML Standard Props', () => { @@ -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( + <> + + + + , + ); + + 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 ( + <> + + { + onDataPointClick(e); + setActiveSegment(e.detail.dataIndex); + }} + /> + + ); + }; + cy.mount(); + 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( + <> + + + , + ); + 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(); + 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 ( + <> + + + + + ); + }; + cy.mount(); + 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( + <> + + + + , + ); + + 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; diff --git a/packages/charts/CLAUDE.md b/packages/charts/CLAUDE.md index 96142ff9fb3..2a1e7109df5 100644 --- a/packages/charts/CLAUDE.md +++ b/packages/charts/CLAUDE.md @@ -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:** diff --git a/packages/charts/src/components/DonutChart/DonutChart.cy.tsx b/packages/charts/src/components/DonutChart/DonutChart.cy.tsx index c14f4afa303..b9bd1df68b7 100644 --- a/packages/charts/src/components/DonutChart/DonutChart.cy.tsx +++ b/packages/charts/src/components/DonutChart/DonutChart.cy.tsx @@ -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', @@ -63,4 +63,6 @@ describe('DonutChart', () => { cypressPassThroughTestsFactory(DonutChart, { dimension: {}, measure: {} }); testChartLegendConfig(DonutChart, { dataset: complexDataSet, dimension, measure }); + + testPieSectorFocus(DonutChart, { dataset: simpleDataSet, dimension, measure }); }); diff --git a/packages/charts/src/components/DonutChart/DonutChart.mdx b/packages/charts/src/components/DonutChart/DonutChart.mdx index 186dbec2ed2..37a276be0bf 100644 --- a/packages/charts/src/components/DonutChart/DonutChart.mdx +++ b/packages/charts/src/components/DonutChart/DonutChart.mdx @@ -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'; @@ -45,6 +46,8 @@ import LegendStory from '../../resources/LegendConfig.mdx'; + + ### Hide labels diff --git a/packages/charts/src/components/DonutChart/DonutChart.stories.tsx b/packages/charts/src/components/DonutChart/DonutChart.stories.tsx index a2a09378d65..5cf92a5ae45 100644 --- a/packages/charts/src/components/DonutChart/DonutChart.stories.tsx +++ b/packages/charts/src/components/DonutChart/DonutChart.stories.tsx @@ -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 = { @@ -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: { @@ -120,3 +104,27 @@ export const WithActiveShape: Story = { return ; }, }; + +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, +}; diff --git a/packages/charts/src/components/PieChart/PieChart.cy.tsx b/packages/charts/src/components/PieChart/PieChart.cy.tsx index 8dd5d4223a2..81e181b07de 100644 --- a/packages/charts/src/components/PieChart/PieChart.cy.tsx +++ b/packages/charts/src/components/PieChart/PieChart.cy.tsx @@ -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', @@ -80,4 +80,6 @@ describe('PieChart', () => { }); testChartLegendConfig(PieChart, { dataset: complexDataSet, dimension, measure }); + + testPieSectorFocus(PieChart, { dataset: simpleDataSet, dimension, measure }); }); diff --git a/packages/charts/src/components/PieChart/PieChart.mdx b/packages/charts/src/components/PieChart/PieChart.mdx index 1b7dbcff3c8..f40adc35f12 100644 --- a/packages/charts/src/components/PieChart/PieChart.mdx +++ b/packages/charts/src/components/PieChart/PieChart.mdx @@ -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'; @@ -33,6 +34,12 @@ import LegendStory from '../../resources/LegendConfig.mdx'; +### With highlighted active segment + + + + + ### Hide labels diff --git a/packages/charts/src/components/PieChart/PieChart.module.css b/packages/charts/src/components/PieChart/PieChart.module.css index 1c18aa43261..55a191e8cfc 100644 --- a/packages/charts/src/components/PieChart/PieChart.module.css +++ b/packages/charts/src/components/PieChart/PieChart.module.css @@ -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) { diff --git a/packages/charts/src/components/PieChart/PieChart.stories.tsx b/packages/charts/src/components/PieChart/PieChart.stories.tsx index de0b8e6eaca..14b400da27b 100644 --- a/packages/charts/src/components/PieChart/PieChart.stories.tsx +++ b/packages/charts/src/components/PieChart/PieChart.stories.tsx @@ -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 = { @@ -91,6 +97,8 @@ export const WithActiveShape: Story = { }, }; +export const KeyboardNavigation: Story = keyboardNavigationStory(PieChart); + export const HideLabels: Story = { args: { measure: { diff --git a/packages/charts/src/components/PieChart/index.tsx b/packages/charts/src/components/PieChart/index.tsx index fb7592b51e2..1a419c89099 100644 --- a/packages/charts/src/components/PieChart/index.tsx +++ b/packages/charts/src/components/PieChart/index.tsx @@ -27,6 +27,7 @@ import { defaultFormatter } from '../../internal/defaults.js'; import { tooltipContentStyle, tooltipFillOpacity } from '../../internal/staticProps.js'; import { classNames, styleData } from './PieChart.module.css.js'; import { PieChartPlaceholder } from './Placeholder.js'; +import { usePieSectorFocus } from './usePieSectorFocus.js'; interface MeasureConfig extends Omit { /** @@ -144,10 +145,65 @@ const PieChart = forwardRef((props, ref) => { [props.measure], ); + const { + chartConfig: _0, + dimension: _1, + measure: _2, + onBlur: consumerOnBlur, + onFocus: consumerOnFocus, + onKeyDownCapture: consumerOnKeyDownCapture, + ...propsWithoutOmitted + } = rest; + + const { containerProps: sectorFocusProps, handleSectorClick } = usePieSectorFocus({ + chartRef, + enabled: !!chartConfig.accessibilityLayer, + activeSegment: chartConfig.activeSegment, + dataLength: dataset?.length ?? 0, + consumerOnBlur, + consumerOnFocus, + consumerOnKeyDownCapture, + onSelect: useCallback( + (index, e) => { + if (typeof onDataPointClick !== 'function' || !dataset?.[index]) { + return; + } + const entry = dataset[index]; + onDataPointClick( + enrichEventWithDetails(e as unknown as CustomEvent, { + value: getValueByDataKey(entry, measure.accessor), + dataKey: measure.accessor, + name: getValueByDataKey(entry, dimension.accessor, ''), + payload: entry, + dataIndex: index, + }), + ); + }, + [onDataPointClick, dataset, measure.accessor, dimension.accessor], + ), + getSectorLabel: useCallback( + (index: number) => { + if (!dataset?.[index]) { + return ''; + } + const entry = dataset[index]; + const name = dimension.formatter(getValueByDataKey(entry, dimension.accessor, '')); + const value = measure.formatter(getValueByDataKey(entry, measure.accessor)); + const rawValue = Number(getValueByDataKey(entry, measure.accessor)) || 0; + const total = dataset.reduce((sum, d) => sum + (Number(getValueByDataKey(d, measure.accessor)) || 0), 0); + const pct = total > 0 ? ((rawValue / total) * 100).toFixed(1) : '0'; + return `${name}, ${value}, ${pct}%`; + }, + [dataset, dimension, measure], + ), + }); + const dataLabel = (props) => { const hideDataLabel = typeof measure.hideDataLabel === 'function' ? measure.hideDataLabel(props) : measure.hideDataLabel; - if (hideDataLabel || chartConfig.activeSegment === props.index) return null; + if (hideDataLabel || chartConfig.activeSegment === props.index) { + return null; + } if (isValidElement(measure.DataLabel)) { return cloneElement(measure.DataLabel, { ...props, config: measure }); @@ -180,8 +236,9 @@ const PieChart = forwardRef((props, ref) => { }), ); } + handleSectorClick(dataIndex); }, - [onDataPointClick], + [onDataPointClick, handleSectorClick], ); // REUSE: part of this function is copied from: https://github.com/recharts/recharts/blob/411e57a3c206a1425ff33a7e63cacf40a844e551/storybook/stories/Examples/Pie/CustomActiveShapePieChart.stories.tsx#L22-L44 @@ -255,7 +312,9 @@ const PieChart = forwardRef((props, ref) => { (props) => { const hideDataLabel = typeof measure.hideDataLabel === 'function' ? measure.hideDataLabel(props) : measure.hideDataLabel; - if (hideDataLabel || chartConfig.activeSegment === props.index) return null; + if (hideDataLabel || chartConfig.activeSegment === props.index) { + return null; + } return Pie.renderLabelLineItem({}, props, undefined); }, [chartConfig.activeSegment, measure], @@ -277,8 +336,6 @@ const PieChart = forwardRef((props, ref) => { return null; }, [showActiveSegmentDataLabel, chartConfig.activeSegment, chartConfig.legendPosition]); - const { chartConfig: _0, dimension: _1, measure: _2, ...propsWithoutOmitted } = rest; - return ( ((props, ref) => { className={className} slot={slot} resizeDebounce={chartConfig.resizeDebounce} + {...sectorFocusProps} {...propsWithoutOmitted} > ; + enabled: boolean; + activeSegment?: number; + dataLength: number; + onSelect?: (index: number, event: KeyboardEvent) => void; + getSectorLabel?: (index: number) => string; + consumerOnBlur?: FocusEventHandler; + consumerOnFocus?: FocusEventHandler; + consumerOnKeyDownCapture?: KeyboardEventHandler; +} + +/** + * Manages keyboard navigation through pie/donut chart sectors. Only one sector is tabbable at a time; arrow keys move focus between sectors. + * + * Active when `chartConfig.accessibilityLayer` is enabled. + */ +export function usePieSectorFocus({ + chartRef, + enabled, + activeSegment, + dataLength, + onSelect, + getSectorLabel, + consumerOnBlur, + consumerOnFocus, + consumerOnKeyDownCapture, +}: UsePieSectorFocusOptions) { + const sectorFocusRef = useRef(-1); + const lastSectorRef = useRef(-1); + const spaceHeldRef = useRef(false); + const rafIdRef = useRef(0); + const [inSectorMode, setInSectorMode] = useState(false); + const getSectorLabelRef = useRef(getSectorLabel); + // Keep ref in sync so focusSector always uses the latest callback without re-creating the memoized function. + useEffect(() => { + getSectorLabelRef.current = getSectorLabel; + }); + + // Reset keyboard state when dataset size changes to prevent stale sector indices. + useEffect(() => { + sectorFocusRef.current = -1; + lastSectorRef.current = -1; + // Dataset changed - exit sector mode so the container becomes tabbable (tabIndex=0) again. + // eslint-disable-next-line react-hooks/set-state-in-effect + setInSectorMode(false); + }, [dataLength]); + + useEffect(() => { + return () => cancelAnimationFrame(rafIdRef.current); + }, []); + + const focusSector = useCallback( + (index: number) => { + const pieGroup = chartRef.current?.querySelector('.recharts-pie'); + if (!pieGroup) { + return; + } + const sectors = pieGroup.querySelectorAll(':scope > .recharts-pie-sector'); + if (!sectors.length) { + return; + } + // Recharts sectors have no identifying attributes, add them so they can be found after DOM reordering + if (!sectors[0].hasAttribute('data-sector-index')) { + sectors.forEach((s, i) => { + s.setAttribute('data-sector-index', String(i)); + s.setAttribute('role', 'img'); + const label = getSectorLabelRef.current?.(i); + if (label) { + s.setAttribute('aria-label', label); + } + }); + } + if (sectorFocusRef.current >= 0) { + pieGroup + .querySelector(`.recharts-pie-sector[data-sector-index="${sectorFocusRef.current}"]`) + ?.removeAttribute('tabindex'); + } + const target = pieGroup.querySelector(`.recharts-pie-sector[data-sector-index="${index}"]`); + if (target) { + // SVG paints in document order - move focused sector last so its focus outline isn't hidden. + pieGroup.appendChild(target); + target.tabIndex = 0; + target.focus(); + } + sectorFocusRef.current = index; + }, + [chartRef], + ); + + const exitSectorMode = useCallback(() => { + const pieGroup = chartRef.current?.querySelector('.recharts-pie'); + if (pieGroup) { + pieGroup + .querySelectorAll('.recharts-pie-sector[tabindex]') + .forEach((s) => s.removeAttribute('tabindex')); + } + spaceHeldRef.current = false; + sectorFocusRef.current = -1; + setInSectorMode(false); + }, [chartRef]); + + // Recharts destroys and recreates all sector DOM elements on re-render, wiping imperative attributes. + useIsomorphicLayoutEffect(() => { + if (!enabled || sectorFocusRef.current < 0) { + return; + } + focusSector(sectorFocusRef.current); + }, [activeSegment, enabled, focusSector, inSectorMode]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!dataLength) { + return; + } + const isContainerFocused = e.target === e.currentTarget; + + if (e.key === 'Tab') { + if (isContainerFocused && !e.shiftKey) { + const sectors = chartRef.current?.querySelectorAll('.recharts-pie-sector'); + if (!sectors?.length) { + return; + } + e.preventDefault(); + sectorFocusRef.current = Math.min(activeSegment ?? 0, dataLength - 1); + setInSectorMode(true); + return; + } + + if (!isContainerFocused) { + if (e.shiftKey) { + e.preventDefault(); + lastSectorRef.current = -1; + exitSectorMode(); + (e.currentTarget as HTMLElement).focus(); + } + } + return; + } + + if (isContainerFocused) { + return; + } + + switch (e.key) { + case 'ArrowRight': + case 'ArrowUp': { + e.preventDefault(); + e.stopPropagation(); + focusSector((sectorFocusRef.current + 1) % dataLength); + break; + } + case 'ArrowLeft': + case 'ArrowDown': { + e.preventDefault(); + e.stopPropagation(); + focusSector((sectorFocusRef.current - 1 + dataLength) % dataLength); + break; + } + case 'Enter': { + e.preventDefault(); + if (sectorFocusRef.current >= 0) { + onSelect?.(sectorFocusRef.current, e); + } + break; + } + case ' ': { + // Space activates on keyup so users can hold it, arrow to another sector, then release. + e.preventDefault(); + spaceHeldRef.current = true; + break; + } + } + }, + [dataLength, chartRef, activeSegment, exitSectorMode, focusSector, onSelect], + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { + // Defer cleanup — blur fires before layout effects, so the new focus target may not be settled yet. + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + const container = e.currentTarget as HTMLElement; + rafIdRef.current = requestAnimationFrame(() => { + if (!container.contains(document.activeElement)) { + lastSectorRef.current = sectorFocusRef.current; + exitSectorMode(); + } + }); + } + if (typeof consumerOnBlur === 'function') { + consumerOnBlur(e); + } + }, + [exitSectorMode, consumerOnBlur], + ); + + const handleFocus = useCallback( + (e: FocusEvent) => { + // Re-enter sector mode when tabbing back — restore the last focused sector. + if (e.target === e.currentTarget && lastSectorRef.current >= 0) { + sectorFocusRef.current = lastSectorRef.current; + lastSectorRef.current = -1; + setInSectorMode(true); + } + if (typeof consumerOnFocus === 'function') { + consumerOnFocus(e); + } + }, + [consumerOnFocus], + ); + + const handleKeyDownCapture = useCallback( + (e: KeyboardEvent) => { + handleKeyDown(e); + if (typeof consumerOnKeyDownCapture === 'function') { + consumerOnKeyDownCapture(e); + } + }, + [handleKeyDown, consumerOnKeyDownCapture], + ); + + const handleKeyUp = useCallback( + (e: KeyboardEvent) => { + if (e.key === ' ' && spaceHeldRef.current) { + spaceHeldRef.current = false; + if (sectorFocusRef.current >= 0) { + onSelect?.(sectorFocusRef.current, e); + } + } + }, + [onSelect], + ); + + const handleSectorClick = useCallback( + (dataIndex: number) => { + if (!enabled) { + return; + } + if (inSectorMode) { + focusSector(dataIndex); + } else { + sectorFocusRef.current = dataIndex; + setInSectorMode(true); + } + }, + [enabled, inSectorMode, focusSector], + ); + + if (!enabled) { + return { + containerProps: {} as const, + handleSectorClick: () => {}, + }; + } + + if (dataLength === 0) { + return { + containerProps: { + tabIndex: 0, + 'aria-roledescription': 'chart', + } as const, + handleSectorClick: () => {}, + }; + } + + return { + containerProps: { + tabIndex: inSectorMode ? -1 : 0, + role: 'application' as const, + 'aria-roledescription': 'chart', + onKeyDownCapture: handleKeyDownCapture, + onKeyUp: handleKeyUp, + onBlur: handleBlur, + onFocus: handleFocus, + }, + handleSectorClick, + }; +} diff --git a/packages/charts/src/interfaces/IChartBaseProps.ts b/packages/charts/src/interfaces/IChartBaseProps.ts index 9106546a421..e0ed1e76dd7 100644 --- a/packages/charts/src/interfaces/IChartBaseProps.ts +++ b/packages/charts/src/interfaces/IChartBaseProps.ts @@ -109,8 +109,8 @@ export interface IChartBaseProps extends Omit((props, ref) = useStylesheet(styleData, ChartContainer.displayName); return ( -
+
{dataset?.length > 0 ? ( <> {loading && ( diff --git a/packages/charts/src/resources/DemoProps.tsx b/packages/charts/src/resources/DemoProps.tsx index 194b31277f5..e9942949724 100644 --- a/packages/charts/src/resources/DemoProps.tsx +++ b/packages/charts/src/resources/DemoProps.tsx @@ -1,4 +1,6 @@ import { ThemingParameters } from '@ui5/webcomponents-react-base/ThemingParameters'; +import type { ComponentType } from 'react'; +import { useEffect, useState } from 'react'; import { DefaultTooltipContent } from 'recharts'; import type { TooltipProps } from 'recharts'; import type { IChartBaseProps } from '../interfaces/IChartBaseProps.js'; @@ -667,3 +669,34 @@ export const CustomTooltipContent = ({ payload, ...rest }: TooltipProps) { + return { + args: { + chartConfig: { + accessibilityLayer: true, + activeSegment: 0, + showActiveSegmentDataLabel: true, + }, + }, + render(args) { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [activeSegment, setActiveSegment] = useState(args.chartConfig.activeSegment); + const handleDataPointClick = (e) => { + const { dataIndex } = e.detail; + if (dataIndex != null) { + setActiveSegment(dataIndex); + } + }; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + setActiveSegment(args.chartConfig.activeSegment); + }, [args.chartConfig.activeSegment]); + + return ( + + ); + }, + }; +} diff --git a/packages/charts/src/resources/KeyboardNavigation.mdx b/packages/charts/src/resources/KeyboardNavigation.mdx new file mode 100644 index 00000000000..6f69b79dd7c --- /dev/null +++ b/packages/charts/src/resources/KeyboardNavigation.mdx @@ -0,0 +1,41 @@ +import { Canvas } from '@storybook/addon-docs/blocks'; + +### Keyboard Navigation + +Enable keyboard navigation for chart sectors via `chartConfig.accessibilityLayer`. When enabled, users can Tab into the chart, use arrow keys to navigate between sectors, and press Enter or Space to select a sector. + +Use `chartConfig.activeSegment` to highlight the selected sector. Space activates on key release, allowing users to hold Space, navigate with arrow keys, and release to select the final sector. + + + +
+ +Show Code + +```tsx +function ChartComponent() { + const [activeSegment, setActiveSegment] = useState(0); + const handleDataPointClick = (e) => { + const { dataIndex } = e.detail; + if (dataIndex != null) { + setActiveSegment(dataIndex); + } + }; + + return ( + + ); +} +``` + +