From f47b4a4521fb51d981099ed8b0174055de6615f4 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 8 May 2026 11:40:01 +0200 Subject: [PATCH 1/6] feat(ScatterChart): enable arrow key nav via custom `accessibilityLayer` --- .../ScatterChart/ScatterChart.module.css | 13 + .../ScatterChart/ScatterChart.stories.tsx | 1 + .../src/components/ScatterChart/index.tsx | 55 +++- .../ScatterChart/useScatterPointFocus.ts | 295 ++++++++++++++++++ .../charts/src/interfaces/IChartBaseProps.ts | 5 +- packages/charts/src/resources/DemoProps.tsx | 24 +- 6 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 packages/charts/src/components/ScatterChart/ScatterChart.module.css create mode 100644 packages/charts/src/components/ScatterChart/useScatterPointFocus.ts diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.module.css b/packages/charts/src/components/ScatterChart/ScatterChart.module.css new file mode 100644 index 00000000000..6de41a56bcb --- /dev/null +++ b/packages/charts/src/components/ScatterChart/ScatterChart.module.css @@ -0,0 +1,13 @@ +.scatterchart { + g:focus, + path:focus { + outline: none; + } + + :global(.recharts-scatter-symbol):focus path, + :global(.recharts-scatter-symbol)[data-point-focused] path { + stroke: var(--sapContent_FocusColor); + stroke-width: calc(var(--sapContent_FocusWidth) * 2); + paint-order: stroke; + } +} diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx index b15b5ca6378..1270487f2ea 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx @@ -12,6 +12,7 @@ const meta = { }, tags: ['package:@ui5/webcomponents-react-charts'], args: { + chartConfig: { accessibilityLayer: true }, dataset: scatterComplexDataSet, measures: [ { diff --git a/packages/charts/src/components/ScatterChart/index.tsx b/packages/charts/src/components/ScatterChart/index.tsx index d3d018e974d..d498c1b044c 100644 --- a/packages/charts/src/components/ScatterChart/index.tsx +++ b/packages/charts/src/components/ScatterChart/index.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useIsRTL, useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks'; +import { useIsRTL, useStylesheet, useSyncRef } from '@ui5/webcomponents-react-base/internal/hooks'; import { enrichEventWithDetails } from '@ui5/webcomponents-react-base/internal/utils'; import { ThemingParameters } from '@ui5/webcomponents-react-base/ThemingParameters'; +import { clsx } from 'clsx'; import type { CSSProperties } from 'react'; import { forwardRef, useCallback, useRef } from 'react'; import type { ReferenceLineProps } from 'recharts'; @@ -32,6 +33,8 @@ import { tickLineConfig, tooltipContentStyle, tooltipFillOpacity, xAxisPadding } import { XAxisTicks } from '../../internal/XAxisTicks.js'; import { YAxisTicks } from '../../internal/YAxisTicks.js'; import { ScatterChartPlaceholder } from './Placeholder.js'; +import { classNames, styleData } from './ScatterChart.module.css.js'; +import { useScatterPointFocus } from './useScatterPointFocus.js'; interface MeasureConfig extends Omit { /** @@ -162,7 +165,7 @@ const ScatterChart = forwardRef((props, ref) legendPosition: 'bottom', legendHorizontalAlign: 'left', zoomingTool: false, - resizeDebounce: 250, + resizeDebounce: 0, ...props.chartConfig, }; const { referenceLine, referenceLineX } = chartConfig; @@ -215,9 +218,41 @@ const ScatterChart = forwardRef((props, ref) const [yAxisWidth, legendPosition] = useLongestYAxisLabel(dataset?.[0]?.data, [yMeasure], chartConfig.legendPosition); const xAxisHeights = useObserveXAxisHeights(chartRef, 1); const marginChart = useChartMargin(chartConfig.margin, chartConfig.zoomingTool); - const { chartConfig: _0, measures: _1, ...propsWithoutOmitted } = rest; + const { + chartConfig: _0, + measures: _1, + onBlur: consumerOnBlur, + onFocus: consumerOnFocus, + onKeyDownCapture: consumerOnKeyDownCapture, + ...propsWithoutOmitted + } = rest; const isRTL = useIsRTL(chartRef); + useStylesheet(styleData, ScatterChart.displayName); + + const { containerProps: pointFocusProps, handlePointClick } = useScatterPointFocus({ + chartRef, + enabled: !!chartConfig.accessibilityLayer, + dataset: dataset ?? [], + measures, + consumerOnBlur, + consumerOnFocus, + consumerOnKeyDownCapture, + onSelect: useCallback( + (point, e) => { + if (typeof onDataPointClick !== 'function') return; + onDataPointClick( + enrichEventWithDetails(e as unknown as CustomEvent, { + value: point.raw, + dataIndex: point.pointIndex, + payload: point.raw, + }), + ); + }, + [onDataPointClick], + ), + }); + return ( ((props, ref) className={className} slot={slot} resizeDebounce={chartConfig.resizeDebounce} + {...pointFocusProps} {...propsWithoutOmitted} > ((props, ref) return ( { + onDataPointClickInternal(payload, pointIndex, event); + handlePointClick?.(index, pointIndex); + }} opacity={dataSet.opacity} data={dataSet?.data} name={dataSet?.label} key={dataSet?.label} fill={dataSet?.color ?? `var(--sapChart_OrderedColor_${(index % 12) + 1})`} - isAnimationActive={!noAnimation} + isAnimationActive={!noAnimation && !chartConfig.accessibilityLayer} /> ); })} diff --git a/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts new file mode 100644 index 00000000000..139a8e1f147 --- /dev/null +++ b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts @@ -0,0 +1,295 @@ +import type { FocusEvent, FocusEventHandler, KeyboardEvent, KeyboardEventHandler, RefObject } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef } from 'react'; + +interface MeasureInfo { + accessor: string | ((entry: Record) => unknown); + label?: string; + formatter?: (value: unknown) => string; + axis: 'x' | 'y' | 'z'; +} + +interface ScatterDataset { + label?: string; + data?: Record[]; + color?: string; + opacity?: number; +} + +interface FlatPoint { + datasetIndex: number; + pointIndex: number; + datasetLabel: string; + raw: Record; +} + +interface UseScatterPointFocusOptions { + chartRef: RefObject; + enabled: boolean; + dataset: ScatterDataset[]; + measures: MeasureInfo[]; + onSelect?: (point: FlatPoint, event: KeyboardEvent) => void; + consumerOnBlur?: FocusEventHandler; + consumerOnFocus?: FocusEventHandler; + consumerOnKeyDownCapture?: KeyboardEventHandler; +} + +function getAccessorValue( + entry: Record, + accessor: string | ((e: Record) => unknown), +) { + if (typeof accessor === 'function') { + return accessor(entry); + } + return entry[accessor]; +} + +function getPointLabel(point: FlatPoint, measures: MeasureInfo[]) { + const parts: string[] = []; + if (point.datasetLabel) { + parts.push(point.datasetLabel); + } + for (const m of measures) { + const value = getAccessorValue(point.raw, m.accessor); + const formatted = m.formatter ? m.formatter(value) : String((value as string) ?? ''); + const label = m.label || (typeof m.accessor === 'string' ? m.accessor : m.axis); + parts.push(`${label}: ${formatted}`); + } + return parts.join(', '); +} + +/** + * Manages keyboard navigation through scatter chart data points using aria-activedescendant. + * + * Single tab stop: focusing the chart immediately activates the first (or last-visited) point. + * Arrow keys navigate between points sorted by X value. Tab/Shift+Tab leave the chart naturally. + * + * Static attributes (id, role, aria-label) are applied to all symbols via effect. + * Only `data-point-focused` and `aria-activedescendant` change during navigation. + * + * Active when `chartConfig.accessibilityLayer` is enabled. + */ +export function useScatterPointFocus({ + chartRef, + enabled, + dataset, + measures, + onSelect, + consumerOnBlur, + consumerOnFocus, + consumerOnKeyDownCapture, +}: UseScatterPointFocusOptions) { + const pointFocusRef = useRef(-1); + const spaceHeldRef = useRef(false); + const baseId = useId(); + + console.log('update'); + + const xMeasure = measures.find((m) => m.axis === 'x'); + + const flatPoints: FlatPoint[] = useMemo(() => { + if (!dataset?.length || !xMeasure) { + return []; + } + const points: FlatPoint[] = []; + dataset.forEach((ds, dsIndex) => { + ds.data?.forEach((entry, ptIndex) => { + points.push({ + datasetIndex: dsIndex, + pointIndex: ptIndex, + datasetLabel: ds.label ?? '', + raw: entry, + }); + }); + }); + points.sort((a, b) => { + const ax = Number(getAccessorValue(a.raw, xMeasure.accessor)) || 0; + const bx = Number(getAccessorValue(b.raw, xMeasure.accessor)) || 0; + if (ax !== bx) return ax - bx; + return a.datasetIndex - b.datasetIndex; + }); + return points; + }, [dataset, xMeasure]); + + const flatPointsRef = useRef(flatPoints); + useEffect(() => { + flatPointsRef.current = flatPoints; + pointFocusRef.current = -1; + }, [flatPoints]); + + useEffect(() => { + if (!enabled) return; + const container = chartRef.current; + if (!container) return; + + const allSymbols: SVGGElement[][] = []; + container.querySelectorAll('.recharts-scatter').forEach((group) => { + allSymbols.push(Array.from(group.querySelectorAll(':scope .recharts-scatter-symbol'))); + }); + + for (const point of flatPoints) { + const el = allSymbols[point.datasetIndex]?.[point.pointIndex]; + if (!el) continue; + const idx = flatPoints.indexOf(point); + el.setAttribute('id', `${baseId}-point-${idx}`); + el.setAttribute('role', 'img'); + el.setAttribute('aria-label', getPointLabel(point, measures)); + } + + // Re-apply active point state after recharts re-creates DOM elements. + const activeIdx = pointFocusRef.current; + if (activeIdx >= 0 && container.contains(document.activeElement)) { + const el = container.querySelector(`#${CSS.escape(baseId)}-point-${activeIdx}`); + if (el) { + el.setAttribute('data-point-focused', ''); + container.setAttribute('aria-activedescendant', `${baseId}-point-${activeIdx}`); + } + } + }); + + const activatePoint = useCallback( + (index: number) => { + const container = chartRef.current; + if (!container) return; + + container + .querySelector('.recharts-scatter-symbol[data-point-focused]') + ?.removeAttribute('data-point-focused'); + + pointFocusRef.current = index; + + const el = container.querySelector(`#${CSS.escape(baseId)}-point-${index}`); + if (!el) return; + + el.setAttribute('data-point-focused', ''); + container.setAttribute('aria-activedescendant', `${baseId}-point-${index}`); + }, + [baseId, chartRef], + ); + + const clearActivePoint = useCallback(() => { + const container = chartRef.current; + if (!container) return; + container + .querySelector('.recharts-scatter-symbol[data-point-focused]') + ?.removeAttribute('data-point-focused'); + container.removeAttribute('aria-activedescendant'); + }, [chartRef]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const points = flatPointsRef.current; + if (!points.length) return; + + switch (e.key) { + case 'ArrowRight': + case 'ArrowDown': { + e.preventDefault(); + const next = Math.min(pointFocusRef.current + 1, points.length - 1); + activatePoint(next); + break; + } + case 'ArrowLeft': + case 'ArrowUp': { + e.preventDefault(); + const prev = Math.max(pointFocusRef.current - 1, 0); + activatePoint(prev); + break; + } + case 'Enter': { + e.preventDefault(); + if (pointFocusRef.current >= 0) { + onSelect?.(points[pointFocusRef.current], e); + } + break; + } + case ' ': { + e.preventDefault(); + spaceHeldRef.current = true; + break; + } + } + }, + [activatePoint, onSelect], + ); + + const handleKeyUp = useCallback( + (e: KeyboardEvent) => { + if (e.key === ' ' && spaceHeldRef.current) { + spaceHeldRef.current = false; + const points = flatPointsRef.current; + if (pointFocusRef.current >= 0) { + onSelect?.(points[pointFocusRef.current], e); + } + } + }, + [onSelect], + ); + + const handleFocus = useCallback( + (e: FocusEvent) => { + if (e.target === e.currentTarget) { + const index = pointFocusRef.current >= 0 ? pointFocusRef.current : 0; + activatePoint(index); + } + consumerOnFocus?.(e); + }, + [activatePoint, consumerOnFocus], + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + clearActivePoint(); + spaceHeldRef.current = false; + } + consumerOnBlur?.(e); + }, + [clearActivePoint, consumerOnBlur], + ); + + const handleKeyDownCapture = useCallback( + (e: KeyboardEvent) => { + handleKeyDown(e); + consumerOnKeyDownCapture?.(e); + }, + [handleKeyDown, consumerOnKeyDownCapture], + ); + + const handlePointClick = useCallback( + (datasetIndex: number, pointIndex: number) => { + const points = flatPointsRef.current; + const idx = points.findIndex((p) => p.datasetIndex === datasetIndex && p.pointIndex === pointIndex); + if (idx >= 0) { + activatePoint(idx); + } + }, + [activatePoint], + ); + + if (!enabled) { + return { containerProps: {} as const, handlePointClick: undefined }; + } + + if (flatPoints.length === 0) { + return { + containerProps: { + tabIndex: 0, + 'aria-roledescription': 'chart', + } as const, + handlePointClick: undefined, + }; + } + + return { + containerProps: { + tabIndex: 0, + role: 'application' as const, + 'aria-roledescription': 'chart', + onKeyDownCapture: handleKeyDownCapture, + onKeyUp: handleKeyUp, + onBlur: handleBlur, + onFocus: handleFocus, + }, + handlePointClick, + }; +} diff --git a/packages/charts/src/interfaces/IChartBaseProps.ts b/packages/charts/src/interfaces/IChartBaseProps.ts index 9106546a421..96fc880ef82 100644 --- a/packages/charts/src/interfaces/IChartBaseProps.ts +++ b/packages/charts/src/interfaces/IChartBaseProps.ts @@ -109,8 +109,9 @@ export interface IChartBaseProps extends Omit Date: Fri, 8 May 2026 11:40:13 +0200 Subject: [PATCH 2/6] tests --- .../ScatterChart/ScatterChart.cy.tsx | 205 +++++++++++++++++- 1 file changed, 204 insertions(+), 1 deletion(-) diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx index e2793419442..ff5a343e54c 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx @@ -19,6 +19,17 @@ const measures = [ }, ]; +function activePointLabelShould(containerSelector: string, ...matchers: string[]) { + cy.get(containerSelector) + .should('have.attr', 'aria-activedescendant') + .then((activeId) => { + let chain = cy.get(`#${CSS.escape(activeId as string)}`).should('have.attr', 'aria-label'); + for (const m of matchers) { + chain = chain.and('contain', m); + } + }); +} + describe('ScatterChart', () => { it('Basic', () => { cy.mount(); @@ -66,7 +77,199 @@ describe('ScatterChart', () => { it('Loading Placeholder', () => { cy.mount(); cy.get('.recharts-scatter').should('not.exist'); - cy.contains('Loading...').should('exist'); + cy.findByText('Loading...').should('exist'); + }); + + it('accessibilityLayer: keyboard navigation, Enter, blur/re-focus, consumer handlers', () => { + const chartConfig = { accessibilityLayer: true }; + const containerSelector = '[aria-roledescription="chart"]'; + const singleDataset = [ + { + label: 'Series A', + data: [ + { users: 100, sessions: 200, volume: 300 }, + { users: 50, sessions: 150, volume: 250 }, + { users: 200, sessions: 400, volume: 500 }, + ], + }, + ]; + + const onDataPointClick = cy.spy().as('onDataPointClick'); + const onBlur = cy.spy().as('onBlur'); + const onFocus = cy.spy().as('onFocus'); + const onKeyDownCapture = cy.spy().as('onKeyDownCapture'); + + cy.mount( + <> + + + + , + ); + cy.get('[role="img"][aria-label]').should('have.length', 3); + + cy.findByText('before').focus(); + + // container focused, first scatter "active" + cy.realPress('Tab'); + cy.focused() + .should('have.attr', 'tabindex', '0') + .should('have.attr', 'role', 'application') + .should('have.attr', 'aria-roledescription', 'chart'); + cy.get('@onFocus').should('have.been.calledOnce'); + activePointLabelShould(containerSelector, 'Number: 50'); + cy.get('[data-point-focused]').should('have.length', 1); + + // 2nd scatter "active" - forward by X + cy.realPress('ArrowRight'); + activePointLabelShould(containerSelector, 'Number: 100'); + cy.get('@onKeyDownCapture').should('have.been.called'); + + // 3rd scatter "active" + cy.realPress('ArrowRight'); + activePointLabelShould(containerSelector, 'Number: 200'); + + // 3rd scatter "active" -> last one + cy.realPress('ArrowRight'); + activePointLabelShould(containerSelector, 'Number: 200'); + + // 2nd scatter "active" + cy.realPress('ArrowLeft'); + activePointLabelShould(containerSelector, 'Number: 100'); + + cy.realPress('Enter'); + cy.get('@onDataPointClick').should( + 'have.been.calledWith', + Cypress.sinon.match({ + detail: Cypress.sinon.match({ payload: singleDataset[0].data[0] }), + }), + ); + + // Leave chart + cy.realPress('Tab'); + cy.focused().should('contain.text', 'after'); + cy.get(containerSelector).should('not.have.attr', 'aria-activedescendant'); + cy.get('[data-point-focused]').should('not.exist'); + cy.get('@onBlur').should('have.been.called'); + + // Reenter chart + cy.realPress(['Shift', 'Tab']); + cy.focused().should('have.attr', 'aria-roledescription', 'chart'); + activePointLabelShould(containerSelector, 'Number: 100'); + + // 1st scatter active + cy.realPress('ArrowLeft'); + activePointLabelShould(containerSelector, 'Number: 50'); + cy.realPress('ArrowLeft'); + activePointLabelShould(containerSelector, 'Number: 50'); + }); + + it('accessibilityLayer: multi-dataset points sorted by X then datasetIndex', () => { + const chartConfig = { accessibilityLayer: true }; + const containerSelector = '[aria-roledescription="chart"]'; + const multiDataset = [ + { + label: 'Alpha', + data: [{ users: 30, sessions: 100, volume: 200 }], + }, + { + label: 'Beta', + data: [ + { users: 30, sessions: 150, volume: 250 }, + { users: 60, sessions: 300, volume: 400 }, + ], + }, + ]; + + cy.mount( + <> + + + , + ); + + cy.get('[role="img"][aria-label]').should('have.length', 3); + cy.findByText('before').focus(); + cy.realPress('Tab'); + + // Same X value (30): sorted by dataset index, Alpha (0) before Beta (1) + activePointLabelShould(containerSelector, 'Alpha'); + cy.realPress('ArrowRight'); + activePointLabelShould(containerSelector, 'Beta', 'Number: 30'); + cy.realPress('ArrowRight'); + activePointLabelShould(containerSelector, 'Beta', 'Number: 60'); + }); + + it('accessibilityLayer: multiple charts', () => { + const chartConfig = { accessibilityLayer: true }; + const singleDataset = [ + { + label: 'Series A', + data: [ + { users: 100, sessions: 200, volume: 300 }, + { users: 50, sessions: 150, volume: 250 }, + ], + }, + ]; + + cy.mount( + <> + + + + + , + ); + + cy.get('[role="img"][id]').then(($els) => { + const ids = [...$els].map((el) => el.id); + expect(new Set(ids).size).to.equal(ids.length); + }); + + cy.findByText('before').focus(); + cy.realPress('Tab'); + cy.focused().should('have.attr', 'aria-roledescription', 'chart1'); + cy.realPress('ArrowRight'); + activePointLabelShould('[aria-roledescription="chart1"]:first', 'Number: 100'); + + cy.realPress('Tab'); + cy.focused().should('have.attr', 'aria-roledescription', 'chart2'); + cy.realPress('ArrowRight'); + activePointLabelShould('[aria-roledescription="chart2"]:first', 'Number: 100'); + + cy.realPress('Tab'); + cy.focused().should('contain.text', 'after'); + }); + + [false, true].forEach((accessibilityLayer) => { + it(`empty dataset (accessibilityLayer: ${accessibilityLayer})`, () => { + cy.mount(); + cy.get('.recharts-scatter').should('not.exist'); + cy.findByText('Loading...').should('exist'); + if (accessibilityLayer) { + cy.get('[aria-roledescription="chart"]') + .should('have.attr', 'tabindex', '0') + .should('not.have.attr', 'role', 'application'); + } + }); }); testChartLegendConfig(ScatterChart, { dataset: complexDataSet, measures }); From 6026ed625e9f208eb18d623c5c62b1681f57d014 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 8 May 2026 12:23:37 +0200 Subject: [PATCH 3/6] minor improvements --- CLAUDE.md | 4 ++ .../ScatterChart/useScatterPointFocus.ts | 45 +++++++++++++------ 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69844598916..20e3779f679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,10 @@ const style = { Components must work in both SPA and SSR environments. Wrappers use `suppressHydrationWarning` because web component internal state doesn't serialize consistently. +### CSS Modules + +Component styles use CSS Modules (`.module.css` files). The corresponding `.css.ts` files are **auto-generated** by the build system and **gitignored** (`src/**/*.css.ts` in each package's `.gitignore`). Never create or edit `.css.ts` files manually — only create the `.module.css` source file. + ## Core Architecture ### Base Package Imports diff --git a/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts index 139a8e1f147..040da25f73d 100644 --- a/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts +++ b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts @@ -82,8 +82,6 @@ export function useScatterPointFocus({ const spaceHeldRef = useRef(false); const baseId = useId(); - console.log('update'); - const xMeasure = measures.find((m) => m.axis === 'x'); const flatPoints: FlatPoint[] = useMemo(() => { @@ -104,7 +102,9 @@ export function useScatterPointFocus({ points.sort((a, b) => { const ax = Number(getAccessorValue(a.raw, xMeasure.accessor)) || 0; const bx = Number(getAccessorValue(b.raw, xMeasure.accessor)) || 0; - if (ax !== bx) return ax - bx; + if (ax !== bx) { + return ax - bx; + } return a.datasetIndex - b.datasetIndex; }); return points; @@ -117,25 +117,36 @@ export function useScatterPointFocus({ }, [flatPoints]); useEffect(() => { - if (!enabled) return; + if (!enabled) { + return; + } const container = chartRef.current; - if (!container) return; + if (!container) { + return; + } const allSymbols: SVGGElement[][] = []; + container.querySelectorAll('.recharts-scatter').forEach((group) => { allSymbols.push(Array.from(group.querySelectorAll(':scope .recharts-scatter-symbol'))); }); - for (const point of flatPoints) { + if (allSymbols.length === 0) { + return; + } + + for (let idx = 0; idx < flatPoints.length; idx++) { + const point = flatPoints[idx]; const el = allSymbols[point.datasetIndex]?.[point.pointIndex]; - if (!el) continue; - const idx = flatPoints.indexOf(point); + if (!el) { + continue; + } el.setAttribute('id', `${baseId}-point-${idx}`); el.setAttribute('role', 'img'); el.setAttribute('aria-label', getPointLabel(point, measures)); } - // Re-apply active point state after recharts re-creates DOM elements. + // Re-apply active point state after recharts re-creates DOM elements (e.g. resize). const activeIdx = pointFocusRef.current; if (activeIdx >= 0 && container.contains(document.activeElement)) { const el = container.querySelector(`#${CSS.escape(baseId)}-point-${activeIdx}`); @@ -149,7 +160,9 @@ export function useScatterPointFocus({ const activatePoint = useCallback( (index: number) => { const container = chartRef.current; - if (!container) return; + if (!container) { + return; + } container .querySelector('.recharts-scatter-symbol[data-point-focused]') @@ -158,7 +171,9 @@ export function useScatterPointFocus({ pointFocusRef.current = index; const el = container.querySelector(`#${CSS.escape(baseId)}-point-${index}`); - if (!el) return; + if (!el) { + return; + } el.setAttribute('data-point-focused', ''); container.setAttribute('aria-activedescendant', `${baseId}-point-${index}`); @@ -168,7 +183,9 @@ export function useScatterPointFocus({ const clearActivePoint = useCallback(() => { const container = chartRef.current; - if (!container) return; + if (!container) { + return; + } container .querySelector('.recharts-scatter-symbol[data-point-focused]') ?.removeAttribute('data-point-focused'); @@ -178,7 +195,9 @@ export function useScatterPointFocus({ const handleKeyDown = useCallback( (e: KeyboardEvent) => { const points = flatPointsRef.current; - if (!points.length) return; + if (!points.length) { + return; + } switch (e.key) { case 'ArrowRight': From 9894803f41939fc3dfe1ea64fb842344c8e759b6 Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 8 May 2026 14:33:48 +0200 Subject: [PATCH 4/6] test: click scatter point --- .../src/components/ScatterChart/ScatterChart.cy.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx index ff5a343e54c..b243c6d184f 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx @@ -153,6 +153,10 @@ describe('ScatterChart', () => { }), ); + cy.get('[role="img"][aria-label]').eq(2).click(); + cy.get('@onDataPointClick').should('have.been.calledTwice'); + activePointLabelShould(containerSelector, 'Number: 200'); + // Leave chart cy.realPress('Tab'); cy.focused().should('contain.text', 'after'); @@ -163,9 +167,10 @@ describe('ScatterChart', () => { // Reenter chart cy.realPress(['Shift', 'Tab']); cy.focused().should('have.attr', 'aria-roledescription', 'chart'); - activePointLabelShould(containerSelector, 'Number: 100'); + activePointLabelShould(containerSelector, 'Number: 200'); - // 1st scatter active + cy.realPress('ArrowLeft'); + activePointLabelShould(containerSelector, 'Number: 100'); cy.realPress('ArrowLeft'); activePointLabelShould(containerSelector, 'Number: 50'); cy.realPress('ArrowLeft'); From 632c7f5be8a9ee5c37fb09eea01f0ebbfa80162b Mon Sep 17 00:00:00 2001 From: Lukas Harbarth Date: Fri, 8 May 2026 14:34:27 +0200 Subject: [PATCH 5/6] cleanup & formatting & add comments --- .../src/components/ScatterChart/index.tsx | 3 +- .../ScatterChart/useScatterPointFocus.ts | 31 +++++++++++++------ .../charts/src/interfaces/IChartBaseProps.ts | 2 +- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/charts/src/components/ScatterChart/index.tsx b/packages/charts/src/components/ScatterChart/index.tsx index d498c1b044c..51fa984fbb4 100644 --- a/packages/charts/src/components/ScatterChart/index.tsx +++ b/packages/charts/src/components/ScatterChart/index.tsx @@ -165,7 +165,7 @@ const ScatterChart = forwardRef((props, ref) legendPosition: 'bottom', legendHorizontalAlign: 'left', zoomingTool: false, - resizeDebounce: 0, + resizeDebounce: 250, ...props.chartConfig, }; const { referenceLine, referenceLineX } = chartConfig; @@ -331,6 +331,7 @@ const ScatterChart = forwardRef((props, ref) name={dataSet?.label} key={dataSet?.label} fill={dataSet?.color ?? `var(--sapChart_OrderedColor_${(index % 12) + 1})`} + // Animation recreates DOM elements, wiping a11y attributes. isAnimationActive={!noAnimation && !chartConfig.accessibilityLayer} /> ); diff --git a/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts index 040da25f73d..c827b3c3d59 100644 --- a/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts +++ b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts @@ -33,6 +33,7 @@ interface UseScatterPointFocusOptions { consumerOnKeyDownCapture?: KeyboardEventHandler; } +// Resolves an accessor (string key or getter function) to the value from a data entry. function getAccessorValue( entry: Record, accessor: string | ((e: Record) => unknown), @@ -43,6 +44,7 @@ function getAccessorValue( return entry[accessor]; } +// Builds the aria-label string for a point: "DatasetLabel, MeasureLabel: value, ..." function getPointLabel(point: FlatPoint, measures: MeasureInfo[]) { const parts: string[] = []; if (point.datasetLabel) { @@ -84,6 +86,7 @@ export function useScatterPointFocus({ const xMeasure = measures.find((m) => m.axis === 'x'); + // flatten dataset and sort by x-axis value for arrow key nav const flatPoints: FlatPoint[] = useMemo(() => { if (!dataset?.length || !xMeasure) { return []; @@ -110,12 +113,14 @@ export function useScatterPointFocus({ return points; }, [dataset, xMeasure]); + // mutable ref for event handler data (reset when data changes) const flatPointsRef = useRef(flatPoints); useEffect(() => { flatPointsRef.current = flatPoints; pointFocusRef.current = -1; }, [flatPoints]); + // Recharts recreates DOM elements on rerender -> must set attributes again after each update useEffect(() => { if (!enabled) { return; @@ -126,15 +131,14 @@ export function useScatterPointFocus({ } const allSymbols: SVGGElement[][] = []; - container.querySelectorAll('.recharts-scatter').forEach((group) => { allSymbols.push(Array.from(group.querySelectorAll(':scope .recharts-scatter-symbol'))); }); - if (allSymbols.length === 0) { return; } + // Set a11y attributes on each scatter symbol. for (let idx = 0; idx < flatPoints.length; idx++) { const point = flatPoints[idx]; const el = allSymbols[point.datasetIndex]?.[point.pointIndex]; @@ -146,7 +150,7 @@ export function useScatterPointFocus({ el.setAttribute('aria-label', getPointLabel(point, measures)); } - // Re-apply active point state after recharts re-creates DOM elements (e.g. resize). + // Reapply attributes when chart is updated if it still has focus. const activeIdx = pointFocusRef.current; if (activeIdx >= 0 && container.contains(document.activeElement)) { const el = container.querySelector(`#${CSS.escape(baseId)}-point-${activeIdx}`); @@ -216,12 +220,13 @@ export function useScatterPointFocus({ } case 'Enter': { e.preventDefault(); - if (pointFocusRef.current >= 0) { - onSelect?.(points[pointFocusRef.current], e); + if (pointFocusRef.current >= 0 && typeof onSelect === 'function') { + onSelect(points[pointFocusRef.current], e); } break; } case ' ': { + // Space fires selection on keyup. e.preventDefault(); spaceHeldRef.current = true; break; @@ -236,8 +241,8 @@ export function useScatterPointFocus({ if (e.key === ' ' && spaceHeldRef.current) { spaceHeldRef.current = false; const points = flatPointsRef.current; - if (pointFocusRef.current >= 0) { - onSelect?.(points[pointFocusRef.current], e); + if (pointFocusRef.current >= 0 && typeof onSelect === 'function') { + onSelect(points[pointFocusRef.current], e); } } }, @@ -250,7 +255,9 @@ export function useScatterPointFocus({ const index = pointFocusRef.current >= 0 ? pointFocusRef.current : 0; activatePoint(index); } - consumerOnFocus?.(e); + if (typeof consumerOnFocus === 'function') { + consumerOnFocus(e); + } }, [activatePoint, consumerOnFocus], ); @@ -261,7 +268,9 @@ export function useScatterPointFocus({ clearActivePoint(); spaceHeldRef.current = false; } - consumerOnBlur?.(e); + if (typeof consumerOnBlur === 'function') { + consumerOnBlur(e); + } }, [clearActivePoint, consumerOnBlur], ); @@ -269,7 +278,9 @@ export function useScatterPointFocus({ const handleKeyDownCapture = useCallback( (e: KeyboardEvent) => { handleKeyDown(e); - consumerOnKeyDownCapture?.(e); + if (typeof consumerOnKeyDownCapture === 'function') { + consumerOnKeyDownCapture(e); + } }, [handleKeyDown, consumerOnKeyDownCapture], ); diff --git a/packages/charts/src/interfaces/IChartBaseProps.ts b/packages/charts/src/interfaces/IChartBaseProps.ts index 96fc880ef82..d217053bb0f 100644 --- a/packages/charts/src/interfaces/IChartBaseProps.ts +++ b/packages/charts/src/interfaces/IChartBaseProps.ts @@ -111,7 +111,7 @@ export interface IChartBaseProps extends Omit Date: Fri, 8 May 2026 16:20:34 +0200 Subject: [PATCH 6/6] add story --- .../charts/src/components/ScatterChart/ScatterChart.mdx | 4 ++++ .../src/components/ScatterChart/ScatterChart.stories.tsx | 7 ++++++- packages/charts/src/components/ScatterChart/index.tsx | 4 +++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.mdx b/packages/charts/src/components/ScatterChart/ScatterChart.mdx index 626c9ca463c..70cbeffe4a0 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.mdx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.mdx @@ -25,6 +25,10 @@ import LegendStory from '../../resources/LegendConfig.mdx'; +### With Accessibility Layer + + + ### Loading Placeholder diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx index 1270487f2ea..5377032fedd 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx @@ -12,7 +12,6 @@ const meta = { }, tags: ['package:@ui5/webcomponents-react-charts'], args: { - chartConfig: { accessibilityLayer: true }, dataset: scatterComplexDataSet, measures: [ { @@ -61,6 +60,12 @@ export const WithCustomColor: Story = { }, }; +export const WithAccessibilityLayer: Story = { + args: { + chartConfig: { accessibilityLayer: true }, + }, +}; + export const LoadingPlaceholder: Story = { args: { dataset: [], diff --git a/packages/charts/src/components/ScatterChart/index.tsx b/packages/charts/src/components/ScatterChart/index.tsx index 51fa984fbb4..68c9a1c6239 100644 --- a/packages/charts/src/components/ScatterChart/index.tsx +++ b/packages/charts/src/components/ScatterChart/index.tsx @@ -240,7 +240,9 @@ const ScatterChart = forwardRef((props, ref) consumerOnKeyDownCapture, onSelect: useCallback( (point, e) => { - if (typeof onDataPointClick !== 'function') return; + if (typeof onDataPointClick !== 'function') { + return; + } onDataPointClick( enrichEventWithDetails(e as unknown as CustomEvent, { value: point.raw,