@automattic/charts 4.9.0 → 4.11.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 (67) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +7 -3
  3. package/dist/index.cjs +496 -774
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +66 -3
  6. package/dist/index.d.cts +65 -60
  7. package/dist/index.d.ts +65 -60
  8. package/dist/index.js +467 -726
  9. package/dist/index.js.map +1 -1
  10. package/dist/visx/group/index.js +1 -2
  11. package/dist/visx/legend/index.js +1 -2
  12. package/dist/visx/text/index.js +1 -2
  13. package/package.json +13 -12
  14. package/src/charts/area-chart/area-chart.tsx +2 -1
  15. package/src/charts/area-chart/test/area-chart.test.tsx +0 -8
  16. package/src/charts/area-chart/types.ts +0 -2
  17. package/src/charts/bar-chart/bar-chart.tsx +7 -2
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +0 -8
  19. package/src/charts/bar-chart/types.ts +0 -2
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +23 -7
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +22 -0
  24. package/src/charts/conversion-funnel-chart/types.ts +1 -1
  25. package/src/charts/geo-chart/geo-chart.module.scss +27 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +85 -45
  27. package/src/charts/geo-chart/private/use-tooltip-position.ts +106 -0
  28. package/src/charts/geo-chart/test/geo-chart.test.tsx +77 -46
  29. package/src/charts/geo-chart/test/use-tooltip-position.test.ts +43 -0
  30. package/src/charts/geo-chart/types.ts +1 -1
  31. package/src/charts/heatmap-chart/heatmap-chart.module.scss +3 -3
  32. package/src/charts/heatmap-chart/private/heatmap-scale.ts +48 -43
  33. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +5 -11
  34. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +55 -27
  35. package/src/charts/heatmap-chart/types.ts +1 -3
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +6 -0
  38. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +34 -19
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +35 -6
  40. package/src/charts/leaderboard-chart/types.ts +1 -0
  41. package/src/charts/line-chart/line-chart.tsx +3 -0
  42. package/src/charts/line-chart/test/line-chart.test.tsx +0 -8
  43. package/src/charts/line-chart/types.ts +0 -2
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +0 -5
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +4 -8
  47. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +3 -0
  48. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +0 -5
  49. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +8 -3
  50. package/src/charts/test/accessible-name.test.tsx +181 -0
  51. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  52. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  53. package/src/components/legend/private/base-legend.module.scss +5 -0
  54. package/src/components/legend/private/base-legend.tsx +35 -17
  55. package/src/components/legend/test/legend.test.tsx +42 -0
  56. package/src/components/legend/types.ts +2 -0
  57. package/src/components/tooltip/_surface.scss +19 -0
  58. package/src/components/tooltip/private/bounded-tooltip.tsx +2 -2
  59. package/src/components/tooltip/tooltip-box.module.scss +3 -17
  60. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  61. package/src/hooks/use-xychart-theme.ts +33 -2
  62. package/src/index.ts +1 -0
  63. package/src/styles/chart-scope.scss +6 -4
  64. package/src/styles/test/chart-scope.test.ts +6 -2
  65. package/src/types.ts +19 -1
  66. package/src/utils/resolve-font-size.ts +9 -10
  67. package/src/utils/test/resolve-font-size.test.ts +11 -2
@@ -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. */
@@ -104,7 +102,7 @@ export interface HeatmapChartProps extends Omit<
104
102
  /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
105
103
  minCellHeight?: number;
106
104
  /**
107
- * Color the cell scale is built from, deepened at the highest value toward 9:1
105
+ * Color the cell scale is built from, deepened at the highest value toward 6:1
108
106
  * against the background. Defaults to the first series palette slot,
109
107
  * `--a8c-charts-color-series-1`.
110
108
  */
@@ -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();
@@ -721,15 +747,18 @@ describe( 'LeaderboardChart', () => {
721
747
 
722
748
  describe( 'grid gaps', () => {
723
749
  // eslint-disable-next-line testing-library/no-node-access -- The grid is rendered by `Grid` from @wordpress/components, which takes no test id.
724
- const grid = () => document.querySelector( '[data-leaderboard-grid]' );
750
+ const grid = () => document.querySelector< HTMLElement >( '[data-leaderboard-grid]' );
725
751
 
726
752
  it( 'hands the catalog role to the grid', () => {
727
753
  render( <LeaderboardChart data={ mockData } /> );
728
754
 
729
- expect( grid() ).toHaveStyle( {
730
- gridRowGap: 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)',
731
- gridColumnGap: 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)',
732
- } );
755
+ // Grid writes its gaps into custom properties and reads them from a stylesheet.
756
+ expect( grid().style.getPropertyValue( '--wp-components-grid-row-gap' ) ).toBe(
757
+ 'var(--a8c-charts-dimension-leaderboard-row-gap, 12px)'
758
+ );
759
+ expect( grid().style.getPropertyValue( '--wp-components-grid-column-gap' ) ).toBe(
760
+ 'var(--a8c-charts-dimension-leaderboard-column-gap, 4px)'
761
+ );
733
762
  } );
734
763
  } );
735
764
  } );
@@ -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;
@@ -129,11 +129,6 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
129
129
  * When provided, replaces the default `label: value` tooltip with custom content.
130
130
  */
131
131
  renderTooltip?: ( params: PieSemiCircleChartRenderTooltipParams ) => ReactNode;
132
-
133
- /**
134
- * Accessible name of the chart. Defaults to a localized "Semi-circle chart".
135
- */
136
- ariaLabel?: string;
137
132
  }
138
133
 
139
134
  // Base props type with optional responsive properties
@@ -1,6 +1,11 @@
1
- import { BoundedTooltip } from '../../../components/tooltip/private/bounded-tooltip';
1
+ import {
2
+ BoundedTooltip,
3
+ TOOLTIP_Z_INDEX,
4
+ } from '../../../components/tooltip/private/bounded-tooltip';
2
5
  import { useStandaloneScopeClass } from '../../../providers/chart-scope';
3
- import type { ReactNode } from 'react';
6
+ import type { CSSProperties, ReactNode } from 'react';
7
+
8
+ const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
4
9
 
5
10
  interface PieTooltipProps {
6
11
  top: number;
@@ -21,7 +26,7 @@ export const PieTooltip = ( {
21
26
  const standaloneScopeClass = useStandaloneScopeClass();
22
27
 
23
28
  return (
24
- <BoundedTooltip top={ top } left={ left }>
29
+ <BoundedTooltip top={ top } left={ left } style={ TOOLTIP_BOX_STYLE }>
25
30
  { selectedIndex === undefined ? (
26
31
  <div className={ standaloneScopeClass } role="tooltip">
27
32
  { children }
@@ -0,0 +1,181 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers';
3
+ import { salesByProduct } from '../../stories/sample-data';
4
+ import { AreaChartUnresponsive } from '../area-chart';
5
+ import { BarChartUnresponsive } from '../bar-chart';
6
+ import { BarListChartUnresponsive } from '../bar-list-chart';
7
+ import { ConversionFunnelChart } from '../conversion-funnel-chart';
8
+ import { GeoChartUnresponsive } from '../geo-chart';
9
+ import { HeatmapChart } from '../heatmap-chart';
10
+ import { LeaderboardChartUnresponsive } from '../leaderboard-chart';
11
+ import { LineChartUnresponsive } from '../line-chart';
12
+ import { PieChartUnresponsive } from '../pie-chart';
13
+ import { PieSemiCircleChartUnresponsive } from '../pie-semi-circle-chart';
14
+ import type { GeoData } from '../../types';
15
+ import type { ReactElement } from 'react';
16
+
17
+ jest.mock( 'react-google-charts', () => ( { Chart: () => <div /> } ) );
18
+
19
+ const series = [
20
+ {
21
+ label: 'Series A',
22
+ data: [
23
+ { date: new Date( '2024-01-01' ), value: 10 },
24
+ { date: new Date( '2024-01-02' ), value: 20 },
25
+ ],
26
+ },
27
+ ];
28
+
29
+ const slices = [
30
+ { label: 'MacOS', value: 30, valueDisplay: '30', percentage: 60 },
31
+ { label: 'Linux', value: 20, valueDisplay: '20', percentage: 40 },
32
+ ];
33
+
34
+ const geoData: GeoData = [
35
+ [ 'Country', 'Value' ],
36
+ [ 'US', 100 ],
37
+ [ 'CA', 50 ],
38
+ [ 'GB', 25 ],
39
+ ];
40
+
41
+ const funnelSteps = [
42
+ { id: 'sessions', label: 'Sessions', rate: 100, count: 10000 },
43
+ { id: 'cart', label: 'Cart', rate: 71.1, count: 7110 },
44
+ { id: 'checkout', label: 'Checkout', rate: 52.5, count: 5250 },
45
+ { id: 'purchase', label: 'Purchase', rate: 10.3, count: 1030 },
46
+ ];
47
+
48
+ const heatmapData = [
49
+ { label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] },
50
+ { label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] },
51
+ ];
52
+
53
+ const leaderboardData = [
54
+ {
55
+ id: 'direct',
56
+ label: 'Direct',
57
+ currentValue: 12500,
58
+ previousValue: 10000,
59
+ currentShare: 100,
60
+ previousShare: 80,
61
+ delta: 25,
62
+ },
63
+ {
64
+ id: 'social',
65
+ label: 'Social Media',
66
+ currentValue: 8750,
67
+ previousValue: 9500,
68
+ currentShare: 70,
69
+ previousShare: 76,
70
+ delta: -8,
71
+ },
72
+ ];
73
+
74
+ const CHARTS: [ string, string, string, ( ariaLabel?: string ) => ReactElement ][] = [
75
+ [
76
+ 'line',
77
+ 'application',
78
+ 'Line chart',
79
+ ariaLabel => (
80
+ <LineChartUnresponsive
81
+ data={ series }
82
+ width={ 400 }
83
+ height={ 300 }
84
+ withGradientFill={ false }
85
+ ariaLabel={ ariaLabel }
86
+ />
87
+ ),
88
+ ],
89
+ [
90
+ 'area',
91
+ 'application',
92
+ 'Area chart',
93
+ ariaLabel => (
94
+ <AreaChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
95
+ ),
96
+ ],
97
+ [
98
+ 'bar',
99
+ 'application',
100
+ 'Bar chart',
101
+ ariaLabel => (
102
+ <BarChartUnresponsive data={ series } width={ 400 } height={ 300 } ariaLabel={ ariaLabel } />
103
+ ),
104
+ ],
105
+ [
106
+ 'bar list',
107
+ 'application',
108
+ 'Bar list chart',
109
+ ariaLabel => (
110
+ <BarListChartUnresponsive
111
+ data={ salesByProduct }
112
+ width={ 400 }
113
+ height={ 300 }
114
+ ariaLabel={ ariaLabel }
115
+ />
116
+ ),
117
+ ],
118
+ [
119
+ 'pie',
120
+ 'application',
121
+ 'Pie chart',
122
+ ariaLabel => <PieChartUnresponsive data={ slices } size={ 300 } ariaLabel={ ariaLabel } />,
123
+ ],
124
+ [
125
+ 'semi-circle',
126
+ 'application',
127
+ 'Semi-circle chart',
128
+ ariaLabel => (
129
+ <PieSemiCircleChartUnresponsive data={ slices } width={ 300 } ariaLabel={ ariaLabel } />
130
+ ),
131
+ ],
132
+ [
133
+ 'geo',
134
+ 'group',
135
+ 'Geo chart',
136
+ ariaLabel => (
137
+ <GeoChartUnresponsive data={ geoData } width={ 800 } height={ 400 } ariaLabel={ ariaLabel } />
138
+ ),
139
+ ],
140
+ [
141
+ 'conversion funnel',
142
+ 'group',
143
+ 'Conversion funnel chart',
144
+ ariaLabel => (
145
+ <ConversionFunnelChart mainRate={ 25 } steps={ funnelSteps } ariaLabel={ ariaLabel } />
146
+ ),
147
+ ],
148
+ [
149
+ 'heatmap',
150
+ 'grid',
151
+ 'Heatmap chart',
152
+ ariaLabel => (
153
+ <HeatmapChart data={ heatmapData } width={ 500 } height={ 300 } ariaLabel={ ariaLabel } />
154
+ ),
155
+ ],
156
+ [
157
+ 'leaderboard',
158
+ 'list',
159
+ 'Leaderboard chart',
160
+ ariaLabel => (
161
+ <LeaderboardChartUnresponsive
162
+ data={ leaderboardData }
163
+ width={ 400 }
164
+ height={ 300 }
165
+ ariaLabel={ ariaLabel }
166
+ />
167
+ ),
168
+ ],
169
+ ];
170
+
171
+ it.each( CHARTS )(
172
+ 'names the %s chart from ariaLabel, or its default',
173
+ ( _chart, role, defaultName, renderChart ) => {
174
+ const { unmount } = render( <GlobalChartsProvider>{ renderChart() }</GlobalChartsProvider> );
175
+ expect( screen.getByRole( role, { name: defaultName } ) ).toBeInTheDocument();
176
+ unmount();
177
+
178
+ render( <GlobalChartsProvider>{ renderChart( 'Views by day' ) }</GlobalChartsProvider> );
179
+ expect( screen.getByRole( role, { name: 'Views by day' } ) ).toBeInTheDocument();
180
+ }
181
+ );
@@ -257,6 +257,23 @@ describe( 'useChartLegendItems', () => {
257
257
  data: [ { label: 'Mon', value: 90 } ],
258
258
  },
259
259
  ];
260
+ const twoMetricData: SeriesData[] = [
261
+ { label: 'Views', group: 'views', data: [ { label: 'Mon', value: 100 } ] },
262
+ {
263
+ label: 'Views — previous',
264
+ group: 'views',
265
+ options: { type: 'comparison' as const },
266
+ data: [ { label: 'Mon', value: 90 } ],
267
+ },
268
+ { label: 'Visitors', group: 'visitors', data: [ { label: 'Mon', value: 50 } ] },
269
+ {
270
+ label: 'Visitors — previous',
271
+ group: 'visitors',
272
+ options: { type: 'comparison' as const },
273
+ data: [ { label: 'Mon', value: 40 } ],
274
+ },
275
+ ];
276
+ const perMetricLabel = ( metric: string ) => `${ metric } (Prev. 7 days)`;
260
277
 
261
278
  test( 'is off by default', () => {
262
279
  const { result } = renderHook(
@@ -300,15 +317,58 @@ describe( 'useChartLegendItems', () => {
300
317
  expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Previous period' ] );
301
318
  } );
302
319
 
303
- test( 'adds nothing when the comparison series already has its own item', () => {
320
+ test( 'names one static item per compared metric with comparisonSeriesItems', () => {
304
321
  const { result } = renderHook(
305
- () => useChartLegendItems( comparisonData, { comparisonItem: true } ),
322
+ () =>
323
+ useChartLegendItems(
324
+ twoMetricData,
325
+ { collapseGroups: true, comparisonSeriesItems: perMetricLabel },
326
+ 'line'
327
+ ),
306
328
  { wrapper }
307
329
  );
308
330
 
309
- expect( result.current.map( item => item.label ) ).toEqual( [ 'Views', 'Views — previous' ] );
331
+ expect( result.current.map( item => item.label ) ).toEqual( [
332
+ 'Views',
333
+ 'Visitors',
334
+ 'Views (Prev. 7 days)',
335
+ 'Visitors (Prev. 7 days)',
336
+ ] );
337
+ expect(
338
+ result.current.slice( 2 ).map( ( { interactive, describesSeries } ) => ( {
339
+ interactive,
340
+ describesSeries,
341
+ } ) )
342
+ ).toEqual( [
343
+ { interactive: false, describesSeries: 'Views — previous' },
344
+ { interactive: false, describesSeries: 'Visitors — previous' },
345
+ ] );
346
+ expect( result.current[ 2 ].color ).toBe( result.current[ 0 ].color );
347
+ expect( result.current[ 3 ].color ).toBe( result.current[ 1 ].color );
310
348
  } );
311
349
 
350
+ test.each( [
351
+ [
352
+ 'comparisonItem',
353
+ comparisonData,
354
+ { comparisonItem: true },
355
+ [ 'Views', 'Views — previous' ],
356
+ ],
357
+ [
358
+ 'comparisonSeriesItems',
359
+ twoMetricData,
360
+ { comparisonSeriesItems: perMetricLabel },
361
+ [ 'Views', 'Views — previous', 'Visitors', 'Visitors — previous' ],
362
+ ],
363
+ ] )(
364
+ 'adds nothing for %s when the comparison series already has its own item',
365
+ ( _name, data, options, expected ) => {
366
+ const { result } = renderHook( () => useChartLegendItems( data, options ), { wrapper } );
367
+
368
+ expect( result.current.map( item => item.label ) ).toEqual( expected );
369
+ }
370
+ );
371
+
312
372
  test( 'styles a current-period series by its own line styles, not the theme index', () => {
313
373
  const theme = {
314
374
  seriesLineStyles: [ { strokeWidth: 2 } ],