@automattic/charts 4.9.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.cjs +159 -66
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +14 -0
  5. package/dist/index.d.cts +27 -22
  6. package/dist/index.d.ts +27 -22
  7. package/dist/index.js +159 -66
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +2 -1
  11. package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
  12. package/src/charts/area-chart/types.ts +0 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +7 -2
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
  15. package/src/charts/bar-chart/types.ts +0 -2
  16. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  18. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  19. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  20. package/src/charts/geo-chart/geo-chart.tsx +17 -2
  21. package/src/charts/geo-chart/test/geo-chart.test.tsx +9 -4
  22. package/src/charts/geo-chart/types.ts +1 -1
  23. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +0 -6
  24. package/src/charts/heatmap-chart/types.ts +0 -2
  25. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  26. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
  27. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
  28. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +27 -1
  29. package/src/charts/leaderboard-chart/types.ts +1 -0
  30. package/src/charts/line-chart/line-chart.tsx +3 -0
  31. package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
  32. package/src/charts/line-chart/types.ts +0 -2
  33. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  34. package/src/charts/pie-chart/pie-chart.tsx +0 -5
  35. package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
  36. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  37. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
  38. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
  39. package/src/charts/test/accessible-name.test.tsx +181 -0
  40. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  41. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  42. package/src/components/legend/private/base-legend.tsx +27 -15
  43. package/src/components/legend/test/legend.test.tsx +42 -0
  44. package/src/components/legend/types.ts +2 -0
  45. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  46. package/src/hooks/use-xychart-theme.ts +33 -2
  47. package/src/index.ts +1 -0
  48. package/src/types.ts +19 -1
  49. package/src/utils/resolve-font-size.ts +9 -10
  50. package/src/utils/test/resolve-font-size.test.ts +11 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.9.0",
3
+ "version": "4.10.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -262,8 +262,9 @@ const AreaChartInternal = forwardRef< ChartInstanceRef, AreaChartProps >(
262
262
  glyphSize: 0,
263
263
  collapseGroups: legend.collapseGroups ?? false,
264
264
  comparisonItem: legend.comparisonItem ?? false,
265
+ comparisonSeriesItems: legend.comparisonSeriesItems,
265
266
  } ),
266
- [ legend.collapseGroups, legend.comparisonItem ]
267
+ [ legend.collapseGroups, legend.comparisonItem, legend.comparisonSeriesItems ]
267
268
  );
268
269
  const legendItems = useChartLegendItems( dataSorted, legendOptions, legendShape );
269
270
 
@@ -785,14 +785,6 @@ describe( 'AreaChart', () => {
785
785
  const chart = screen.getByRole( 'application', { name: /area chart/i } );
786
786
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
787
787
  expect( chart ).toHaveAttribute( 'aria-label', 'Area chart' );
788
- } );
789
-
790
- test.each( [
791
- [ undefined, 'Area chart' ],
792
- [ 'Views over time', 'Views over time' ],
793
- ] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
794
- renderWithProvider( { ariaLabel } );
795
- expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
796
788
  expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
797
789
  } );
798
790
  } );
@@ -11,8 +11,6 @@ import type { CurveType } from '../line-chart/types';
11
11
  import type { ReactNode } from 'react';
12
12
 
13
13
  export interface AreaChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
14
- /** Accessible name of the chart. Defaults to a localized "Area chart". */
15
- ariaLabel?: string;
16
14
  /**
17
15
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
18
16
  */
@@ -139,6 +139,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
139
139
  const legendInteractive = legend.interactive ?? false;
140
140
  const legendCollapseGroups = legend.collapseGroups ?? false;
141
141
  const legendComparisonItem = legend.comparisonItem ?? false;
142
+ const legendComparisonSeriesItems = legend.comparisonSeriesItems;
142
143
  const horizontal = orientation === 'horizontal';
143
144
  const chartId = useChartId( providedChartId );
144
145
  const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
@@ -159,8 +160,12 @@ const BarChartInternal: FC< BarChartProps > = ( {
159
160
 
160
161
  // Create legend items using the reusable hook
161
162
  const legendOptions = useMemo(
162
- () => ( { collapseGroups: legendCollapseGroups, comparisonItem: legendComparisonItem } ),
163
- [ legendCollapseGroups, legendComparisonItem ]
163
+ () => ( {
164
+ collapseGroups: legendCollapseGroups,
165
+ comparisonItem: legendComparisonItem,
166
+ comparisonSeriesItems: legendComparisonSeriesItems,
167
+ } ),
168
+ [ legendCollapseGroups, legendComparisonItem, legendComparisonSeriesItems ]
164
169
  );
165
170
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
166
171
 
@@ -1685,14 +1685,6 @@ describe( 'BarChart', () => {
1685
1685
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
1686
1686
  expect( chart ).toHaveAttribute( 'role', 'application' );
1687
1687
  expect( chart ).toHaveAttribute( 'aria-label', 'Bar chart' );
1688
- } );
1689
-
1690
- test.each( [
1691
- [ undefined, 'Bar chart' ],
1692
- [ 'Views over time', 'Views over time' ],
1693
- ] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
1694
- renderWithTheme( { ariaLabel } );
1695
- expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
1696
1688
  expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
1697
1689
  } );
1698
1690
 
@@ -19,8 +19,6 @@ export type BandHighlightSelection = {
19
19
  };
20
20
 
21
21
  export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
22
- /** Accessible name of the chart. Defaults to a localized "Bar chart". */
23
- ariaLabel?: string;
24
22
  /**
25
23
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
26
24
  */
@@ -2,6 +2,7 @@ import { formatNumberCompact } from '@automattic/number-formatters';
2
2
  import { Group } from '@visx/group';
3
3
  import { createScale, scaleBand } from '@visx/scale';
4
4
  import { Text, type TextProps } from '@visx/text';
5
+ import { __ } from '@wordpress/i18n';
5
6
  import clsx from 'clsx';
6
7
  import { useContext, useMemo } from 'react';
7
8
  import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
@@ -235,6 +236,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
235
236
  top: 0,
236
237
  },
237
238
  className,
239
+ ariaLabel,
238
240
  ...rest
239
241
  } ) => {
240
242
  const { getElementStyles } = useGlobalChartsContext();
@@ -333,6 +335,7 @@ const BarListChartInternal: FC< BarListChartProps > = ( {
333
335
  xScale: chartOptions.xScale,
334
336
  yScale: chartOptions.yScale,
335
337
  } }
338
+ ariaLabel={ ariaLabel ?? __( 'Bar list chart', 'jetpack-charts' ) }
336
339
  { ...rest }
337
340
  />
338
341
  );
@@ -1,9 +1,10 @@
1
1
  import { useTooltip } from '@visx/tooltip';
2
+ import { __ } from '@wordpress/i18n';
2
3
  import { Stack } from '@wordpress/ui';
3
4
  import clsx from 'clsx';
4
5
  import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
5
6
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
6
- import { usePrefersReducedMotion } from '../../hooks';
7
+ import { useElementSize, usePrefersReducedMotion } from '../../hooks';
7
8
  import {
8
9
  GlobalChartsProvider,
9
10
  GlobalChartsContext,
@@ -19,6 +20,9 @@ import styles from './conversion-funnel-chart.module.scss';
19
20
  import { useFunnelSelection } from './private';
20
21
  import type { FunnelStep, ConversionFunnelChartProps } from './types';
21
22
 
23
+ /** Chart height (px) below which the funnel tightens its gaps so the bars keep their room. */
24
+ const SHORT_CHART_HEIGHT = 180;
25
+
22
26
  /**
23
27
  * Internal ConversionFunnelChart component with chart registration
24
28
  *
@@ -30,6 +34,7 @@ import type { FunnelStep, ConversionFunnelChartProps } from './types';
30
34
  * @param props.loading - Whether the chart is in loading state
31
35
  * @param props.animation - Whether to show chart animation on initial render or not
32
36
  * @param props.className - Additional CSS class name
37
+ * @param props.ariaLabel - Accessible name for the chart group
33
38
  * @param props.height - Height of the chart container. Falls back to style.height if set, otherwise defaults to "100%".
34
39
  * @param props.style - Custom styling
35
40
  * @param props.renderStepLabel - Custom render function for step labels
@@ -46,6 +51,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
46
51
  loading = false,
47
52
  animation,
48
53
  className,
54
+ ariaLabel,
49
55
  chartId: providedChartId,
50
56
  height,
51
57
  style,
@@ -69,11 +75,19 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
69
75
  const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
70
76
  useFunnelSelection( hideTooltip );
71
77
  const standaloneScopeClass = useStandaloneScopeClass();
78
+ const [ measureChartRef, , chartHeight ] = useElementSize< HTMLDivElement >();
79
+ // A zero height is an unmeasured or hidden chart, not a short one.
80
+ const isShort = chartHeight > 0 && chartHeight < SHORT_CHART_HEIGHT;
81
+ const gap = isShort ? 'sm' : 'xl';
72
82
  // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep it firing on unmount (node === null).
73
- const setChartRef = useCallback( ( node: HTMLDivElement | null ) => {
74
- chartRef.current = node;
75
- setScopeNode( node );
76
- }, [] );
83
+ const setChartRef = useCallback(
84
+ ( node: HTMLDivElement | null ) => {
85
+ chartRef.current = node;
86
+ setScopeNode( node );
87
+ measureChartRef( node );
88
+ },
89
+ [ measureChartRef ]
90
+ );
77
91
 
78
92
  // Wrapper to clear selectedBarRef after clearing selection
79
93
  const clearSelectionAndRef = useCallback( () => {
@@ -309,8 +323,10 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
309
323
  return (
310
324
  <Stack
311
325
  direction="column"
312
- gap="xl"
326
+ gap={ gap }
313
327
  data-testid="conversion-funnel-chart"
328
+ role="group"
329
+ aria-label={ ariaLabel ?? __( 'Conversion funnel chart', 'jetpack-charts' ) }
314
330
  ref={ setChartRef }
315
331
  className={ clsx(
316
332
  'conversion-funnel-chart',
@@ -351,7 +367,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
351
367
  isColorPaletteResolved && styles[ 'funnel-step--animated' ],
352
368
  isBlurred && styles[ 'funnel-step--blurred' ]
353
369
  ) }
354
- gap="xl"
370
+ gap={ gap }
355
371
  >
356
372
  { /* Step Label and Rate */ }
357
373
  <Stack direction="column" gap="xs">
@@ -3,6 +3,11 @@ import userEvent from '@testing-library/user-event';
3
3
  import ConversionFunnelChart from '../conversion-funnel-chart';
4
4
  import type { FunnelStep } from '../types';
5
5
 
6
+ let mockChartHeight = 0;
7
+ jest.mock( '../../../hooks/use-element-size', () => ( {
8
+ useElementSize: () => [ jest.fn(), 500, mockChartHeight ],
9
+ } ) );
10
+
6
11
  // Mock data for testing
7
12
  const mockSteps: FunnelStep[] = [
8
13
  {
@@ -42,6 +47,10 @@ const renderWithoutTheme = ( component: React.ReactElement ) => {
42
47
  };
43
48
 
44
49
  describe( 'ConversionFunnelChart', () => {
50
+ afterEach( () => {
51
+ mockChartHeight = 0;
52
+ } );
53
+
45
54
  describe( 'Basic Rendering', () => {
46
55
  it( 'renders the main conversion rate', () => {
47
56
  renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
@@ -116,6 +125,19 @@ describe( 'ConversionFunnelChart', () => {
116
125
  height: '250px',
117
126
  } );
118
127
  } );
128
+
129
+ it.each( [
130
+ [ 'unmeasured', 0, 'xl' ],
131
+ [ 'short', 120, 'sm' ],
132
+ [ 'tall', 320, 'xl' ],
133
+ ] )( 'spaces a %s (%ipx) chart with the %s gap', ( _, height, gap ) => {
134
+ mockChartHeight = height;
135
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
136
+
137
+ expect( screen.getByTestId( 'conversion-funnel-chart' ).style.gap ).toContain(
138
+ `gap-${ gap }`
139
+ );
140
+ } );
119
141
  } );
120
142
 
121
143
  describe( 'Empty State', () => {
@@ -59,7 +59,7 @@ export interface TooltipRenderProps {
59
59
  */
60
60
  export interface ConversionFunnelChartProps extends Pick<
61
61
  BaseChartProps,
62
- 'className' | 'chartId' | 'animation'
62
+ 'className' | 'chartId' | 'animation' | 'ariaLabel'
63
63
  > {
64
64
  /** Main conversion rate to highlight */
65
65
  mainRate: number;
@@ -126,6 +126,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
126
126
  *
127
127
  * @param props - The props for the GeoChart component
128
128
  * @param props.data - Data in Google Charts format (array of arrays with headers)
129
+ * @param props.ariaLabel - Accessible name of the chart
129
130
  * @param props.width - Width of the chart in pixels
130
131
  * @param props.height - Height of the chart in pixels
131
132
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
@@ -140,6 +141,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
140
141
  const GeoChartInternal: FC< GeoChartProps > = ( {
141
142
  className,
142
143
  data,
144
+ ariaLabel,
143
145
  width,
144
146
  height,
145
147
  region = 'world',
@@ -289,12 +291,23 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
289
291
  ]
290
292
  );
291
293
 
292
- const chartEvents = useMemo< ReactGoogleChartEvent[] | undefined >( () => {
294
+ const chartEvents = useMemo< ReactGoogleChartEvent[] >( () => {
295
+ const readyEvent: ReactGoogleChartEvent = {
296
+ eventName: 'ready',
297
+ // Google hardcodes aria-label="A chart." on its SVG, which repeats the chart's own name.
298
+ callback: () => {
299
+ containerRef.current
300
+ ?.querySelectorAll( 'svg' )
301
+ .forEach( svg => svg.setAttribute( 'aria-hidden', 'true' ) );
302
+ },
303
+ };
304
+
293
305
  if ( ! onError ) {
294
- return undefined;
306
+ return [ readyEvent ];
295
307
  }
296
308
 
297
309
  return [
310
+ readyEvent,
298
311
  {
299
312
  eventName: 'error',
300
313
  callback: ( { eventArgs } ) => {
@@ -307,6 +320,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
307
320
  return (
308
321
  <Center
309
322
  ref={ containerRef }
323
+ role="group"
324
+ aria-label={ ariaLabel ?? __( 'Geo chart', 'jetpack-charts' ) }
310
325
  className={ clsx( 'geo-chart', styles.container, className ) }
311
326
  data-testid="geo-chart"
312
327
  style={ { width, height } }
@@ -281,13 +281,18 @@ describe( 'GeoChart', () => {
281
281
  } );
282
282
 
283
283
  describe( 'Chart Events', () => {
284
- test( 'does not pass chart events when onError is omitted', () => {
284
+ test( "hides Google's own SVG label from screen readers once the map is ready", () => {
285
285
  renderWithTheme();
286
286
 
287
- const chartEvents = screen.getByTestId( 'chart-events' );
288
- const events = JSON.parse( chartEvents.textContent || '[]' );
287
+ const svg = document.createElementNS( 'http://www.w3.org/2000/svg', 'svg' );
288
+ screen.getByTestId( 'google-chart-mock' ).appendChild( svg );
289
289
 
290
- expect( events ).toEqual( [] );
290
+ const chartProps = ChartMock.mock.calls[ ChartMock.mock.calls.length - 1 ]?.[ 0 ];
291
+ chartProps.chartEvents
292
+ .find( ( event: { eventName: string } ) => event.eventName === 'ready' )
293
+ .callback( {} );
294
+
295
+ expect( svg ).toHaveAttribute( 'aria-hidden', 'true' );
291
296
  } );
292
297
 
293
298
  test( 'calls onError when Google Charts emits an error', () => {
@@ -31,7 +31,7 @@ export interface GeoChartError {
31
31
 
32
32
  export interface GeoChartProps extends Pick<
33
33
  BaseChartProps,
34
- 'className' | 'chartId' | 'width' | 'height'
34
+ 'className' | 'chartId' | 'ariaLabel' | 'width' | 'height'
35
35
  > {
36
36
  /**
37
37
  * Data in Google Charts native format for maximum flexibility.
@@ -1222,12 +1222,6 @@ describe( 'HeatmapChart calendar navigation', () => {
1222
1222
  } );
1223
1223
 
1224
1224
  describe( 'HeatmapChart naming and focus', () => {
1225
- test( 'names the grid from ariaLabel when given', () => {
1226
- renderChart( { ariaLabel: 'Monthly posting activity' } );
1227
- expect( screen.getByRole( 'grid', { name: 'Monthly posting activity' } ) ).toBeInTheDocument();
1228
- expect( screen.queryByRole( 'grid', { name: /heatmap chart/i } ) ).not.toBeInTheDocument();
1229
- } );
1230
-
1231
1225
  test( 'scrolls the keyboard-selected cell into view', async () => {
1232
1226
  // jsdom has no scrollIntoView; the assertion is that the chart asks for it.
1233
1227
  const scrollIntoView = jest.fn();
@@ -74,8 +74,6 @@ export interface HeatmapChartProps extends Omit<
74
74
  * group past the edge, Page Up/Down by one group. Default `grid`.
75
75
  */
76
76
  keyboardNavigation?: 'grid' | 'calendar';
77
- /** Accessible name of the grid. Defaults to a localized "Heatmap chart". */
78
- ariaLabel?: string;
79
77
  /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */
80
78
  compact?: boolean;
81
79
  /** Compact mode only: grow the cells to fill the chart's box, wrapping the column groups onto more rows when that makes them larger. Default false. */
@@ -16,8 +16,8 @@ export const SUBPIXEL_TOLERANCE = 0.5;
16
16
  * depends on `--a8c-charts-dimension-leaderboard-row-gap`, label wrapping, and
17
17
  * whatever a caller renders as a label, so any restated number would drift.
18
18
  *
19
- * Every row is a direct grid child marked with `data-row-index`; interactive rows
20
- * use a button wrapper and non-interactive rows use a div wrapper.
19
+ * Every row is a `listitem` div, a direct grid child marked with `data-row-index`;
20
+ * interactive rows hold a button inside it.
21
21
  *
22
22
  * @param enabled - Whether to measure at all. When false every row fits.
23
23
  * @param rowCount - Total number of rows rendered.
@@ -147,6 +147,12 @@
147
147
  // needs no runtime cascade and never lands on the DOM as an unprefixed var.
148
148
  $focus-ring-width: var(--wpds-border-width-focus);
149
149
 
150
+ .rowItem {
151
+ display: grid;
152
+ grid-column: 1 / -1;
153
+ grid-template-columns: subgrid;
154
+ }
155
+
150
156
  .row {
151
157
  display: grid;
152
158
  grid-column: 1 / -1;
@@ -188,6 +188,7 @@ const BarWithLabel = ( {
188
188
  * @param props.children - Child components for composition API
189
189
  * @param props.className - Additional CSS class name
190
190
  * @param props.style - Custom styling for the chart container
191
+ * @param props.ariaLabel - Accessible name for the list, defaulting to "Leaderboard chart"
191
192
  * @return JSX element representing the leaderboard chart
192
193
  */
193
194
  const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
@@ -208,6 +209,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
208
209
  legend = {},
209
210
  legendLabels,
210
211
  gap = 'md',
212
+ ariaLabel,
211
213
  className,
212
214
  style,
213
215
  children,
@@ -389,6 +391,8 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
389
391
  </div>
390
392
  ) : (
391
393
  <Grid
394
+ role="list"
395
+ aria-label={ ariaLabel ?? __( 'Leaderboard chart', 'jetpack-charts' ) }
392
396
  templateColumns="minmax(0, 1fr) auto"
393
397
  rowGap={ ROW_GAP }
394
398
  columnGap={ COLUMN_GAP }
@@ -465,46 +469,57 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
465
469
 
466
470
  if ( entry.onClick && entry.hasInteractiveLabel ) {
467
471
  return (
468
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Mouse-only fallback; the row button carries keyboard access.
469
472
  <div
470
473
  key={ entry.id }
474
+ role="listitem"
471
475
  data-row-index={ rowIndex }
472
476
  style={ rowStyle }
473
- className={ clsx( styles.row, styles.interactiveRow, styles.layeredRow ) }
474
- onClick={ forwardCellClickToRowButton }
477
+ className={ styles.rowItem }
475
478
  >
476
- { rowCells }
477
- <button
478
- type="button"
479
- className={ styles.rowButton }
480
- onClick={ entry.onClick }
481
- aria-label={ entry.ariaLabel }
482
- />
483
- <Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
479
+ { /* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- Mouse-only fallback; the row button carries keyboard access. */ }
480
+ <div
481
+ className={ clsx( styles.row, styles.interactiveRow, styles.layeredRow ) }
482
+ onClick={ forwardCellClickToRowButton }
483
+ >
484
+ { rowCells }
485
+ <button
486
+ type="button"
487
+ className={ styles.rowButton }
488
+ onClick={ entry.onClick }
489
+ aria-label={ entry.ariaLabel }
490
+ />
491
+ <Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
492
+ </div>
484
493
  </div>
485
494
  );
486
495
  }
487
496
 
488
497
  if ( entry.onClick ) {
489
498
  return (
490
- <button
499
+ <div
491
500
  key={ entry.id }
492
- type="button"
501
+ role="listitem"
493
502
  data-row-index={ rowIndex }
494
503
  style={ rowStyle }
495
- className={ clsx( styles.row, styles.interactiveRow ) }
496
- onClick={ entry.onClick }
497
- aria-label={ entry.ariaLabel }
504
+ className={ styles.rowItem }
498
505
  >
499
- { rowCells }
500
- <Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
501
- </button>
506
+ <button
507
+ type="button"
508
+ className={ clsx( styles.row, styles.interactiveRow ) }
509
+ onClick={ entry.onClick }
510
+ aria-label={ entry.ariaLabel }
511
+ >
512
+ { rowCells }
513
+ <Icon className={ styles.chevron } icon={ chevronRight } size={ 24 } />
514
+ </button>
515
+ </div>
502
516
  );
503
517
  }
504
518
 
505
519
  return (
506
520
  <div
507
521
  key={ entry.id }
522
+ role="listitem"
508
523
  data-row-index={ rowIndex }
509
524
  style={ rowStyle }
510
525
  className={ styles.row }
@@ -1,4 +1,4 @@
1
- import { render, screen, act } from '@testing-library/react';
1
+ import { render, screen, act, within } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { GlobalChartsProvider } from '../../../providers';
4
4
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
@@ -706,6 +706,32 @@ describe( 'LeaderboardChart', () => {
706
706
  );
707
707
  } );
708
708
 
709
+ it( 'renders each entry as a list item, keeping clickable rows as named buttons', () => {
710
+ render(
711
+ <LeaderboardChart
712
+ data={ [
713
+ mockData[ 0 ],
714
+ { ...mockData[ 1 ], ariaLabel: 'Open Home', onClick: jest.fn() },
715
+ {
716
+ ...mockData[ 1 ],
717
+ id: 'about',
718
+ ariaLabel: 'Open About',
719
+ onClick: jest.fn(),
720
+ hasInteractiveLabel: true,
721
+ },
722
+ ] }
723
+ />
724
+ );
725
+ const items = screen.getAllByRole( 'listitem' );
726
+ expect( items ).toHaveLength( 3 );
727
+ expect(
728
+ within( items[ 1 ] ).getByRole( 'button', { name: 'Open Home' } )
729
+ ).toBeInTheDocument();
730
+ expect(
731
+ within( items[ 2 ] ).getByRole( 'button', { name: 'Open About' } )
732
+ ).toBeInTheDocument();
733
+ } );
734
+
709
735
  it( 'does not render a button for entries without onClick', () => {
710
736
  render( <LeaderboardChart data={ [ mockData[ 0 ] ] } /> );
711
737
  expect( screen.queryByRole( 'button' ) ).not.toBeInTheDocument();
@@ -12,6 +12,7 @@ export interface LeaderboardChartProps extends Pick<
12
12
  | 'size'
13
13
  | 'gap'
14
14
  | 'animation'
15
+ | 'ariaLabel'
15
16
  > {
16
17
  /**
17
18
  * Entries to display. Declared here rather than picked, since the base type
@@ -272,6 +272,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
272
272
  const legendInteractive = legend.interactive ?? false;
273
273
  const legendCollapseGroups = legend.collapseGroups ?? false;
274
274
  const legendComparisonItem = legend.comparisonItem ?? false;
275
+ const legendComparisonSeriesItems = legend.comparisonSeriesItems;
275
276
  const legendShape = legend.shape ?? 'line';
276
277
  const legendPosition = legend.position ?? 'bottom';
277
278
 
@@ -523,6 +524,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
523
524
  glyphSize: Math.max( 0, toNumber( glyphStyle?.radius ) ?? 4 ),
524
525
  collapseGroups: legendCollapseGroups,
525
526
  comparisonItem: legendComparisonItem,
527
+ comparisonSeriesItems: legendComparisonSeriesItems,
526
528
  renderGlyph,
527
529
  } ),
528
530
  [
@@ -530,6 +532,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
530
532
  glyphStyle?.radius,
531
533
  legendCollapseGroups,
532
534
  legendComparisonItem,
535
+ legendComparisonSeriesItems,
533
536
  renderGlyph,
534
537
  ]
535
538
  );
@@ -1610,14 +1610,6 @@ describe( 'LineChart', () => {
1610
1610
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
1611
1611
  expect( chart ).toHaveAttribute( 'role', 'application' );
1612
1612
  expect( chart ).toHaveAttribute( 'aria-label', 'Line chart' );
1613
- } );
1614
-
1615
- test.each( [
1616
- [ undefined, 'Line chart' ],
1617
- [ 'Views over time', 'Views over time' ],
1618
- ] )( 'names the chart container from ariaLabel %p', ( ariaLabel, name ) => {
1619
- renderWithTheme( { ariaLabel } );
1620
- expect( screen.getByRole( 'application', { name } ) ).toBeInTheDocument();
1621
1613
  expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
1622
1614
  } );
1623
1615
 
@@ -31,8 +31,6 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
31
31
  };
32
32
 
33
33
  export interface LineChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
34
- /** Accessible name of the chart. Defaults to a localized "Line chart". */
35
- ariaLabel?: string;
36
34
  /**
37
35
  * Legend configuration. Supports `collapseGroups` on top of the shared options.
38
36
  */
@@ -1,4 +1,7 @@
1
1
  .pie-chart {
2
+ // Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
3
+ isolation: isolate;
4
+
2
5
  // Anchors the tooltip, which renders next to the SVG in here.
3
6
  &__plot {
4
7
  position: relative;
@@ -138,11 +138,6 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
138
138
  * When provided, replaces the default `label: value` tooltip with custom content.
139
139
  */
140
140
  renderTooltip?: ( params: PieChartRenderTooltipParams ) => ReactNode;
141
-
142
- /**
143
- * Accessible name of the chart. Defaults to a localized "Pie chart".
144
- */
145
- ariaLabel?: string;
146
141
  }
147
142
 
148
143
  // Base props type with optional responsive properties
@@ -780,14 +780,6 @@ describe( 'PieChart', () => {
780
780
  { label: 'Linux', value: 22000, valueDisplay: '22K' },
781
781
  ];
782
782
 
783
- test.each( [
784
- [ undefined, 'Pie chart' ],
785
- [ 'Devices', 'Devices' ],
786
- ] )( 'names the focusable chart from ariaLabel %p', ( ariaLabel, name ) => {
787
- render( <PieChart data={ data } ariaLabel={ ariaLabel } /> );
788
- expect( screen.getByRole( 'application', { name } ) ).toHaveAttribute( 'tabindex', '0' );
789
- } );
790
-
791
783
  const expectRingOn = ( segment: HTMLElement ) =>
792
784
  expect( screen.getByTestId( 'pie-selected-ring' ) ).toHaveAttribute(
793
785
  'd',
@@ -799,6 +791,10 @@ describe( 'PieChart', () => {
799
791
  render( <PieChart data={ data } withTooltips /> );
800
792
  const [ macos, linux ] = screen.getAllByTestId( 'pie-segment' );
801
793
  expect( screen.queryByTestId( 'pie-selected-ring' ) ).not.toBeInTheDocument();
794
+ expect( screen.getByRole( 'application', { name: 'Pie chart' } ) ).toHaveAttribute(
795
+ 'tabindex',
796
+ '0'
797
+ );
802
798
 
803
799
  await user.tab();
804
800
  await user.keyboard( '{ArrowRight}' );
@@ -1,4 +1,7 @@
1
1
  .pie-semi-circle-chart {
2
+ // Keeps the tooltip's z-index inside the chart, as `.xy-plot` does.
3
+ isolation: isolate;
4
+
2
5
  // Anchors the tooltip, which renders next to the SVG in here.
3
6
  &__plot {
4
7
  position: relative;