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/ScatterChart.cy.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx index e2793419442..b243c6d184f 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,204 @@ 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] }), + }), + ); + + 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'); + 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: 200'); + + cy.realPress('ArrowLeft'); + activePointLabelShould(containerSelector, 'Number: 100'); + 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 }); 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.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..5377032fedd 100644 --- a/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx +++ b/packages/charts/src/components/ScatterChart/ScatterChart.stories.tsx @@ -60,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 d3d018e974d..68c9a1c6239 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 { /** @@ -215,9 +218,43 @@ 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} + // 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 new file mode 100644 index 00000000000..c827b3c3d59 --- /dev/null +++ b/packages/charts/src/components/ScatterChart/useScatterPointFocus.ts @@ -0,0 +1,325 @@ +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; +} + +// Resolves an accessor (string key or getter function) to the value from a data entry. +function getAccessorValue( + entry: Record, + accessor: string | ((e: Record) => unknown), +) { + if (typeof accessor === 'function') { + return accessor(entry); + } + 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) { + 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(); + + 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 []; + } + 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]); + + // 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; + } + 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'))); + }); + 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]; + if (!el) { + continue; + } + el.setAttribute('id', `${baseId}-point-${idx}`); + el.setAttribute('role', 'img'); + el.setAttribute('aria-label', getPointLabel(point, measures)); + } + + // 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}`); + 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 && typeof onSelect === 'function') { + onSelect(points[pointFocusRef.current], e); + } + break; + } + case ' ': { + // Space fires selection on keyup. + 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 && typeof onSelect === 'function') { + 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); + } + if (typeof consumerOnFocus === 'function') { + consumerOnFocus(e); + } + }, + [activatePoint, consumerOnFocus], + ); + + const handleBlur = useCallback( + (e: FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + clearActivePoint(); + spaceHeldRef.current = false; + } + if (typeof consumerOnBlur === 'function') { + consumerOnBlur(e); + } + }, + [clearActivePoint, consumerOnBlur], + ); + + const handleKeyDownCapture = useCallback( + (e: KeyboardEvent) => { + handleKeyDown(e); + if (typeof consumerOnKeyDownCapture === 'function') { + 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..d217053bb0f 100644 --- a/packages/charts/src/interfaces/IChartBaseProps.ts +++ b/packages/charts/src/interfaces/IChartBaseProps.ts @@ -109,8 +109,9 @@ export interface IChartBaseProps extends Omit