Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
210 changes: 209 additions & 1 deletion packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ScatterChart dataset={scatterComplexDataSet} measures={measures} />);
Expand Down Expand Up @@ -66,7 +77,204 @@ describe('ScatterChart', () => {
it('Loading Placeholder', () => {
cy.mount(<ScatterChart dataset={[]} measures={[]} />);
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(
<>
<button>before</button>
<ScatterChart
dataset={singleDataset}
measures={measures}
chartConfig={chartConfig}
onDataPointClick={onDataPointClick}
onBlur={onBlur}
onFocus={onFocus}
onKeyDownCapture={onKeyDownCapture}
/>
<button>after</button>
</>,
);
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(
<>
<button>before</button>
<ScatterChart dataset={multiDataset} measures={measures} chartConfig={chartConfig} />
</>,
);

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(
<>
<button>before</button>
<ScatterChart
dataset={singleDataset}
measures={measures}
chartConfig={chartConfig}
aria-roledescription="chart1"
/>
<ScatterChart
dataset={singleDataset}
measures={measures}
chartConfig={chartConfig}
aria-roledescription="chart2"
/>
<button>after</button>
</>,
);

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(<ScatterChart dataset={[]} measures={measures} chartConfig={{ accessibilityLayer }} />);
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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import LegendStory from '../../resources/LegendConfig.mdx';

<Canvas of={ComponentStories.WithCustomColor} />

### With Accessibility Layer

<Canvas of={ComponentStories.WithAccessibilityLayer} />

### Loading Placeholder

<Canvas of={ComponentStories.LoadingPlaceholder} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export const WithCustomColor: Story = {
},
};

export const WithAccessibilityLayer: Story = {
args: {
chartConfig: { accessibilityLayer: true },
},
};

export const LoadingPlaceholder: Story = {
args: {
dataset: [],
Expand Down
56 changes: 50 additions & 6 deletions packages/charts/src/components/ScatterChart/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<IChartMeasure, 'color' | 'hideDataLabel' | 'DataLabel'> {
/**
Expand Down Expand Up @@ -215,9 +218,43 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((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 (
<ChartContainer
dataset={dataset}
Expand All @@ -229,13 +266,16 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((props, ref)
className={className}
slot={slot}
resizeDebounce={chartConfig.resizeDebounce}
{...pointFocusProps}
{...propsWithoutOmitted}
>
<ScatterChartLib
onClick={onClickInternal}
margin={marginChart}
accessibilityLayer={chartConfig.accessibilityLayer}
className={typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined}
className={clsx(
typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined,
classNames.scatterchart,
)}
>
<CartesianGrid
vertical={chartConfig.gridVertical}
Expand Down Expand Up @@ -284,13 +324,17 @@ const ScatterChart = forwardRef<HTMLDivElement, ScatterChartProps>((props, ref)
return (
<Scatter
className={typeof onDataPointClick === 'function' ? 'has-click-handler' : undefined}
onMouseDown={onDataPointClickInternal}
onMouseDown={(payload, pointIndex, event) => {
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}
/>
);
})}
Expand Down
Loading
Loading