From 40a448f8a6b43542c9b491b155e6d75a26bf4ce2 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Thu, 23 Apr 2026 15:22:52 +0200 Subject: [PATCH 01/11] feat(Pie- & DonutChart): enable `accessibilityLayer` --- packages/charts/CLAUDE.md | 2 +- .../components/PieChart/PieChart.module.css | 6 + .../charts/src/components/PieChart/index.tsx | 44 ++++- .../charts/src/hooks/usePieSectorFocus.ts | 187 ++++++++++++++++++ .../charts/src/interfaces/IChartBaseProps.ts | 4 +- 5 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 packages/charts/src/hooks/usePieSectorFocus.ts 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/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/index.tsx b/packages/charts/src/components/PieChart/index.tsx index fb7592b51e2..438372d4d32 100644 --- a/packages/charts/src/components/PieChart/index.tsx +++ b/packages/charts/src/components/PieChart/index.tsx @@ -18,6 +18,7 @@ import { import { getValueByDataKey } from 'recharts/lib/util/ChartUtils.js'; import { useLegendItemClick } from '../../hooks/useLegendItemClick.js'; import { useOnClickInternal } from '../../hooks/useOnClickInternal.js'; +import { usePieSectorFocus } from '../../hooks/usePieSectorFocus.js'; import type { IChartBaseProps } from '../../interfaces/IChartBaseProps.js'; import type { IChartDimension } from '../../interfaces/IChartDimension.js'; import type { IChartMeasure } from '../../interfaces/IChartMeasure.js'; @@ -144,6 +145,42 @@ const PieChart = forwardRef((props, ref) => { [props.measure], ); + const { containerProps: sectorFocusProps, handleSectorClick } = usePieSectorFocus({ + chartRef, + enabled: !!chartConfig.accessibilityLayer, + activeSegment: chartConfig.activeSegment, + dataLength: dataset?.length ?? 0, + 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; @@ -180,8 +217,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 @@ -290,12 +328,14 @@ const PieChart = forwardRef((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; +} + +export function usePieSectorFocus({ + chartRef, + enabled, + activeSegment, + dataLength, + onSelect, + getSectorLabel, +}: UsePieSectorFocusOptions) { + const sectorFocusRef = useRef(-1); + const lastSectorRef = useRef(-1); + const [inSectorMode, setInSectorMode] = useState(false); + const getSectorLabelRef = useRef(getSectorLabel); + useLayoutEffect(() => { + getSectorLabelRef.current = getSectorLabel; + }); + + 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; + 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) { + 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')); + } + sectorFocusRef.current = -1; + setInSectorMode(false); + }, [chartRef]); + + useLayoutEffect(() => { + 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 = activeSegment ?? 0; + 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': + case ' ': { + e.preventDefault(); + if (sectorFocusRef.current >= 0) { + onSelect?.(sectorFocusRef.current, e); + } + break; + } + } + }, + [dataLength, chartRef, activeSegment, exitSectorMode, focusSector, onSelect], + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + const container = e.currentTarget as HTMLElement; + requestAnimationFrame(() => { + if (!container.contains(document.activeElement)) { + lastSectorRef.current = sectorFocusRef.current; + exitSectorMode(); + } + }); + } + }, + [exitSectorMode], + ); + + const handleFocus = useCallback((e: FocusEvent) => { + if (e.target === e.currentTarget && lastSectorRef.current >= 0) { + sectorFocusRef.current = lastSectorRef.current; + lastSectorRef.current = -1; + setInSectorMode(true); + } + }, []); + + 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: () => {}, + }; + } + + return { + containerProps: { + tabIndex: inSectorMode ? -1 : 0, + role: 'application' as const, + 'aria-roledescription': 'chart', + onKeyDownCapture: handleKeyDown, + 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 Date: Thu, 23 Apr 2026 16:01:18 +0200 Subject: [PATCH 02/11] Update usePieSectorFocus.ts --- packages/charts/src/hooks/usePieSectorFocus.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/charts/src/hooks/usePieSectorFocus.ts b/packages/charts/src/hooks/usePieSectorFocus.ts index 6ad1655bf36..5bb6eb8e7ae 100644 --- a/packages/charts/src/hooks/usePieSectorFocus.ts +++ b/packages/charts/src/hooks/usePieSectorFocus.ts @@ -1,5 +1,6 @@ +import { useIsomorphicLayoutEffect } from '@ui5/webcomponents-react-base/internal/hooks'; import type { FocusEvent, KeyboardEvent, RefObject } from 'react'; -import { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; interface UsePieSectorFocusOptions { chartRef: RefObject; @@ -22,7 +23,7 @@ export function usePieSectorFocus({ const lastSectorRef = useRef(-1); const [inSectorMode, setInSectorMode] = useState(false); const getSectorLabelRef = useRef(getSectorLabel); - useLayoutEffect(() => { + useEffect(() => { getSectorLabelRef.current = getSectorLabel; }); @@ -69,7 +70,7 @@ export function usePieSectorFocus({ setInSectorMode(false); }, [chartRef]); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!enabled || sectorFocusRef.current < 0) return; focusSector(sectorFocusRef.current); }, [activeSegment, enabled, focusSector, inSectorMode]); From 907db834f0e2168dc88b3ff058d582343775e1ce Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 12:03:10 +0200 Subject: [PATCH 03/11] fix: loading behavior/a11y, active segment edge case, handler merge, dataset change --- .../charts/src/components/PieChart/index.tsx | 31 +++++-- .../charts/src/hooks/usePieSectorFocus.ts | 93 +++++++++++++++---- .../charts/src/internal/ChartContainer.tsx | 8 +- 3 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/charts/src/components/PieChart/index.tsx b/packages/charts/src/components/PieChart/index.tsx index 438372d4d32..277d1d7fc10 100644 --- a/packages/charts/src/components/PieChart/index.tsx +++ b/packages/charts/src/components/PieChart/index.tsx @@ -145,14 +145,29 @@ 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; + if (typeof onDataPointClick !== 'function' || !dataset?.[index]) { + return; + } const entry = dataset[index]; onDataPointClick( enrichEventWithDetails(e as unknown as CustomEvent, { @@ -168,7 +183,9 @@ const PieChart = forwardRef((props, ref) => { ), getSectorLabel: useCallback( (index: number) => { - if (!dataset?.[index]) return ''; + if (!dataset?.[index]) { + return ''; + } const entry = dataset[index]; const name = dimension.formatter(getValueByDataKey(entry, dimension.accessor, '')); const value = measure.formatter(getValueByDataKey(entry, measure.accessor)); @@ -184,7 +201,9 @@ const PieChart = forwardRef((props, ref) => { 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 }); @@ -293,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], @@ -315,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 ( void; getSectorLabel?: (index: number) => string; + consumerOnBlur?: FocusEventHandler; + consumerOnFocus?: FocusEventHandler; + consumerOnKeyDownCapture?: KeyboardEventHandler; } export function usePieSectorFocus({ @@ -18,6 +21,9 @@ export function usePieSectorFocus({ dataLength, onSelect, getSectorLabel, + consumerOnBlur, + consumerOnFocus, + consumerOnKeyDownCapture, }: UsePieSectorFocusOptions) { const sectorFocusRef = useRef(-1); const lastSectorRef = useRef(-1); @@ -27,12 +33,24 @@ export function usePieSectorFocus({ getSectorLabelRef.current = getSectorLabel; }); + 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]); + const focusSector = useCallback( (index: number) => { const pieGroup = chartRef.current?.querySelector('.recharts-pie'); - if (!pieGroup) return; + if (!pieGroup) { + return; + } const sectors = pieGroup.querySelectorAll(':scope > .recharts-pie-sector'); - if (!sectors.length) return; + if (!sectors.length) { + return; + } if (!sectors[0].hasAttribute('data-sector-index')) { sectors.forEach((s, i) => { s.setAttribute('data-sector-index', String(i)); @@ -71,21 +89,27 @@ export function usePieSectorFocus({ }, [chartRef]); useIsomorphicLayoutEffect(() => { - if (!enabled || sectorFocusRef.current < 0) return; + if (!enabled || sectorFocusRef.current < 0) { + return; + } focusSector(sectorFocusRef.current); }, [activeSegment, enabled, focusSector, inSectorMode]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!dataLength) return; + 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; + if (!sectors?.length) { + return; + } e.preventDefault(); - sectorFocusRef.current = activeSegment ?? 0; + sectorFocusRef.current = Math.min(activeSegment ?? 0, dataLength - 1); setInSectorMode(true); return; } @@ -101,7 +125,9 @@ export function usePieSectorFocus({ return; } - if (isContainerFocused) return; + if (isContainerFocused) { + return; + } switch (e.key) { case 'ArrowRight': @@ -142,21 +168,42 @@ export function usePieSectorFocus({ } }); } + if (typeof consumerOnBlur === 'function') { + consumerOnBlur(e); + } }, - [exitSectorMode], + [exitSectorMode, consumerOnBlur], ); - const handleFocus = useCallback((e: FocusEvent) => { - if (e.target === e.currentTarget && lastSectorRef.current >= 0) { - sectorFocusRef.current = lastSectorRef.current; - lastSectorRef.current = -1; - setInSectorMode(true); - } - }, []); + const handleFocus = useCallback( + (e: FocusEvent) => { + 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 handleSectorClick = useCallback( (dataIndex: number) => { - if (!enabled) return; + if (!enabled) { + return; + } if (inSectorMode) { focusSector(dataIndex); } else { @@ -174,12 +221,22 @@ export function usePieSectorFocus({ }; } + 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: handleKeyDown, + onKeyDownCapture: handleKeyDownCapture, onBlur: handleBlur, onFocus: handleFocus, }, diff --git a/packages/charts/src/internal/ChartContainer.tsx b/packages/charts/src/internal/ChartContainer.tsx index 4fcf401c96c..1b777fd508d 100644 --- a/packages/charts/src/internal/ChartContainer.tsx +++ b/packages/charts/src/internal/ChartContainer.tsx @@ -65,7 +65,13 @@ const ChartContainer = forwardRef((props, ref) = useStylesheet(styleData, ChartContainer.displayName); return ( -
+
{dataset?.length > 0 ? ( <> {loading && ( From 00479048dd85ddf90341f5b75818ac0de228a7bf Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 12:07:14 +0200 Subject: [PATCH 04/11] move hook --- packages/charts/src/components/PieChart/index.tsx | 2 +- .../src/{hooks => components/PieChart}/usePieSectorFocus.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/charts/src/{hooks => components/PieChart}/usePieSectorFocus.ts (100%) diff --git a/packages/charts/src/components/PieChart/index.tsx b/packages/charts/src/components/PieChart/index.tsx index 277d1d7fc10..1a419c89099 100644 --- a/packages/charts/src/components/PieChart/index.tsx +++ b/packages/charts/src/components/PieChart/index.tsx @@ -18,7 +18,6 @@ import { import { getValueByDataKey } from 'recharts/lib/util/ChartUtils.js'; import { useLegendItemClick } from '../../hooks/useLegendItemClick.js'; import { useOnClickInternal } from '../../hooks/useOnClickInternal.js'; -import { usePieSectorFocus } from '../../hooks/usePieSectorFocus.js'; import type { IChartBaseProps } from '../../interfaces/IChartBaseProps.js'; import type { IChartDimension } from '../../interfaces/IChartDimension.js'; import type { IChartMeasure } from '../../interfaces/IChartMeasure.js'; @@ -28,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 { /** diff --git a/packages/charts/src/hooks/usePieSectorFocus.ts b/packages/charts/src/components/PieChart/usePieSectorFocus.ts similarity index 100% rename from packages/charts/src/hooks/usePieSectorFocus.ts rename to packages/charts/src/components/PieChart/usePieSectorFocus.ts From 1ce4563f1f665cafefbb50c0b0123f6a3687149b Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 12:35:23 +0200 Subject: [PATCH 05/11] add comments, fix types --- .../components/PieChart/usePieSectorFocus.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/charts/src/components/PieChart/usePieSectorFocus.ts b/packages/charts/src/components/PieChart/usePieSectorFocus.ts index b5bf85d154d..0f09ede49e4 100644 --- a/packages/charts/src/components/PieChart/usePieSectorFocus.ts +++ b/packages/charts/src/components/PieChart/usePieSectorFocus.ts @@ -7,13 +7,18 @@ interface UsePieSectorFocusOptions { enabled: boolean; activeSegment?: number; dataLength: number; - onSelect?: (index: number, event: KeyboardEvent) => void; + 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, @@ -29,10 +34,12 @@ export function usePieSectorFocus({ const lastSectorRef = useRef(-1); 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; @@ -51,6 +58,7 @@ export function usePieSectorFocus({ 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)); @@ -68,6 +76,7 @@ export function usePieSectorFocus({ } 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(); @@ -88,6 +97,7 @@ export function usePieSectorFocus({ setInSectorMode(false); }, [chartRef]); + // Recharts destroys and recreates all sector DOM elements on re-render, wiping imperative attributes. useIsomorphicLayoutEffect(() => { if (!enabled || sectorFocusRef.current < 0) { return; @@ -96,7 +106,7 @@ export function usePieSectorFocus({ }, [activeSegment, enabled, focusSector, inSectorMode]); const handleKeyDown = useCallback( - (e: KeyboardEvent) => { + (e: KeyboardEvent) => { if (!dataLength) { return; } @@ -158,7 +168,8 @@ export function usePieSectorFocus({ ); const handleBlur = useCallback( - (e: FocusEvent) => { + (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; requestAnimationFrame(() => { @@ -176,7 +187,8 @@ export function usePieSectorFocus({ ); const handleFocus = useCallback( - (e: FocusEvent) => { + (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; @@ -190,7 +202,7 @@ export function usePieSectorFocus({ ); const handleKeyDownCapture = useCallback( - (e: KeyboardEvent) => { + (e: KeyboardEvent) => { handleKeyDown(e); if (typeof consumerOnKeyDownCapture === 'function') { consumerOnKeyDownCapture(e); From d17b673bf05116db0744b2f11568a79e43f1e81f Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 14:15:40 +0200 Subject: [PATCH 06/11] add tests --- cypress/support/utils.tsx | 197 ++++++++++++++++++ .../components/DonutChart/DonutChart.cy.tsx | 4 +- .../src/components/PieChart/PieChart.cy.tsx | 4 +- 3 files changed, 203 insertions(+), 2 deletions(-) diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx index 4c35c2ac5f2..57e3b0917ec 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,202 @@ 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'); + cy.realPress('Space'); + cy.get('@onDataPointClick').should( + 'have.been.calledWith', + Cypress.sinon.match({ + detail: Cypress.sinon.match({ + dataIndex: 5, + }), + }), + ); + cy.focused().should('have.attr', 'data-sector-index', '5'); + }); + + 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/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/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 }); }); From 062a7ec76b6e5f31ca39931c7631b3b12dd5d883 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 15:55:01 +0200 Subject: [PATCH 07/11] activate section after SPACE was released --- cypress/support/utils.tsx | 11 ++++++--- .../components/PieChart/usePieSectorFocus.ts | 23 +++++++++++++++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx index 57e3b0917ec..fd1ec6b36ee 100644 --- a/cypress/support/utils.tsx +++ b/cypress/support/utils.tsx @@ -196,16 +196,21 @@ export function testPieSectorFocus(Component, props, { only }: { only?: boolean cy.realPress('ArrowRight'); cy.focused().should('have.attr', 'data-sector-index', '5'); - cy.realPress('Space'); + + // 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: 5, + dataIndex: 6, }), }), ); - cy.focused().should('have.attr', 'data-sector-index', '5'); + cy.focused().should('have.attr', 'data-sector-index', '6'); }); test('sector focus - activeSegment out of bounds is clamped', () => { diff --git a/packages/charts/src/components/PieChart/usePieSectorFocus.ts b/packages/charts/src/components/PieChart/usePieSectorFocus.ts index 0f09ede49e4..1654e5e80dd 100644 --- a/packages/charts/src/components/PieChart/usePieSectorFocus.ts +++ b/packages/charts/src/components/PieChart/usePieSectorFocus.ts @@ -32,6 +32,7 @@ export function usePieSectorFocus({ }: UsePieSectorFocusOptions) { const sectorFocusRef = useRef(-1); const lastSectorRef = useRef(-1); + const spaceHeldRef = useRef(false); 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. @@ -154,14 +155,19 @@ export function usePieSectorFocus({ focusSector((sectorFocusRef.current - 1 + dataLength) % dataLength); break; } - case 'Enter': - case ' ': { + 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], @@ -211,6 +217,18 @@ export function usePieSectorFocus({ [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) { @@ -249,6 +267,7 @@ export function usePieSectorFocus({ role: 'application' as const, 'aria-roledescription': 'chart', onKeyDownCapture: handleKeyDownCapture, + onKeyUp: handleKeyUp, onBlur: handleBlur, onFocus: handleFocus, }, From ef761dc32a2aa1b4efc9fd0055f457b08c8ccb41 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 16:51:42 +0200 Subject: [PATCH 08/11] add stories --- .../src/components/DonutChart/DonutChart.mdx | 3 ++ .../DonutChart/DonutChart.stories.tsx | 54 +++++++++++-------- .../src/components/PieChart/PieChart.mdx | 7 +++ .../components/PieChart/PieChart.stories.tsx | 10 +++- packages/charts/src/resources/DemoProps.tsx | 33 ++++++++++++ .../src/resources/KeyboardNavigation.mdx | 41 ++++++++++++++ 6 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 packages/charts/src/resources/KeyboardNavigation.mdx 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.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.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/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 ( + + ); +} +``` + +
From 76f3ac0deaca4d580ba41e400231abed1ea77a05 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 24 Apr 2026 17:00:38 +0200 Subject: [PATCH 09/11] cleanup ref value & raf --- .../charts/src/components/PieChart/usePieSectorFocus.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/charts/src/components/PieChart/usePieSectorFocus.ts b/packages/charts/src/components/PieChart/usePieSectorFocus.ts index 1654e5e80dd..117261ae4e2 100644 --- a/packages/charts/src/components/PieChart/usePieSectorFocus.ts +++ b/packages/charts/src/components/PieChart/usePieSectorFocus.ts @@ -33,6 +33,7 @@ export function usePieSectorFocus({ 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. @@ -49,6 +50,10 @@ export function usePieSectorFocus({ setInSectorMode(false); }, [dataLength]); + useEffect(() => { + return () => cancelAnimationFrame(rafIdRef.current); + }, []); + const focusSector = useCallback( (index: number) => { const pieGroup = chartRef.current?.querySelector('.recharts-pie'); @@ -94,6 +99,7 @@ export function usePieSectorFocus({ .querySelectorAll('.recharts-pie-sector[tabindex]') .forEach((s) => s.removeAttribute('tabindex')); } + spaceHeldRef.current = false; sectorFocusRef.current = -1; setInSectorMode(false); }, [chartRef]); @@ -178,7 +184,7 @@ export function usePieSectorFocus({ // 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; - requestAnimationFrame(() => { + rafIdRef.current = requestAnimationFrame(() => { if (!container.contains(document.activeElement)) { lastSectorRef.current = sectorFocusRef.current; exitSectorMode(); From ab81edd529f4c36010f84023213ad8e5a431dcee Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 28 Apr 2026 17:17:50 +0200 Subject: [PATCH 10/11] fix arrow navigation --- cypress/support/utils.tsx | 12 ++++++------ packages/charts/src/components/PieChart/index.tsx | 1 + .../src/components/PieChart/usePieSectorFocus.ts | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx index fd1ec6b36ee..ab12a53a77c 100644 --- a/cypress/support/utils.tsx +++ b/cypress/support/utils.tsx @@ -130,13 +130,13 @@ export function testPieSectorFocus(Component, props, { only }: { only?: boolean .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', '1'); + cy.realPress('ArrowRight'); cy.focused().should('have.attr', 'data-sector-index', '0'); // Wraps from first to last - cy.realPress('ArrowLeft'); + cy.realPress('ArrowRight'); cy.focused().should('have.attr', 'data-sector-index', String(props.dataset.length - 1)); cy.realPress('Enter'); @@ -180,7 +180,7 @@ export function testPieSectorFocus(Component, props, { only }: { only?: boolean cy.realPress('Tab'); cy.focused().should('have.attr', 'data-sector-index', '3'); - cy.realPress('ArrowRight'); + cy.realPress('ArrowLeft'); cy.focused().should('have.attr', 'data-sector-index', '4'); cy.realPress('Enter'); cy.get('@onDataPointClick').should( @@ -194,12 +194,12 @@ export function testPieSectorFocus(Component, props, { only }: { only?: boolean cy.get('.recharts-active-shape').should('exist'); cy.focused().should('have.attr', 'data-sector-index', '4'); - cy.realPress('ArrowRight'); + 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('ArrowRight'); + 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( diff --git a/packages/charts/src/components/PieChart/index.tsx b/packages/charts/src/components/PieChart/index.tsx index 1a419c89099..7035ba1a0b8 100644 --- a/packages/charts/src/components/PieChart/index.tsx +++ b/packages/charts/src/components/PieChart/index.tsx @@ -361,6 +361,7 @@ const PieChart = forwardRef((props, ref) => { )} > ) => { // 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)) { + if (!e.currentTarget.contains(e.relatedTarget)) { const container = e.currentTarget as HTMLElement; rafIdRef.current = requestAnimationFrame(() => { if (!container.contains(document.activeElement)) { From 2976cade46de8637cae4297b4815c9fc5762c76c Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Tue, 28 Apr 2026 18:05:27 +0200 Subject: [PATCH 11/11] Update utils.tsx --- cypress/support/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/support/utils.tsx b/cypress/support/utils.tsx index ab12a53a77c..0a66094314b 100644 --- a/cypress/support/utils.tsx +++ b/cypress/support/utils.tsx @@ -255,7 +255,7 @@ export function testPieSectorFocus(Component, props, { only }: { only?: boolean cy.realPress('Tab'); for (let i = 0; i < 5; i++) { - cy.realPress('ArrowRight'); + cy.realPress('ArrowLeft'); } cy.focused().should('have.attr', 'data-sector-index', '5');