@automattic/charts 4.1.1 → 4.2.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 (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +875 -237
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +106 -6
  7. package/dist/index.d.ts +106 -6
  8. package/dist/index.js +875 -239
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/private/overlays.tsx +1 -2
  12. package/src/charts/bar-chart/bar-chart.tsx +67 -17
  13. package/src/charts/bar-chart/index.ts +1 -1
  14. package/src/charts/bar-chart/private/band-highlight.tsx +85 -0
  15. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  16. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  17. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  18. package/src/charts/bar-chart/private/index.ts +2 -0
  19. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +109 -0
  20. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  21. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  22. package/src/charts/bar-chart/private/use-bar-chart-options.ts +1 -1
  23. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  24. package/src/charts/bar-chart/test/bar-chart.test.tsx +565 -1
  25. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  26. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  27. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  28. package/src/charts/geo-chart/types.ts +4 -2
  29. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  30. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  31. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  32. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  33. package/src/charts/heatmap-chart/index.ts +6 -0
  34. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  35. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  36. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  37. package/src/charts/heatmap-chart/private/index.ts +2 -0
  38. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  39. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  40. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  41. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  42. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  43. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  44. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  45. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  46. package/src/charts/heatmap-chart/types.ts +59 -3
  47. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  48. package/src/charts/leaderboard-chart/types.ts +13 -14
  49. package/src/charts/line-chart/line-chart.tsx +1 -1
  50. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  51. package/src/charts/line-chart/types.ts +2 -2
  52. package/src/charts/private/time-axis-options.ts +1 -1
  53. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  54. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  55. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  56. package/src/components/legend/private/base-legend.tsx +1 -1
  57. package/src/components/tooltip/accessible-tooltip.tsx +127 -40
  58. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  59. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  60. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  61. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  62. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  63. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  64. package/src/hooks/test/use-element-size.test.tsx +13 -13
  65. package/src/hooks/use-xychart-theme.ts +1 -1
  66. package/src/index.ts +7 -1
  67. package/src/providers/chart-context/themes.ts +1 -0
  68. package/src/styles/test/chart-scope.test.ts +1 -1
  69. package/src/types.ts +6 -13
  70. package/src/utils/format-metric-value.ts +4 -4
  71. package/src/utils/get-edge-tick-widths.ts +1 -1
  72. package/src/utils/get-styles.ts +1 -1
  73. package/src/visx/types.ts +7 -10
@@ -330,7 +330,7 @@ export function useBarChartOptions(
330
330
  timeAxis.domain,
331
331
  timeAxis.tickFormatter,
332
332
  dateAxisOptions.numTicks ?? DEFAULT_NUM_TICKS
333
- )
333
+ )
334
334
  : null;
335
335
  const dateAxisTickValues = bandTickValues ? { tickValues: bandTickValues } : {};
336
336
 
@@ -1,25 +1,13 @@
1
1
  import { render } from '@testing-library/react';
2
2
  import { GlobalChartsProvider } from '../../../providers';
3
3
  import BarChart from '../bar-chart';
4
- import type { ReactNode } from 'react';
5
-
6
- // jsdom gives every element a zero-sized box, so visx can never resolve a
7
- // pointer to a datum here. Standing `XYChart` in for a prop recorder keeps this
8
- // test on the part jsdom can answer: that the handlers reach the chart at all.
9
- // Whether a bar then reports the datum under the pointer is a browser question.
10
- const mockXYChart = jest.fn();
11
- jest.mock( '@visx/xychart', () => {
12
- const actual = jest.requireActual( '@visx/xychart' );
13
-
14
- return {
15
- __esModule: true,
16
- ...actual,
17
- XYChart: ( props: { children?: ReactNode } ) => {
18
- mockXYChart( props );
19
- return <svg />;
20
- },
21
- };
22
- } );
4
+ const mockBandTooltip = jest.fn();
5
+ jest.mock( '../private/band-tooltip', () => ( {
6
+ BandTooltip: props => {
7
+ mockBandTooltip( props );
8
+ return null;
9
+ },
10
+ } ) );
23
11
 
24
12
  const mockRefCallback = jest.fn();
25
13
  jest.mock( '../../../hooks/use-element-size', () => ( {
@@ -38,7 +26,7 @@ const DATA = [
38
26
  ];
39
27
 
40
28
  /**
41
- * Render the chart with the recorder standing in for `XYChart`.
29
+ * Render the chart with the recorder standing in for band pointer handling.
42
30
  *
43
31
  * @param props - Props layered onto the defaults.
44
32
  * @return The render result.
@@ -52,15 +40,15 @@ function renderBarChart( props = {} ) {
52
40
  }
53
41
 
54
42
  describe( 'BarChart pointer events', () => {
55
- beforeEach( () => mockXYChart.mockClear() );
43
+ beforeEach( () => mockBandTooltip.mockClear() );
56
44
 
57
- test( 'hands the pointer handlers to the chart, so a bar can be clicked', () => {
45
+ test( 'routes pointer handlers through band correction without requiring tooltips', () => {
58
46
  const onPointerDown = jest.fn();
59
47
  const onPointerUp = jest.fn();
60
48
 
61
49
  renderBarChart( { onPointerDown, onPointerUp } );
62
50
 
63
- const props = mockXYChart.mock.calls.at( -1 )[ 0 ];
51
+ const props = mockBandTooltip.mock.calls.at( -1 )[ 0 ];
64
52
  expect( props.onPointerDown ).toBe( onPointerDown );
65
53
  expect( props.onPointerUp ).toBe( onPointerUp );
66
54
  } );
@@ -1,4 +1,12 @@
1
- import { render, renderHook, screen, within, act } from '@testing-library/react';
1
+ import {
2
+ render,
3
+ renderHook,
4
+ screen,
5
+ within,
6
+ act,
7
+ waitFor,
8
+ fireEvent,
9
+ } from '@testing-library/react';
2
10
  import userEvent from '@testing-library/user-event';
3
11
  import { GlobalChartsProvider } from '../../../providers';
4
12
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
@@ -30,6 +38,12 @@ describe( 'BarChart', () => {
30
38
  ],
31
39
  };
32
40
 
41
+ // visx renders bars and grid lines without accessible roles or configurable test IDs.
42
+ const getBarRects = () => {
43
+ // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
44
+ return screen.getByRole( 'grid' ).querySelectorAll( '.visx-bar-group rect' );
45
+ };
46
+
33
47
  const renderWithTheme = ( props = {}, children = undefined ) => {
34
48
  return render(
35
49
  <GlobalChartsProvider>
@@ -40,6 +54,247 @@ describe( 'BarChart', () => {
40
54
  );
41
55
  };
42
56
 
57
+ describe( 'pointer selection and focus return', () => {
58
+ const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
59
+ beforeAll( () => {
60
+ // jsdom lacks the SVG transform used by visx to convert pointer coordinates.
61
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
62
+ configurable: true,
63
+ value: () => null,
64
+ } );
65
+ } );
66
+
67
+ afterAll( () => {
68
+ if ( screenTransform ) {
69
+ Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
70
+ } else {
71
+ Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
72
+ }
73
+ } );
74
+
75
+ const barGeometry = () => {
76
+ const bars = getBarRects();
77
+ return Array.from( bars, bar =>
78
+ [ 'x', 'y', 'width', 'height' ].map( name => Number( bar.getAttribute( name ) ) )
79
+ );
80
+ };
81
+
82
+ const pointer = ( type: string, clientX: number, clientY: number ) => {
83
+ // visx owns the pointer capture rect and does not expose an attribute prop for it.
84
+ // eslint-disable-next-line testing-library/no-node-access
85
+ const target = screen.getByRole( 'grid' ).querySelector( 'svg > rect[fill="transparent"]' );
86
+ fireEvent( target, new MouseEvent( type, { bubbles: true, clientX, clientY } ) );
87
+ };
88
+
89
+ const hover = ( clientX: number, clientY: number ) =>
90
+ pointer( 'pointermove', clientX, clientY );
91
+
92
+ test( 'Escape returns focus to the grid without reopening either tooltip', async () => {
93
+ jest.useFakeTimers();
94
+ try {
95
+ const user = userEvent.setup( { advanceTimers: jest.advanceTimersByTime } );
96
+ renderWithTheme( { withTooltips: true } );
97
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
98
+ await user.tab();
99
+ await user.keyboard( '{ArrowRight}' );
100
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
101
+ await user.keyboard( '{Escape}' );
102
+ await act( async () => {
103
+ jest.advanceTimersByTime( 5000 );
104
+ } );
105
+ expect( chart ).toHaveFocus();
106
+ expect( screen.queryByTestId( /^chart-tooltip-\d+$/ ) ).not.toBeInTheDocument();
107
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
108
+ } finally {
109
+ jest.useRealTimers();
110
+ }
111
+ } );
112
+
113
+ // The pointer never moves, so visx never fires the leave that would normally close this.
114
+ test( 'closes a hover tooltip when the series it describes is hidden', async () => {
115
+ let context: GlobalChartsContextValue;
116
+ const Grab = () => {
117
+ context = useGlobalChartsContext();
118
+ return null;
119
+ };
120
+
121
+ render(
122
+ <GlobalChartsProvider>
123
+ <Grab />
124
+ <BarChart { ...defaultProps } withTooltips chartId="test-hover-then-hide" />
125
+ </GlobalChartsProvider>
126
+ );
127
+
128
+ const [ [ x0, , width ] ] = barGeometry();
129
+ hover( x0 + width / 2, 150 );
130
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
131
+
132
+ act( () => context.toggleSeriesVisibility( 'test-hover-then-hide', 'Series A' ) );
133
+
134
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
135
+ } );
136
+
137
+ test( 'selects the nearer padded band before and after an unrelated rerender', async () => {
138
+ const chart = ( className: string ) => (
139
+ <GlobalChartsProvider>
140
+ <BarChart { ...defaultProps } withTooltips className={ className } />
141
+ </GlobalChartsProvider>
142
+ );
143
+ const { rerender } = render( chart( 'before' ) );
144
+ const [ [ x0, , width ], [ x1 ] ] = barGeometry();
145
+ const clientX = ( x0 + width / 2 + x1 + width / 2 ) / 2 - 2;
146
+ hover( clientX, 150 );
147
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
148
+ rerender( chart( 'after' ) );
149
+ hover( x1 + width / 2, 150 );
150
+ await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 2' ) );
151
+ hover( clientX, 150 );
152
+ await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 1' ) );
153
+ } );
154
+
155
+ test( 'reports the tooltip datum when pressing and releasing in a padded gap', async () => {
156
+ const onPointerDown = jest.fn();
157
+ const onPointerUp = jest.fn();
158
+ renderWithTheme( { withTooltips: true, onPointerDown, onPointerUp } );
159
+ const [ [ x0, , width ], [ x1 ] ] = barGeometry();
160
+ const clientX = ( x0 + width / 2 + x1 + width / 2 ) / 2 - 2;
161
+ hover( clientX, 150 );
162
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
163
+ pointer( 'pointerdown', clientX, 150 );
164
+ pointer( 'pointerup', clientX, 150 );
165
+ for ( const callback of [ onPointerDown, onPointerUp ] ) {
166
+ expect( callback ).toHaveBeenCalledTimes( 1 );
167
+ expect( callback ).toHaveBeenLastCalledWith(
168
+ expect.objectContaining( {
169
+ key: 'Series A',
170
+ index: 0,
171
+ datum: defaultProps.data[ 0 ].data[ 0 ],
172
+ } )
173
+ );
174
+ }
175
+ } );
176
+
177
+ test( 'selects the painted grouped bar at the outer edge of a padded band', async () => {
178
+ renderWithTheme( {
179
+ withTooltips: true,
180
+ data: [ 'A', 'B', 'C' ].map( label => ( {
181
+ label,
182
+ data: [
183
+ { label: 'Jan 1', value: 50 },
184
+ { label: 'Jan 2', value: 50 },
185
+ { label: 'Jan 3', value: 50 },
186
+ ],
187
+ } ) ),
188
+ } );
189
+ const [ x, y, width, height ] = barGeometry()[ 6 ];
190
+ hover( x + width - 1, y + height / 2 );
191
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Jan 1' );
192
+ } );
193
+
194
+ test.each( [ 'vertical', 'horizontal' ] )(
195
+ 'selects the long grouped bar near its base in a %s chart',
196
+ async orientation => {
197
+ renderWithTheme( {
198
+ withTooltips: true,
199
+ orientation,
200
+ data: [
201
+ {
202
+ label: 'Series A',
203
+ data: [
204
+ { label: 'Jan 1', value: 100 },
205
+ { label: 'Jan 2', value: 100 },
206
+ ],
207
+ },
208
+ {
209
+ label: 'Series B',
210
+ data: [
211
+ { label: 'Jan 1', value: 10 },
212
+ { label: 'Jan 2', value: 10 },
213
+ ],
214
+ },
215
+ ],
216
+ } );
217
+ const [ [ x, y, width, height ] ] = barGeometry();
218
+ hover(
219
+ orientation === 'horizontal' ? x + 2 : x + width / 2,
220
+ orientation === 'horizontal' ? y + height / 2 : y + height - 2
221
+ );
222
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toHaveTextContent( 'Series A' );
223
+ }
224
+ );
225
+ } );
226
+
227
+ test( 'reports band bounds without drawing an overlay and clears them on Escape', async () => {
228
+ const user = userEvent.setup();
229
+ const onBandHighlightChange = jest.fn();
230
+ renderWithTheme( { withTooltips: true, onBandHighlightChange } );
231
+ await user.tab();
232
+ await user.keyboard( '{ArrowRight}' );
233
+ expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
234
+ expect.objectContaining( {
235
+ datum: expect.objectContaining( { value: 10 } ),
236
+ x: expect.any( Number ),
237
+ y: expect.any( Number ),
238
+ width: expect.any( Number ),
239
+ height: expect.any( Number ),
240
+ } )
241
+ );
242
+ expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
243
+ await user.keyboard( '{Escape}' );
244
+ await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
245
+ } );
246
+
247
+ test.each( [ 'vertical', 'horizontal' ] )(
248
+ 'draws the active band across a %s plot',
249
+ async orientation => {
250
+ const user = userEvent.setup();
251
+ const onBandHighlightChange = jest.fn();
252
+ renderWithTheme( {
253
+ withTooltips: true,
254
+ withBandHighlight: true,
255
+ orientation,
256
+ onBandHighlightChange,
257
+ } );
258
+ await user.tab();
259
+ await user.keyboard( '{ArrowRight}' );
260
+ const highlight = screen.getByTestId( 'bar-chart-band-highlight' );
261
+ expect( highlight.matches( 'svg > :first-child' ) ).toBe( true );
262
+ const bounds = onBandHighlightChange.mock.calls.at( -1 )[ 0 ];
263
+ expect( Number( highlight.getAttribute( 'width' ) ) ).toBe( bounds.width );
264
+ expect( Number( highlight.getAttribute( 'height' ) ) ).toBe( bounds.height );
265
+ expect( bounds.width ).toBeGreaterThan( 0 );
266
+ expect( bounds.height ).toBeGreaterThan( 0 );
267
+ const bar = getBarRects()[ 0 ];
268
+ const bandAxis = orientation === 'vertical' ? 'x' : 'y';
269
+ const bandSize = orientation === 'vertical' ? 'width' : 'height';
270
+ const plotSize = orientation === 'vertical' ? 'height' : 'width';
271
+ const barStart = Number( bar.getAttribute( bandAxis ) );
272
+ const barSize = Number( bar.getAttribute( bandSize ) );
273
+ expect( Number( highlight.getAttribute( bandAxis ) ) ).toBeLessThanOrEqual( barStart );
274
+ expect(
275
+ Number( highlight.getAttribute( bandAxis ) ) + Number( highlight.getAttribute( bandSize ) )
276
+ ).toBeGreaterThanOrEqual( barStart + barSize );
277
+ expect( Number( highlight.getAttribute( bandSize ) ) ).toBeLessThan( barSize * 2 );
278
+ expect( Number( highlight.getAttribute( plotSize ) ) ).toBeGreaterThan(
279
+ Number( bar.getAttribute( plotSize ) )
280
+ );
281
+ }
282
+ );
283
+
284
+ test( 'aligns horizontal grid lines with explicit value ticks', () => {
285
+ const { container } = renderWithTheme( {
286
+ gridVisibility: 'x',
287
+ options: {
288
+ axis: { y: { tickValues: [ 0, 50, 100 ] } },
289
+ scale: { y: { domain: [ 0, 100 ] } },
290
+ },
291
+ } );
292
+ // See the visx node constraint at getBarRects.
293
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
294
+ const lines = container.querySelectorAll( '.visx-rows line' );
295
+ expect( lines ).toHaveLength( 3 );
296
+ } );
297
+
43
298
  describe( 'Data Validation', () => {
44
299
  test( 'handles empty data array', () => {
45
300
  renderWithTheme( { data: [] } );
@@ -927,6 +1182,54 @@ describe( 'BarChart', () => {
927
1182
  } );
928
1183
 
929
1184
  describe( 'Pattern', () => {
1185
+ test( 'uses point colors with a series fallback and preserves pattern fills', () => {
1186
+ const data: SeriesData[] = [
1187
+ {
1188
+ label: 'Scores',
1189
+ data: [
1190
+ {
1191
+ label: 'First day',
1192
+ value: 80,
1193
+ color: 'var(--wpds-color-foreground-content-success-weak)',
1194
+ },
1195
+ {
1196
+ label: 'Second day',
1197
+ value: 40,
1198
+ color: 'var(--wpds-color-foreground-content-error-weak)',
1199
+ },
1200
+ { label: 'Third day', value: 60 },
1201
+ ],
1202
+ },
1203
+ ];
1204
+ const { rerender } = render( <BarChart { ...defaultProps } data={ data } /> );
1205
+ const getBars = getBarRects;
1206
+ const bars = getBars();
1207
+ expect( bars ).toHaveLength( 3 );
1208
+ expect( bars[ 0 ] ).toHaveAttribute( 'fill', data[ 0 ].data[ 0 ].color );
1209
+ expect( bars[ 1 ] ).toHaveAttribute( 'fill', data[ 0 ].data[ 1 ].color );
1210
+ const fallbackFill = bars[ 2 ].getAttribute( 'fill' );
1211
+ expect( fallbackFill ).toBeTruthy();
1212
+ rerender(
1213
+ <BarChart
1214
+ { ...defaultProps }
1215
+ data={ [
1216
+ {
1217
+ ...data[ 0 ],
1218
+ data: data[ 0 ].data.map( point => ( { ...point, color: undefined } ) ),
1219
+ },
1220
+ ] }
1221
+ />
1222
+ );
1223
+ expect( getBars()[ 0 ] ).toHaveAttribute( 'fill', fallbackFill );
1224
+ rerender( <BarChart { ...defaultProps } data={ data } withPatterns /> );
1225
+ for ( const bar of getBars() ) {
1226
+ expect( bar ).toHaveAttribute(
1227
+ 'fill',
1228
+ expect.stringMatching( /^url\(#bar-pattern-.+-0\)$/ )
1229
+ );
1230
+ }
1231
+ } );
1232
+
930
1233
  test( 'renders with patterns', () => {
931
1234
  renderWithTheme( { withPatterns: true } );
932
1235
  expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
@@ -1225,6 +1528,57 @@ describe( 'BarChart', () => {
1225
1528
  } );
1226
1529
  } );
1227
1530
 
1531
+ describe( 'ARIA grid boundaries', () => {
1532
+ // Three points, so a clamp at a boundary lands on a different index than a wrap would.
1533
+ const threePointData = [
1534
+ {
1535
+ label: 'Series A',
1536
+ data: [
1537
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1538
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1539
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
1540
+ ],
1541
+ options: {},
1542
+ },
1543
+ ];
1544
+
1545
+ test( 'right arrow at the last point stays put and keeps the highlight (no escape, no wrap)', async () => {
1546
+ const user = userEvent.setup();
1547
+ renderWithTheme( { withTooltips: true, data: threePointData } );
1548
+
1549
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
1550
+ chart.focus();
1551
+
1552
+ // Move to the first, then on to the last (third) point.
1553
+ await user.keyboard( '{ArrowRight}' );
1554
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1555
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
1556
+ expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
1557
+
1558
+ // Right arrow at the last point must not move focus: the highlighted
1559
+ // bar/tooltip stays visible and focused (ARIA grid: focus does not move).
1560
+ await user.keyboard( '{ArrowRight}' );
1561
+ expect( screen.getByTestId( 'chart-tooltip-2' ) ).toBeInTheDocument();
1562
+ expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveFocus();
1563
+ expect( screen.queryByTestId( 'chart-tooltip-0' ) ).not.toBeInTheDocument();
1564
+ // Focus did not escape back to the chart container.
1565
+ expect( chart ).not.toHaveFocus();
1566
+ } );
1567
+
1568
+ // Left arrow as the first key is the hard case: it clamps from the unselected -1, and a wrap
1569
+ // would land on the last point instead.
1570
+ test( 'left arrow as the first key selects the first point', async () => {
1571
+ const user = userEvent.setup();
1572
+ renderWithTheme( { withTooltips: true, data: threePointData } );
1573
+
1574
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1575
+ await user.keyboard( '{ArrowLeft}' );
1576
+
1577
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveFocus();
1578
+ expect( screen.queryByTestId( 'chart-tooltip-2' ) ).not.toBeInTheDocument();
1579
+ } );
1580
+ } );
1581
+
1228
1582
  describe( 'Comparison tooltip', () => {
1229
1583
  test( 'tooltip shows both the primary and comparison values', async () => {
1230
1584
  const user = userEvent.setup();
@@ -2350,4 +2704,214 @@ describe( 'BarChart', () => {
2350
2704
  expect( screen.getAllByRole( 'button' )[ 1 ] ).toHaveAttribute( 'aria-pressed', 'true' );
2351
2705
  } );
2352
2706
  } );
2707
+
2708
+ describe( 'Keyboard navigation with a hidden primary series', () => {
2709
+ const mountWithVisibilityToggle = () => {
2710
+ let context: GlobalChartsContextValue;
2711
+ const Grab = () => {
2712
+ context = useGlobalChartsContext();
2713
+ return null;
2714
+ };
2715
+
2716
+ render(
2717
+ <GlobalChartsProvider>
2718
+ <Grab />
2719
+ <BarChartUnresponsive
2720
+ width={ 500 }
2721
+ height={ 300 }
2722
+ withTooltips
2723
+ chartId="test-hide-while-navigating"
2724
+ data={ [
2725
+ {
2726
+ label: 'Series A',
2727
+ data: [
2728
+ { label: 'Jan', value: 10 },
2729
+ { label: 'Feb', value: 20 },
2730
+ { label: 'Mar', value: 30 },
2731
+ ],
2732
+ options: {},
2733
+ },
2734
+ {
2735
+ label: 'Series B',
2736
+ data: [
2737
+ { label: 'Jan', value: 15 },
2738
+ { label: 'Feb', value: 25 },
2739
+ { label: 'Mar', value: 35 },
2740
+ ],
2741
+ options: {},
2742
+ },
2743
+ ] }
2744
+ />
2745
+ </GlobalChartsProvider>
2746
+ );
2747
+
2748
+ return ( label: string ) =>
2749
+ act( () => context.toggleSeriesVisibility( 'test-hide-while-navigating', label ) );
2750
+ };
2751
+
2752
+ it( 'moves the selection onto a visible bar when the selected series is hidden', async () => {
2753
+ const user = userEvent.setup();
2754
+ const toggle = mountWithVisibilityToggle();
2755
+
2756
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2757
+ for ( let i = 0; i < 6; i++ ) {
2758
+ await user.keyboard( '{ArrowRight}' );
2759
+ }
2760
+ expect( screen.getByTestId( 'chart-tooltip-5' ) ).toHaveTextContent( 'Series B' );
2761
+
2762
+ toggle( 'Series B' );
2763
+
2764
+ expect( screen.getByTestId( 'chart-tooltip-2' ) ).toHaveTextContent( 'Series A' );
2765
+ expect( screen.queryByTestId( 'chart-tooltip-5' ) ).not.toBeInTheDocument();
2766
+
2767
+ await user.keyboard( '{ArrowLeft}' );
2768
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series A' );
2769
+ } );
2770
+
2771
+ it( 'leaves focus on the legend item that hid the selected series', async () => {
2772
+ const user = userEvent.setup();
2773
+
2774
+ renderWithTheme( {
2775
+ withTooltips: true,
2776
+ showLegend: true,
2777
+ legend: { interactive: true },
2778
+ chartId: 'test-hide-from-legend-while-navigating',
2779
+ data: [
2780
+ {
2781
+ label: 'Series A',
2782
+ data: [
2783
+ { label: 'Jan', value: 10 },
2784
+ { label: 'Feb', value: 20 },
2785
+ { label: 'Mar', value: 30 },
2786
+ ],
2787
+ options: {},
2788
+ },
2789
+ {
2790
+ label: 'Series B',
2791
+ data: [
2792
+ { label: 'Jan', value: 15 },
2793
+ { label: 'Feb', value: 25 },
2794
+ { label: 'Mar', value: 35 },
2795
+ ],
2796
+ options: {},
2797
+ },
2798
+ ],
2799
+ } );
2800
+
2801
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2802
+ for ( let i = 0; i < 6; i++ ) {
2803
+ await user.keyboard( '{ArrowRight}' );
2804
+ }
2805
+
2806
+ const seriesBToggle = screen.getByRole( 'button', { name: /Series B: visible/i } );
2807
+ await user.click( seriesBToggle );
2808
+
2809
+ expect( seriesBToggle ).toHaveFocus();
2810
+ expect( screen.queryAllByTestId( /^chart-tooltip-/ ) ).toHaveLength( 0 );
2811
+ // Clearing the selection drops the keyboard test id, so assert on the role too:
2812
+ // visx's own hide is debounced, and the stale datum repaints as a different series.
2813
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
2814
+ } );
2815
+
2816
+ // The point count is unchanged by a one-for-one swap, so only the visible set reveals it.
2817
+ it( 'clears the selection when a swap keeps the count but changes which series are visible', async () => {
2818
+ const user = userEvent.setup();
2819
+ let context: GlobalChartsContextValue;
2820
+ const Grab = () => {
2821
+ context = useGlobalChartsContext();
2822
+ return null;
2823
+ };
2824
+
2825
+ render(
2826
+ <GlobalChartsProvider>
2827
+ <Grab />
2828
+ <BarChartUnresponsive
2829
+ width={ 500 }
2830
+ height={ 300 }
2831
+ withTooltips
2832
+ chartId="test-equal-count-swap"
2833
+ defaultHiddenSeries={ [ 'Series B' ] }
2834
+ data={ [
2835
+ {
2836
+ label: 'Series A',
2837
+ data: [
2838
+ { label: 'Jan', value: 10 },
2839
+ { label: 'Feb', value: 20 },
2840
+ { label: 'Mar', value: 30 },
2841
+ ],
2842
+ options: {},
2843
+ },
2844
+ {
2845
+ label: 'Series B',
2846
+ data: [
2847
+ { label: 'Jan', value: 15 },
2848
+ { label: 'Feb', value: 25 },
2849
+ { label: 'Mar', value: 35 },
2850
+ ],
2851
+ options: {},
2852
+ },
2853
+ ] }
2854
+ />
2855
+ </GlobalChartsProvider>
2856
+ );
2857
+
2858
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2859
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
2860
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( 'Series A' );
2861
+
2862
+ // Move focus out of the chart, then swap which series is hidden without changing the count.
2863
+ screen.getByRole( 'grid', { name: /bar chart/i } ).blur();
2864
+ act( () => context.setChartHiddenSeries( 'test-equal-count-swap', [ 'Series A' ] ) );
2865
+
2866
+ expect( screen.queryAllByTestId( /^chart-tooltip-/ ) ).toHaveLength( 0 );
2867
+ expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
2868
+ } );
2869
+
2870
+ it( 'stops at the last visible slot on a standard chart', async () => {
2871
+ const user = userEvent.setup();
2872
+
2873
+ renderWithTheme( {
2874
+ withTooltips: true,
2875
+ showLegend: true,
2876
+ legend: { interactive: true },
2877
+ chartId: 'test-hide-primary-standard',
2878
+ data: [
2879
+ {
2880
+ label: 'Series A',
2881
+ data: [
2882
+ { label: 'Jan', value: 10 },
2883
+ { label: 'Feb', value: 20 },
2884
+ { label: 'Mar', value: 30 },
2885
+ ],
2886
+ options: {},
2887
+ },
2888
+ {
2889
+ label: 'Series B',
2890
+ data: [
2891
+ { label: 'Jan', value: 15 },
2892
+ { label: 'Feb', value: 25 },
2893
+ { label: 'Mar', value: 35 },
2894
+ ],
2895
+ options: {},
2896
+ },
2897
+ ],
2898
+ } );
2899
+
2900
+ await user.click( screen.getByRole( 'button', { name: /Series B: visible/i } ) );
2901
+
2902
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
2903
+ chart.focus();
2904
+
2905
+ for ( let i = 0; i < 5; i++ ) {
2906
+ await user.keyboard( '{ArrowRight}' );
2907
+ }
2908
+
2909
+ const tooltips = screen.queryAllByTestId( /^chart-tooltip-/ );
2910
+ expect( tooltips ).toHaveLength( 1 );
2911
+ expect( screen.getByTestId( 'chart-tooltip-2' ) ).toBeInTheDocument();
2912
+ expect( tooltips[ 0 ] ).toHaveTextContent( 'Series A' );
2913
+ expect( tooltips[ 0 ] ).not.toHaveTextContent( 'Series B' );
2914
+ expect( screen.queryByTestId( 'chart-tooltip-3' ) ).not.toBeInTheDocument();
2915
+ } );
2916
+ } );
2353
2917
  } );
@@ -15,8 +15,10 @@ import type { AxisRendererProps, AxisScale } from '@visx/axis';
15
15
  import type { AnyD3Scale } from '@visx/scale';
16
16
  import type { ComponentType, FC } from 'react';
17
17
 
18
- export interface BarListChartProps
19
- extends Exclude< BarChartProps, 'orientation' | 'size' | 'gridVisibility' > {
18
+ export interface BarListChartProps extends Exclude<
19
+ BarChartProps,
20
+ 'orientation' | 'size' | 'gridVisibility'
21
+ > {
20
22
  options?: {
21
23
  /**
22
24
  * Scale for the y axis. Exclude the type property.
@@ -89,7 +91,7 @@ export interface RenderValueProps {
89
91
  const getScaleBandwidth = < Scale extends AxisScale >( scale?: Scale ) => {
90
92
  // Broaden type before using 'xxx' in s as typeguard.
91
93
  const s = scale as AxisScale;
92
- return s && 'bandwidth' in s ? s?.bandwidth() ?? 0 : 0;
94
+ return s && 'bandwidth' in s ? ( s?.bandwidth() ?? 0 ) : 0;
93
95
  };
94
96
 
95
97
  const DefaultLabelComponent = ( { textProps, x, y, label, formatter } ) => {