@automattic/charts 4.8.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 (75) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +766 -277
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +72 -8
  5. package/dist/index.d.cts +54 -15
  6. package/dist/index.d.ts +54 -15
  7. package/dist/index.js +770 -281
  8. package/dist/index.js.map +1 -1
  9. package/package.json +1 -1
  10. package/src/charts/area-chart/area-chart.tsx +7 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +15 -14
  12. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +2 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +12 -4
  14. package/src/charts/bar-chart/test/bar-chart.test.tsx +74 -63
  15. package/src/charts/bar-list-chart/bar-list-chart.tsx +3 -0
  16. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +3 -1
  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/heatmap-chart.module.scss +10 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.tsx +144 -37
  25. package/src/charts/heatmap-chart/private/column-groups.ts +79 -8
  26. package/src/charts/heatmap-chart/private/fit-compact-cells.ts +72 -0
  27. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +14 -2
  28. package/src/charts/heatmap-chart/private/index.ts +4 -2
  29. package/src/charts/heatmap-chart/test/column-groups.test.ts +41 -4
  30. package/src/charts/heatmap-chart/test/fit-compact-cells.test.ts +59 -0
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +132 -7
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +52 -4
  35. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +60 -9
  36. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +80 -1
  37. package/src/charts/leaderboard-chart/types.ts +7 -1
  38. package/src/charts/line-chart/line-chart.tsx +8 -2
  39. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +19 -9
  40. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +1 -1
  41. package/src/charts/line-chart/test/line-chart.test.tsx +37 -35
  42. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +2 -2
  43. package/src/charts/line-chart/types.ts +3 -3
  44. package/src/charts/pie-chart/pie-chart.module.scss +3 -0
  45. package/src/charts/pie-chart/pie-chart.tsx +144 -139
  46. package/src/charts/pie-chart/test/pie-chart.test.tsx +167 -0
  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 +117 -120
  49. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +54 -0
  50. package/src/charts/private/chart-composition/use-chart-children.ts +11 -6
  51. package/src/charts/private/pie-keyboard-navigation/index.ts +4 -0
  52. package/src/charts/private/pie-keyboard-navigation/order-arcs-for-navigation.ts +10 -0
  53. package/src/charts/private/pie-keyboard-navigation/pie-keyboard-navigation.module.scss +16 -0
  54. package/src/charts/private/pie-keyboard-navigation/pie-selection-announcement.tsx +14 -0
  55. package/src/charts/private/pie-keyboard-navigation/pie-selection-output.tsx +82 -0
  56. package/src/charts/private/pie-keyboard-navigation/pie-tooltip.tsx +49 -0
  57. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +47 -0
  58. package/src/charts/private/pie-keyboard-navigation/test/order-arcs-for-navigation.test.ts +34 -0
  59. package/src/charts/private/pie-keyboard-navigation/test/selected-segment-ring.test.ts +14 -0
  60. package/src/charts/private/pie-keyboard-navigation/use-pie-keyboard-navigation.ts +139 -0
  61. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +3 -2
  62. package/src/charts/test/accessible-name.test.tsx +181 -0
  63. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +63 -3
  64. package/src/components/legend/hooks/use-chart-legend-items.ts +63 -1
  65. package/src/components/legend/private/base-legend.tsx +27 -15
  66. package/src/components/legend/test/legend.test.tsx +42 -0
  67. package/src/components/legend/types.ts +2 -0
  68. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +20 -19
  69. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -3
  70. package/src/hooks/test/use-xychart-theme.test.tsx +22 -0
  71. package/src/hooks/use-xychart-theme.ts +33 -2
  72. package/src/index.ts +1 -0
  73. package/src/types.ts +37 -3
  74. package/src/utils/resolve-font-size.ts +9 -10
  75. package/src/utils/test/resolve-font-size.test.ts +11 -2
@@ -42,12 +42,12 @@ describe( 'BarChart', () => {
42
42
  // visx renders bars and grid lines without accessible roles or configurable test IDs.
43
43
  const getBarRects = () => {
44
44
  // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
45
- return screen.getByRole( 'grid' ).querySelectorAll( '.visx-bar-group rect' );
45
+ return screen.getByRole( 'application' ).querySelectorAll( '.visx-bar-group rect' );
46
46
  };
47
47
 
48
48
  const getGridLines = () => {
49
49
  // eslint-disable-next-line testing-library/no-node-access -- visx grid lines cannot receive test IDs.
50
- return screen.getByRole( 'grid' ).querySelectorAll( '.visx-rows line' );
50
+ return screen.getByRole( 'application' ).querySelectorAll( '.visx-rows line' );
51
51
  };
52
52
 
53
53
  const renderWithTheme = ( props = {}, children = undefined ) => {
@@ -151,20 +151,21 @@ describe( 'BarChart', () => {
151
151
 
152
152
  const pointer = ( type: string, clientX: number, clientY: number ) => {
153
153
  // visx owns the pointer capture rect and does not expose an attribute prop for it.
154
+ const chart = screen.getByRole( 'application' );
154
155
  // eslint-disable-next-line testing-library/no-node-access
155
- const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' );
156
+ const target = chart.querySelector( 'svg > rect[fill="transparent"]' );
156
157
  fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) );
157
158
  };
158
159
 
159
160
  const hover = ( clientX: number, clientY: number ) =>
160
161
  pointer( 'pointermove', clientX, clientY );
161
162
 
162
- test( 'Escape returns focus to the grid without reopening either tooltip', async () => {
163
+ test( 'Escape returns focus to the chart without reopening either tooltip', async () => {
163
164
  jest.useFakeTimers();
164
165
  try {
165
166
  const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
166
167
  renderWithTheme( { withTooltips: true } );
167
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
168
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
168
169
  await user.tab();
169
170
  await user.keyboard( '{ArrowRight}' );
170
171
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
@@ -369,7 +370,7 @@ describe( 'BarChart', () => {
369
370
  unmount();
370
371
  renderWithTheme( { withBandHighlight: true, onBandHighlightChange } );
371
372
  expect( warn ).toHaveBeenCalledTimes( 1 );
372
- expect( screen.getByRole( 'grid' ) ).toBeInTheDocument();
373
+ expect( screen.getByRole( 'application' ) ).toBeInTheDocument();
373
374
  expect( warn ).toHaveBeenCalledWith(
374
375
  '[Charts] BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
375
376
  );
@@ -473,7 +474,7 @@ describe( 'BarChart', () => {
473
474
 
474
475
  test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
475
476
  renderWithTheme( { data: wholeNumberData } );
476
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
477
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
477
478
  const ticks = within( chart )
478
479
  .getAllByText( /^-?[\d.,]+$/ )
479
480
  .map( el => el.textContent );
@@ -482,7 +483,7 @@ describe( 'BarChart', () => {
482
483
 
483
484
  test( 'labels a horizontal whole-number range once per whole number', () => {
484
485
  renderWithTheme( { data: wholeNumberData, orientation: 'horizontal' } );
485
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
486
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
486
487
  const ticks = within( chart )
487
488
  .getAllByText( /^-?[\d.,]+$/ )
488
489
  .map( el => el.textContent );
@@ -501,7 +502,7 @@ describe( 'BarChart', () => {
501
502
  },
502
503
  ],
503
504
  } );
504
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
505
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
505
506
  const ticks = within( chart )
506
507
  .getAllByText( /^-?[\d.,]+$/ )
507
508
  .map( el => el.textContent );
@@ -530,7 +531,7 @@ describe( 'BarChart', () => {
530
531
  },
531
532
  },
532
533
  } );
533
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
534
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
534
535
  expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
535
536
  }
536
537
  );
@@ -540,7 +541,7 @@ describe( 'BarChart', () => {
540
541
  data: wholeNumberData,
541
542
  options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
542
543
  } );
543
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
544
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
544
545
  expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
545
546
  } );
546
547
  } );
@@ -584,7 +585,7 @@ describe( 'BarChart', () => {
584
585
  },
585
586
  ],
586
587
  } );
587
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
588
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
588
589
  } );
589
590
 
590
591
  test( 'handles negative values', () => {
@@ -600,7 +601,7 @@ describe( 'BarChart', () => {
600
601
  },
601
602
  ],
602
603
  } );
603
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
604
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
604
605
  } );
605
606
 
606
607
  test( 'renders a bucket with no reading instead of failing the whole chart', () => {
@@ -617,7 +618,7 @@ describe( 'BarChart', () => {
617
618
  ],
618
619
  } );
619
620
  expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
620
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
621
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
621
622
  } );
622
623
 
623
624
  test( 'draws no bar for a bucket with no reading', () => {
@@ -736,7 +737,7 @@ describe( 'BarChart', () => {
736
737
  expect( screen.getAllByTestId( 'legend-item' ) ).toHaveLength( 2 );
737
738
  const html = document.body.innerHTML;
738
739
  expect( html.indexOf( 'data-testid="legend-horizontal"' ) ).toBeLessThan(
739
- html.indexOf( 'role="grid"' )
740
+ html.indexOf( 'role="application"' )
740
741
  );
741
742
  } );
742
743
  } );
@@ -744,7 +745,7 @@ describe( 'BarChart', () => {
744
745
  describe( 'Grid tick counts', () => {
745
746
  const getPositions = ( selector: string, coordinate: string ) => {
746
747
  // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
747
- const lines = screen.getByRole( 'grid' ).querySelectorAll( selector );
748
+ const lines = screen.getByRole( 'application' ).querySelectorAll( selector );
748
749
  return Array.from( lines, line => Number( line.getAttribute( coordinate ) ) );
749
750
  };
750
751
 
@@ -822,7 +823,7 @@ describe( 'BarChart', () => {
822
823
  },
823
824
  },
824
825
  } );
825
- const chart = screen.getByRole( 'grid' );
826
+ const chart = screen.getByRole( 'application' );
826
827
 
827
828
  // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
828
829
  expect( chart.querySelector( '.test-hidden-axis' ) ).toBeNull();
@@ -834,21 +835,21 @@ describe( 'BarChart', () => {
834
835
  describe( 'Grid Visibility', () => {
835
836
  test( 'renders with different grid visibility options', () => {
836
837
  const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
837
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
838
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
838
839
 
839
840
  rerender(
840
841
  <GlobalChartsProvider>
841
842
  <BarChart { ...defaultProps } gridVisibility="y" />
842
843
  </GlobalChartsProvider>
843
844
  );
844
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
845
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
845
846
 
846
847
  rerender(
847
848
  <GlobalChartsProvider>
848
849
  <BarChart { ...defaultProps } gridVisibility="xy" />
849
850
  </GlobalChartsProvider>
850
851
  );
851
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
852
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
852
853
  } );
853
854
  } );
854
855
 
@@ -930,7 +931,7 @@ describe( 'BarChart', () => {
930
931
 
931
932
  // Scoped to the chart: @visx/text parks a measuring node on document.body,
932
933
  // which a negative assertion would otherwise match.
933
- const inChart = () => within( screen.getByRole( 'grid', { name: /bar chart/i } ) );
934
+ const inChart = () => within( screen.getByRole( 'application', { name: /bar chart/i } ) );
934
935
 
935
936
  test( 'leaves tick values alone when a bucket is labelled rather than dated', () => {
936
937
  // visx builds the band domain from `label || date`, so a labelled bucket
@@ -1134,7 +1135,7 @@ describe( 'BarChart', () => {
1134
1135
  // on document.body, which outlives RTL's cleanup still holding the last
1135
1136
  // string measured. Query inside the chart so a previous test's measurement
1136
1137
  // can't answer for this one's axis.
1137
- const inChart = () => within( screen.getByRole( 'grid', { name: /bar chart/i } ) );
1138
+ const inChart = () => within( screen.getByRole( 'application', { name: /bar chart/i } ) );
1138
1139
 
1139
1140
  test( 'renders distinct date ticks for daily buckets within a year', () => {
1140
1141
  renderWithTheme( {
@@ -1570,7 +1571,7 @@ describe( 'BarChart', () => {
1570
1571
  ],
1571
1572
  } );
1572
1573
 
1573
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1574
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1574
1575
  await user.keyboard( '{ArrowRight}' );
1575
1576
 
1576
1577
  // The month tick reads "2024" here; the tooltip names the bucket itself.
@@ -1629,7 +1630,7 @@ describe( 'BarChart', () => {
1629
1630
 
1630
1631
  test( 'renders with patterns', () => {
1631
1632
  renderWithTheme( { withPatterns: true } );
1632
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
1633
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
1633
1634
 
1634
1635
  // Check that pattern definitions container is present
1635
1636
  expect( screen.getByTestId( 'bar-chart-patterns' ) ).toBeInTheDocument();
@@ -1637,7 +1638,7 @@ describe( 'BarChart', () => {
1637
1638
 
1638
1639
  test( 'renders without patterns by default', () => {
1639
1640
  renderWithTheme( { withPatterns: false } );
1640
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
1641
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
1641
1642
 
1642
1643
  // Check that no pattern definitions container is present
1643
1644
  expect( screen.queryByTestId( 'bar-chart-patterns' ) ).not.toBeInTheDocument();
@@ -1679,17 +1680,18 @@ describe( 'BarChart', () => {
1679
1680
  describe( 'Chart Focus and Accessibility Attributes', () => {
1680
1681
  test( 'chart container has proper accessibility attributes', () => {
1681
1682
  renderWithTheme();
1682
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1683
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1683
1684
 
1684
1685
  expect( chart ).toHaveAttribute( 'tabIndex', '0' );
1685
- expect( chart ).toHaveAttribute( 'role', 'grid' );
1686
+ expect( chart ).toHaveAttribute( 'role', 'application' );
1686
1687
  expect( chart ).toHaveAttribute( 'aria-label', 'Bar chart' );
1688
+ expect( screen.queryByLabelText( 'XYChart' ) ).not.toBeInTheDocument();
1687
1689
  } );
1688
1690
 
1689
1691
  test( 'chart container can receive focus', async () => {
1690
1692
  const user = userEvent.setup();
1691
1693
  renderWithTheme();
1692
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1694
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1693
1695
 
1694
1696
  await user.tab();
1695
1697
  expect( chart ).toHaveFocus();
@@ -1719,7 +1721,7 @@ describe( 'BarChart', () => {
1719
1721
  const onDatumActivate = jest.fn();
1720
1722
  renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1721
1723
 
1722
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1724
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1723
1725
  await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1724
1726
 
1725
1727
  expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
@@ -1735,7 +1737,7 @@ describe( 'BarChart', () => {
1735
1737
  const onDatumActivate = jest.fn();
1736
1738
  renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1737
1739
 
1738
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1740
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1739
1741
  await user.keyboard( '{ArrowRight}[Space]' );
1740
1742
 
1741
1743
  expect( onDatumActivate ).toHaveBeenCalledWith( {
@@ -1753,7 +1755,7 @@ describe( 'BarChart', () => {
1753
1755
  const onDatumActivate = jest.fn();
1754
1756
  renderWithTheme( { withTooltips: true, data: [ SERIES_A, SERIES_B ], onDatumActivate } );
1755
1757
 
1756
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1758
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1757
1759
  await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1758
1760
 
1759
1761
  expect( onDatumActivate ).toHaveBeenCalledWith( {
@@ -1768,7 +1770,7 @@ describe( 'BarChart', () => {
1768
1770
  const onDatumActivate = jest.fn();
1769
1771
  renderWithTheme( { withTooltips: true, data: [ SERIES_A ], onDatumActivate } );
1770
1772
 
1771
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1773
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1772
1774
  await user.keyboard( '{Enter}' );
1773
1775
 
1774
1776
  expect( onDatumActivate ).not.toHaveBeenCalled();
@@ -1802,7 +1804,7 @@ describe( 'BarChart', () => {
1802
1804
  ],
1803
1805
  } );
1804
1806
 
1805
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1807
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1806
1808
  chart.focus();
1807
1809
 
1808
1810
  // Single tab should focus on the first tooltip.
@@ -1853,7 +1855,7 @@ describe( 'BarChart', () => {
1853
1855
  ],
1854
1856
  } );
1855
1857
 
1856
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1858
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1857
1859
  chart.focus();
1858
1860
 
1859
1861
  await user.keyboard( '{ArrowRight}' );
@@ -1902,7 +1904,7 @@ describe( 'BarChart', () => {
1902
1904
  ],
1903
1905
  } );
1904
1906
 
1905
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1907
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1906
1908
  chart.focus();
1907
1909
 
1908
1910
  // Right arrow key should focus on the first tooltip.
@@ -1925,7 +1927,7 @@ describe( 'BarChart', () => {
1925
1927
  } );
1926
1928
  } );
1927
1929
 
1928
- describe( 'ARIA grid boundaries', () => {
1930
+ describe( 'Keyboard navigation boundaries', () => {
1929
1931
  // Three points, so a clamp at a boundary lands on a different index than a wrap would.
1930
1932
  const threePointData = [
1931
1933
  {
@@ -1943,7 +1945,7 @@ describe( 'BarChart', () => {
1943
1945
  const user = userEvent.setup();
1944
1946
  renderWithTheme( { withTooltips: true, data: threePointData } );
1945
1947
 
1946
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1948
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
1947
1949
  chart.focus();
1948
1950
 
1949
1951
  // Move to the first, then on to the last (third) point.
@@ -1953,7 +1955,7 @@ describe( 'BarChart', () => {
1953
1955
  expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
1954
1956
 
1955
1957
  // Right arrow at the last point must not move focus: the highlighted
1956
- // bar/tooltip stays visible and focused (ARIA grid: focus does not move).
1958
+ // bar/tooltip stays visible and focused (focus does not move).
1957
1959
  await user.keyboard( '{ArrowRight}' );
1958
1960
  expect( screen.getByTestId( 'chart-tooltip-2' ) ).toBeInTheDocument();
1959
1961
  expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
@@ -1968,7 +1970,7 @@ describe( 'BarChart', () => {
1968
1970
  const user = userEvent.setup();
1969
1971
  renderWithTheme( { withTooltips: true, data: threePointData } );
1970
1972
 
1971
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1973
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
1972
1974
  await user.keyboard( '{ArrowLeft}' );
1973
1975
 
1974
1976
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
@@ -2002,7 +2004,7 @@ describe( 'BarChart', () => {
2002
2004
  ],
2003
2005
  } );
2004
2006
 
2005
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2007
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2006
2008
  chart.focus();
2007
2009
 
2008
2010
  await user.keyboard( '{ArrowRight}' );
@@ -2037,7 +2039,7 @@ describe( 'BarChart', () => {
2037
2039
  ],
2038
2040
  } );
2039
2041
 
2040
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2042
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2041
2043
  chart.focus();
2042
2044
 
2043
2045
  // Slot 0 is the first primary bar (Mon); slot 1 must be the SECOND primary
@@ -2069,7 +2071,7 @@ describe( 'BarChart', () => {
2069
2071
  ],
2070
2072
  } );
2071
2073
 
2072
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2074
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2073
2075
  await user.keyboard( '{ArrowRight}' );
2074
2076
 
2075
2077
  const tooltip = screen.getByTestId( 'chart-tooltip-0' );
@@ -2102,7 +2104,7 @@ describe( 'BarChart', () => {
2102
2104
  ],
2103
2105
  } );
2104
2106
 
2105
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2107
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2106
2108
  await user.keyboard( '{ArrowRight}' );
2107
2109
 
2108
2110
  const tooltip = screen.getByTestId( 'chart-tooltip-0' );
@@ -2162,7 +2164,7 @@ describe( 'BarChart', () => {
2162
2164
  ],
2163
2165
  } );
2164
2166
 
2165
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2167
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2166
2168
  chart.focus();
2167
2169
 
2168
2170
  // Chart should be in focus.
@@ -2202,7 +2204,7 @@ describe( 'BarChart', () => {
2202
2204
  ],
2203
2205
  } );
2204
2206
 
2205
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2207
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2206
2208
  chart.focus();
2207
2209
 
2208
2210
  // Navigate to the first bar
@@ -2237,7 +2239,7 @@ describe( 'BarChart', () => {
2237
2239
  ],
2238
2240
  } );
2239
2241
 
2240
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2242
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2241
2243
  chart.focus();
2242
2244
 
2243
2245
  // Navigate to the first bar (Series A, Jan 1)
@@ -2269,7 +2271,7 @@ describe( 'BarChart', () => {
2269
2271
  const user = userEvent.setup();
2270
2272
  renderWithTheme( { withTooltips: true, data } );
2271
2273
 
2272
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2274
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2273
2275
  await user.keyboard( '{ArrowRight}' );
2274
2276
 
2275
2277
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
@@ -2280,7 +2282,7 @@ describe( 'BarChart', () => {
2280
2282
  const user = userEvent.setup();
2281
2283
  renderWithTheme( { withTooltips: true, data } );
2282
2284
 
2283
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2285
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2284
2286
  await user.keyboard( '{ArrowRight}{ArrowRight}' );
2285
2287
 
2286
2288
  expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
@@ -2313,7 +2315,7 @@ describe( 'BarChart', () => {
2313
2315
  ];
2314
2316
  renderWithTheme( { withTooltips: true, data: twoSeries } );
2315
2317
 
2316
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2318
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
2317
2319
  await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
2318
2320
 
2319
2321
  expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
@@ -2340,7 +2342,7 @@ describe( 'BarChart', () => {
2340
2342
 
2341
2343
  renderWithTheme( { withTooltips: true, renderTooltip: customTooltipRenderer } );
2342
2344
 
2343
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2345
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2344
2346
  chart.focus();
2345
2347
 
2346
2348
  // Click on right arrow key to focus on the first tooltip.
@@ -2372,7 +2374,9 @@ describe( 'BarChart', () => {
2372
2374
  ],
2373
2375
  } );
2374
2376
 
2375
- const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2377
+ const svgElement = screen
2378
+ .getByRole( 'application', { name: /bar chart/i } )
2379
+ .querySelector( 'svg' );
2376
2380
  const bars = svgElement?.querySelectorAll( '.visx-bar-group rect' );
2377
2381
 
2378
2382
  // Should have 2 bars
@@ -2399,7 +2403,9 @@ describe( 'BarChart', () => {
2399
2403
  ],
2400
2404
  } );
2401
2405
 
2402
- const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2406
+ const svgElement = screen
2407
+ .getByRole( 'application', { name: /bar chart/i } )
2408
+ .querySelector( 'svg' );
2403
2409
  const bars = svgElement?.querySelectorAll( '.visx-bar-group rect' );
2404
2410
 
2405
2411
  // Should have 2 bars
@@ -2432,7 +2438,9 @@ describe( 'BarChart', () => {
2432
2438
  orientation: 'horizontal',
2433
2439
  } );
2434
2440
 
2435
- const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2441
+ const svgElement = screen
2442
+ .getByRole( 'application', { name: /bar chart/i } )
2443
+ .querySelector( 'svg' );
2436
2444
  const bars = svgElement?.querySelectorAll( '.visx-bar-group rect' );
2437
2445
 
2438
2446
  // Both bars should be visible in horizontal mode
@@ -2461,7 +2469,9 @@ describe( 'BarChart', () => {
2461
2469
  ],
2462
2470
  } );
2463
2471
 
2464
- const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
2472
+ const svgElement = screen
2473
+ .getByRole( 'application', { name: /bar chart/i } )
2474
+ .querySelector( 'svg' );
2465
2475
  const bars = svgElement?.querySelectorAll( '.visx-bar-group rect' );
2466
2476
 
2467
2477
  expect( bars?.length ).toBe( 2 );
@@ -2501,7 +2511,7 @@ describe( 'BarChart', () => {
2501
2511
  },
2502
2512
  } );
2503
2513
 
2504
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
2514
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
2505
2515
  } );
2506
2516
 
2507
2517
  test( 'truncates labels with CSS text-overflow ellipsis', () => {
@@ -2594,7 +2604,7 @@ describe( 'BarChart', () => {
2594
2604
  } );
2595
2605
 
2596
2606
  // Chart should still render without errors
2597
- expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
2607
+ expect( screen.getByRole( 'application', { name: /bar chart/i } ) ).toBeInTheDocument();
2598
2608
 
2599
2609
  // Labels should still be present and have minimum width applied
2600
2610
  const label = screen.getByText( /Very Long Category Label One/i );
@@ -2890,7 +2900,7 @@ describe( 'BarChart', () => {
2890
2900
  </GlobalChartsProvider>
2891
2901
  );
2892
2902
 
2893
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2903
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
2894
2904
  chart.focus();
2895
2905
 
2896
2906
  // Navigate through all 4 primary slots (2 series × 2 data points)
@@ -3176,8 +3186,9 @@ describe( 'BarChart', () => {
3176
3186
  </GlobalChartsProvider>
3177
3187
  );
3178
3188
 
3189
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
3179
3190
  // eslint-disable-next-line testing-library/no-node-access
3180
- const svgElement = screen.getByRole( 'grid', { name: /bar chart/i } ).querySelector( 'svg' );
3191
+ const svgElement = chart.querySelector( 'svg' );
3181
3192
  // eslint-disable-next-line testing-library/no-node-access
3182
3193
  expect( svgElement?.querySelectorAll( '.visx-bar-group rect' ) ).toHaveLength( 2 );
3183
3194
 
@@ -3382,7 +3393,7 @@ describe( 'BarChart', () => {
3382
3393
  const user = userEvent.setup();
3383
3394
  const toggle = mountWithVisibilityToggle();
3384
3395
 
3385
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
3396
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
3386
3397
  for ( let i = 0; i < 6; i++ ) {
3387
3398
  await user.keyboard( '{ArrowRight}' );
3388
3399
  }
@@ -3427,7 +3438,7 @@ describe( 'BarChart', () => {
3427
3438
  ],
3428
3439
  } );
3429
3440
 
3430
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
3441
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
3431
3442
  for ( let i = 0; i < 6; i++ ) {
3432
3443
  await user.keyboard( '{ArrowRight}' );
3433
3444
  }
@@ -3484,12 +3495,12 @@ describe( 'BarChart', () => {
3484
3495
  </GlobalChartsProvider>
3485
3496
  );
3486
3497
 
3487
- screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
3498
+ screen.getByRole( 'application', { name: /bar chart/i } ).focus();
3488
3499
  await user.keyboard( '{ArrowRight}{ArrowRight}' );
3489
3500
  expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series A' );
3490
3501
 
3491
3502
  // Move focus out of the chart, then swap which series is hidden without changing the count.
3492
- screen.getByRole( 'grid', { name: /bar chart/i } ).blur();
3503
+ screen.getByRole( 'application', { name: /bar chart/i } ).blur();
3493
3504
  act( () => context.setChartHiddenSeries( 'test-equal-count-swap', [ 'Series A' ] ) );
3494
3505
 
3495
3506
  expect( screen.queryAllByTestId( /^chart-tooltip-/ ) ).toHaveLength( 0 );
@@ -3528,7 +3539,7 @@ describe( 'BarChart', () => {
3528
3539
 
3529
3540
  await user.click( screen.getByRole( 'button', { name: /Series B: visible/i } ) );
3530
3541
 
3531
- const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
3542
+ const chart = screen.getByRole( 'application', { name: /bar chart/i } );
3532
3543
  chart.focus();
3533
3544
 
3534
3545
  for ( let i = 0; i < 5; i++ ) {
@@ -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
  );
@@ -72,7 +72,9 @@ describe( 'BarListChart', () => {
72
72
  /* eslint-disable testing-library/no-node-access */
73
73
  const barFills = () =>
74
74
  Array.from(
75
- screen.getByRole( 'grid' ).querySelectorAll< SVGRectElement >( '.visx-bar-group rect' )
75
+ screen
76
+ .getByRole( 'application' )
77
+ .querySelectorAll< SVGRectElement >( '.visx-bar-group rect' )
76
78
  ).map( bar => bar.getAttribute( 'fill' ) );
77
79
  /* eslint-enable testing-library/no-node-access */
78
80
 
@@ -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">