@automattic/charts 4.2.0 → 4.3.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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/index.cjs +470 -121
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +23 -2
  5. package/dist/index.d.ts +23 -2
  6. package/dist/index.js +471 -122
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/charts/area-chart/area-chart.tsx +42 -10
  10. package/src/charts/area-chart/private/validate-data.ts +2 -3
  11. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  12. package/src/charts/bar-chart/bar-chart.tsx +100 -83
  13. package/src/charts/bar-chart/index.ts +1 -1
  14. package/src/charts/bar-chart/private/band-highlight.tsx +3 -1
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +1 -1
  16. package/src/charts/bar-chart/private/index.ts +1 -0
  17. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
  18. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
  20. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +484 -9
  22. package/src/charts/bar-chart/types.ts +39 -0
  23. package/src/charts/line-chart/line-chart.tsx +148 -35
  24. package/src/charts/line-chart/private/index.ts +1 -0
  25. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  26. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  27. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  28. package/src/charts/line-chart/types.ts +2 -1
  29. package/src/charts/private/readings.ts +34 -0
  30. package/src/charts/private/test/readings.test.ts +29 -0
  31. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  32. package/src/charts/private/whole-number-ticks.tsx +79 -0
  33. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  34. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  35. package/src/components/legend/private/base-legend.tsx +36 -21
  36. package/src/components/legend/test/legend.test.tsx +84 -0
  37. package/src/components/legend/types.ts +2 -0
  38. package/src/components/legend/utils/index.ts +0 -1
  39. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  40. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  41. package/src/hooks/use-chart-margin.tsx +7 -2
  42. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  43. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  44. package/src/types.ts +8 -0
  45. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -10,6 +10,7 @@ import {
10
10
  import userEvent from '@testing-library/user-event';
11
11
  import { GlobalChartsProvider } from '../../../providers';
12
12
  import { useGlobalChartsContext } from '../../../providers/chart-context/hooks/use-global-charts-context';
13
+ import { steadyTrafficData } from '../../../stories/sample-data';
13
14
  import BarChart, { BarChartUnresponsive } from '../bar-chart';
14
15
  import { useBarChartOptions } from '../private';
15
16
  import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
@@ -44,6 +45,11 @@ describe( 'BarChart', () => {
44
45
  return screen.getByRole( 'grid' ).querySelectorAll( '.visx-bar-group rect' );
45
46
  };
46
47
 
48
+ const getGridLines = () => {
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' );
51
+ };
52
+
47
53
  const renderWithTheme = ( props = {}, children = undefined ) => {
48
54
  return render(
49
55
  <GlobalChartsProvider>
@@ -134,7 +140,7 @@ describe( 'BarChart', () => {
134
140
  expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
135
141
  } );
136
142
 
137
- test( 'selects the nearer padded band before and after an unrelated rerender', async () => {
143
+ test( 'keeps band correction after BarGroup subscriptions on mount and rerender', async () => {
138
144
  const chart = ( className: string ) => (
139
145
  <GlobalChartsProvider>
140
146
  <BarChart { ...defaultProps } withTooltips className={ className } />
@@ -152,6 +158,63 @@ describe( 'BarChart', () => {
152
158
  await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 1' ) );
153
159
  } );
154
160
 
161
+ test( 'clears the highlight callback when the pointer leaves the plot', async () => {
162
+ const onBandHighlightChange = jest.fn();
163
+ renderWithTheme( { withTooltips: true, withBandHighlight: true, onBandHighlightChange } );
164
+ const [ [ x, , width ] ] = barGeometry();
165
+ hover( x + width / 2, 150 );
166
+ await expect(
167
+ screen.findByTestId( 'bar-chart-band-highlight' )
168
+ ).resolves.toBeInTheDocument();
169
+ pointer( 'pointerout', x + width / 2, 150 );
170
+ await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
171
+ expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
172
+ } );
173
+
174
+ test.each( [ 'comparison', 'hidden' ] )(
175
+ 'selects the visible primary band with a %s series',
176
+ async mode => {
177
+ const onBandHighlightChange = jest.fn();
178
+ const onPointerUp = jest.fn();
179
+ renderWithTheme( {
180
+ withTooltips: true,
181
+ withBandHighlight: true,
182
+ onBandHighlightChange,
183
+ onPointerUp,
184
+ defaultHiddenSeries: mode === 'hidden' ? [ 'Other' ] : [],
185
+ data: [
186
+ { ...defaultProps.data[ 0 ], group: 'scores' },
187
+ {
188
+ label: 'Other',
189
+ group: 'scores',
190
+ options: mode === 'comparison' ? { type: 'comparison' } : {},
191
+ data: defaultProps.data[ 0 ].data,
192
+ },
193
+ ],
194
+ } );
195
+ const [ [ x, , width ] ] = barGeometry();
196
+ hover( x + width / 2, 150 );
197
+ const highlight = await screen.findByTestId( 'bar-chart-band-highlight' );
198
+ const otherSlot =
199
+ Number( highlight.getAttribute( 'x' ) ) +
200
+ Number( highlight.getAttribute( 'width' ) ) * 0.85;
201
+ hover( otherSlot, 150 );
202
+ pointer( 'pointerup', otherSlot, 150 );
203
+ for ( const callback of [ onBandHighlightChange, onPointerUp ] ) {
204
+ expect( callback ).not.toHaveBeenCalledWith(
205
+ expect.objectContaining( { key: 'Other' } )
206
+ );
207
+ expect( callback ).toHaveBeenLastCalledWith(
208
+ expect.objectContaining( {
209
+ key: 'Series A',
210
+ index: 0,
211
+ datum: defaultProps.data[ 0 ].data[ 0 ],
212
+ } )
213
+ );
214
+ }
215
+ }
216
+ );
217
+
155
218
  test( 'reports the tooltip datum when pressing and releasing in a padded gap', async () => {
156
219
  const onPointerDown = jest.fn();
157
220
  const onPointerUp = jest.fn();
@@ -224,7 +287,36 @@ describe( 'BarChart', () => {
224
287
  );
225
288
  } );
226
289
 
227
- test( 'reports band bounds without drawing an overlay and clears them on Escape', async () => {
290
+ test( 'warns once when highlight options lack tooltips across callback changes and remounts', () => {
291
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
292
+ try {
293
+ const onBandHighlightChange = jest.fn();
294
+ const { rerender, unmount } = renderWithTheme( {
295
+ withBandHighlight: true,
296
+ onBandHighlightChange,
297
+ } );
298
+ for ( let i = 0; i < 4; i++ ) {
299
+ rerender(
300
+ <GlobalChartsProvider>
301
+ <BarChart { ...defaultProps } withBandHighlight onBandHighlightChange={ jest.fn() } />
302
+ </GlobalChartsProvider>
303
+ );
304
+ }
305
+ unmount();
306
+ renderWithTheme( { withBandHighlight: true, onBandHighlightChange } );
307
+ expect( warn ).toHaveBeenCalledTimes( 1 );
308
+ expect( screen.getByRole( 'grid' ) ).toBeInTheDocument();
309
+ expect( warn ).toHaveBeenCalledWith(
310
+ '[Charts] BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
311
+ );
312
+ expect( onBandHighlightChange ).not.toHaveBeenCalled();
313
+ expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
314
+ } finally {
315
+ warn.mockRestore();
316
+ }
317
+ } );
318
+
319
+ test( 'reports band bounds and point indexes during keyboard navigation and clears them on Escape', async () => {
228
320
  const user = userEvent.setup();
229
321
  const onBandHighlightChange = jest.fn();
230
322
  renderWithTheme( { withTooltips: true, onBandHighlightChange } );
@@ -233,6 +325,8 @@ describe( 'BarChart', () => {
233
325
  expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
234
326
  expect.objectContaining( {
235
327
  datum: expect.objectContaining( { value: 10 } ),
328
+ key: 'Series A',
329
+ index: 0,
236
330
  x: expect.any( Number ),
237
331
  y: expect.any( Number ),
238
332
  width: expect.any( Number ),
@@ -240,6 +334,14 @@ describe( 'BarChart', () => {
240
334
  } )
241
335
  );
242
336
  expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
337
+ await user.keyboard( '{ArrowRight}' );
338
+ expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
339
+ expect.objectContaining( {
340
+ datum: expect.objectContaining( { value: 20 } ),
341
+ key: 'Series A',
342
+ index: 1,
343
+ } )
344
+ );
243
345
  await user.keyboard( '{Escape}' );
244
346
  await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
245
347
  } );
@@ -282,19 +384,126 @@ describe( 'BarChart', () => {
282
384
  );
283
385
 
284
386
  test( 'aligns horizontal grid lines with explicit value ticks', () => {
285
- const { container } = renderWithTheme( {
387
+ renderWithTheme( {
286
388
  gridVisibility: 'x',
287
389
  options: {
288
390
  axis: { y: { tickValues: [ 0, 50, 100 ] } },
289
391
  scale: { y: { domain: [ 0, 100 ] } },
290
392
  },
291
393
  } );
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' );
394
+ const lines = getGridLines();
295
395
  expect( lines ).toHaveLength( 3 );
296
396
  } );
297
397
 
398
+ describe( 'Whole-number value ticks', () => {
399
+ const wholeNumberData: SeriesData[] = [
400
+ {
401
+ label: 'Series A',
402
+ data: [
403
+ { label: 'Mon', value: 0 },
404
+ { label: 'Tue', value: 1 },
405
+ { label: 'Wed', value: 1 },
406
+ ],
407
+ },
408
+ ];
409
+
410
+ test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
411
+ renderWithTheme( { data: wholeNumberData } );
412
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
413
+ const ticks = within( chart )
414
+ .getAllByText( /^-?[\d.,]+$/ )
415
+ .map( el => el.textContent );
416
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
417
+ } );
418
+
419
+ test( 'labels a horizontal whole-number range once per whole number', () => {
420
+ renderWithTheme( { data: wholeNumberData, orientation: 'horizontal' } );
421
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
422
+ const ticks = within( chart )
423
+ .getAllByText( /^-?[\d.,]+$/ )
424
+ .map( el => el.textContent );
425
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
426
+ } );
427
+
428
+ test( 'draws a flat series of ones on a 0 and 1 axis', () => {
429
+ renderWithTheme( {
430
+ data: [
431
+ {
432
+ label: 'Series A',
433
+ data: [
434
+ { label: 'Mon', value: 1 },
435
+ { label: 'Tue', value: 1 },
436
+ ],
437
+ },
438
+ ],
439
+ } );
440
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
441
+ const ticks = within( chart )
442
+ .getAllByText( /^-?[\d.,]+$/ )
443
+ .map( el => el.textContent );
444
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
445
+ } );
446
+
447
+ test.each( [ 'vertical', 'horizontal' ] as const )(
448
+ 'keeps every tick on a value domain the caller pinned (%s)',
449
+ orientation => {
450
+ const valueAxis = orientation === 'horizontal' ? 'x' : 'y';
451
+ renderWithTheme( {
452
+ orientation,
453
+ data: [
454
+ {
455
+ label: 'Series A',
456
+ data: [
457
+ { label: 'Mon', value: 0 },
458
+ { label: 'Tue', value: 0 },
459
+ ],
460
+ },
461
+ ],
462
+ options: {
463
+ [ `${ valueAxis }Scale` ]: { domain: [ 0, 1 ] },
464
+ axis: {
465
+ [ valueAxis ]: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` },
466
+ },
467
+ },
468
+ } );
469
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
470
+ expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
471
+ }
472
+ );
473
+
474
+ test( "keeps a caller's value tickValues", () => {
475
+ renderWithTheme( {
476
+ data: wholeNumberData,
477
+ options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
478
+ } );
479
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
480
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
481
+ } );
482
+ } );
483
+
484
+ test( 'draws category grid lines at the x axis numTicks', () => {
485
+ const data: SeriesData[] = [
486
+ {
487
+ label: 'Series A',
488
+ data: Array.from( { length: 30 }, ( _, i ) => ( { label: `Day ${ i + 1 }`, value: i } ) ),
489
+ },
490
+ ];
491
+ const countColumns = ( numTicks: number ) => {
492
+ const { container, unmount } = renderWithTheme( {
493
+ data,
494
+ gridVisibility: 'y',
495
+ options: { axis: { x: { numTicks } } },
496
+ } );
497
+ // See the visx node constraint at getBarRects.
498
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
499
+ const count = container.querySelectorAll( '.visx-columns line' ).length;
500
+ unmount();
501
+ return count;
502
+ };
503
+
504
+ expect( countColumns( 15 ) ).toBeGreaterThan( countColumns( 4 ) );
505
+ } );
506
+
298
507
  describe( 'Data Validation', () => {
299
508
  test( 'handles empty data array', () => {
300
509
  renderWithTheme( { data: [] } );
@@ -330,18 +539,52 @@ describe( 'BarChart', () => {
330
539
  expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
331
540
  } );
332
541
 
333
- test( 'handles null or undefined values', () => {
542
+ test( 'renders a bucket with no reading instead of failing the whole chart', () => {
543
+ renderWithTheme( {
544
+ data: [
545
+ {
546
+ label: 'Series A',
547
+ data: [
548
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
549
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
550
+ ],
551
+ options: {},
552
+ },
553
+ ],
554
+ } );
555
+ expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
556
+ expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
557
+ } );
558
+
559
+ test( 'draws no bar for a bucket with no reading', () => {
334
560
  renderWithTheme( {
335
561
  data: [
336
562
  {
337
563
  label: 'Series A',
338
564
  data: [
339
565
  { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
566
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
567
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
568
+ ],
569
+ options: {},
570
+ },
571
+ ],
572
+ } );
573
+ expect( getBarRects() ).toHaveLength( 2 );
574
+ } );
575
+
576
+ test( 'still rejects undefined values', () => {
577
+ renderWithTheme( {
578
+ data: [
579
+ {
580
+ label: 'Series A',
581
+ data: [
340
582
  {
341
- date: new Date( '2024-01-02' ),
583
+ date: new Date( '2024-01-01' ),
342
584
  value: undefined as number | undefined,
343
- label: 'Jan 2',
585
+ label: 'Jan 1',
344
586
  },
587
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
345
588
  ],
346
589
  options: {},
347
590
  },
@@ -1655,6 +1898,90 @@ describe( 'BarChart', () => {
1655
1898
  expect( tooltip ).toHaveTextContent( 'Previous period' );
1656
1899
  expect( tooltip ).toHaveTextContent( '25' );
1657
1900
  } );
1901
+
1902
+ test( 'reports a bucket with no reading as having no data', async () => {
1903
+ const user = userEvent.setup();
1904
+ renderWithTheme( {
1905
+ withTooltips: true,
1906
+ data: [
1907
+ {
1908
+ label: 'Series A',
1909
+ data: [
1910
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
1911
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1912
+ ],
1913
+ options: {},
1914
+ },
1915
+ ],
1916
+ } );
1917
+
1918
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1919
+ await user.keyboard( '{ArrowRight}' );
1920
+
1921
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
1922
+ expect( tooltip ).toHaveTextContent( 'Jan 1: No data' );
1923
+ expect( tooltip ).not.toHaveTextContent( 'Jan 1: 0' );
1924
+ } );
1925
+
1926
+ test( 'keeps both periods in the tooltip when the comparison period has no reading', async () => {
1927
+ const user = userEvent.setup();
1928
+ renderWithTheme( {
1929
+ withTooltips: true,
1930
+ data: [
1931
+ {
1932
+ label: 'This period',
1933
+ group: 'views',
1934
+ data: [
1935
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1936
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1937
+ ],
1938
+ },
1939
+ {
1940
+ label: 'Previous period',
1941
+ group: 'views',
1942
+ options: { type: 'comparison' as const },
1943
+ data: [
1944
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
1945
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1946
+ ],
1947
+ },
1948
+ ],
1949
+ } );
1950
+
1951
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
1952
+ await user.keyboard( '{ArrowRight}' );
1953
+
1954
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
1955
+ expect( tooltip ).toHaveTextContent( 'This period: 10' );
1956
+ expect( tooltip ).toHaveTextContent( 'Previous period: No data' );
1957
+ } );
1958
+
1959
+ test( 'draws no comparison shadow for a comparison bucket with no reading', () => {
1960
+ renderWithTheme( {
1961
+ data: [
1962
+ {
1963
+ label: 'This period',
1964
+ group: 'views',
1965
+ data: [
1966
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
1967
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
1968
+ ],
1969
+ },
1970
+ {
1971
+ label: 'Previous period',
1972
+ group: 'views',
1973
+ options: { type: 'comparison' as const },
1974
+ data: [
1975
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
1976
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
1977
+ ],
1978
+ },
1979
+ ],
1980
+ } );
1981
+
1982
+ expect( screen.queryByTestId( 'bar-chart-comparison-1-0' ) ).not.toBeInTheDocument();
1983
+ expect( screen.getByTestId( 'bar-chart-comparison-1-1' ) ).toBeInTheDocument();
1984
+ } );
1658
1985
  } );
1659
1986
 
1660
1987
  describe( 'Tab Key Navigation', () => {
@@ -1770,6 +2097,77 @@ describe( 'BarChart', () => {
1770
2097
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
1771
2098
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1772
2099
  } );
2100
+
2101
+ describe( 'with a leading bucket that has no reading', () => {
2102
+ const data = [
2103
+ {
2104
+ label: 'Series A',
2105
+ data: [
2106
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2107
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2108
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
2109
+ ],
2110
+ options: {},
2111
+ },
2112
+ ];
2113
+
2114
+ test( 'outlines nothing when the selected bucket has no bar', async () => {
2115
+ const user = userEvent.setup();
2116
+ renderWithTheme( { withTooltips: true, data } );
2117
+
2118
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2119
+ await user.keyboard( '{ArrowRight}' );
2120
+
2121
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
2122
+ expect( screen.queryByTestId( 'bar-chart-keyboard-highlight' ) ).not.toBeInTheDocument();
2123
+ } );
2124
+
2125
+ test( 'outlines the first rendered bar for the first bucket with a reading', async () => {
2126
+ const user = userEvent.setup();
2127
+ renderWithTheme( { withTooltips: true, data } );
2128
+
2129
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2130
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
2131
+
2132
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
2133
+ expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2134
+ /\.visx-bar:nth-child\(1\) \{/
2135
+ );
2136
+ } );
2137
+
2138
+ test( 'skips the bars an earlier series left out when outlining a later series', async () => {
2139
+ const user = userEvent.setup();
2140
+ const twoSeries = [
2141
+ {
2142
+ label: 'Series A',
2143
+ data: [
2144
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2145
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2146
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
2147
+ ],
2148
+ options: {},
2149
+ },
2150
+ {
2151
+ label: 'Series B',
2152
+ data: [
2153
+ { date: new Date( '2024-01-01' ), value: 5, label: 'Jan 1' },
2154
+ { date: new Date( '2024-01-02' ), value: 15, label: 'Jan 2' },
2155
+ { date: new Date( '2024-01-03' ), value: 25, label: 'Jan 3' },
2156
+ ],
2157
+ options: {},
2158
+ },
2159
+ ];
2160
+ renderWithTheme( { withTooltips: true, data: twoSeries } );
2161
+
2162
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2163
+ await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
2164
+
2165
+ expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
2166
+ expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2167
+ /\.visx-bar:nth-child\(4\) \{/
2168
+ );
2169
+ } );
2170
+ } );
1773
2171
  } );
1774
2172
 
1775
2173
  test( 'keyboard navigation works with custom tooltip renderer', async () => {
@@ -2065,6 +2463,83 @@ describe( 'BarChart', () => {
2065
2463
  } );
2066
2464
  } );
2067
2465
 
2466
+ describe( 'Value axis baseline', () => {
2467
+ it( 'starts the value axis at zero in horizontal charts', () => {
2468
+ const { result } = renderHook( () => useBarChartOptions( steadyTrafficData, true, {} ) );
2469
+ const xScale = result.current.xScale as { domain?: number[] };
2470
+ expect( xScale.domain ).toEqual( [ 0, 989 ] );
2471
+ } );
2472
+
2473
+ it( 'lets a caller opt out of the zero baseline', () => {
2474
+ const { result } = renderHook( () =>
2475
+ useBarChartOptions( steadyTrafficData, false, { yScale: { zero: false } } )
2476
+ );
2477
+ const yScale = result.current.yScale as { domain?: number[] };
2478
+ expect( yScale.domain ).toBeUndefined();
2479
+ } );
2480
+
2481
+ it( 'reads the opt-out from the x scale of a horizontal chart', () => {
2482
+ const { result } = renderHook( () =>
2483
+ useBarChartOptions( steadyTrafficData, true, { xScale: { zero: false } } )
2484
+ );
2485
+ const xScale = result.current.xScale as { domain?: number[] };
2486
+ expect( xScale.domain ).toBeUndefined();
2487
+ } );
2488
+
2489
+ it( 'keeps an explicit domain as given', () => {
2490
+ const { result } = renderHook( () =>
2491
+ useBarChartOptions( steadyTrafficData, false, { yScale: { domain: [ 900, 1000 ] } } )
2492
+ );
2493
+ const yScale = result.current.yScale as { domain?: number[] };
2494
+ expect( yScale.domain ).toEqual( [ 900, 1000 ] );
2495
+ } );
2496
+
2497
+ it( 'renders an explicit domain without stretching it to zero', () => {
2498
+ render(
2499
+ <BarChart
2500
+ data={ steadyTrafficData }
2501
+ width={ 400 }
2502
+ height={ 300 }
2503
+ options={ { yScale: { domain: [ 900, 1000 ] } } }
2504
+ />
2505
+ );
2506
+ const heights = Array.from( getBarRects() ).map( bar =>
2507
+ parseFloat( bar.getAttribute( 'height' ) || '0' )
2508
+ );
2509
+ // 921 sits at 21% of a 900–1000 axis; on a 0–1000 axis it would be 92%.
2510
+ expect( Math.min( ...heights ) / Math.max( ...heights ) ).toBeLessThan( 0.5 );
2511
+ } );
2512
+
2513
+ it( 'keeps zero in the domain when the caller opts out but a comparison series is present', () => {
2514
+ const data = [
2515
+ ...steadyTrafficData,
2516
+ {
2517
+ label: 'Previous',
2518
+ options: { type: 'comparison' as const },
2519
+ data: steadyTrafficData[ 0 ].data.map( point => ( {
2520
+ ...point,
2521
+ value: point.value - 10,
2522
+ } ) ),
2523
+ },
2524
+ ];
2525
+ const { result } = renderHook( () =>
2526
+ useBarChartOptions( data, false, { yScale: { zero: false } } )
2527
+ );
2528
+ const yScale = result.current.yScale as { domain?: number[] };
2529
+ expect( yScale.domain ).toEqual( [ 0, 989 ] );
2530
+ } );
2531
+
2532
+ it( 'renders every bar of steady traffic at most a few percent shorter than the tallest', () => {
2533
+ render( <BarChart data={ steadyTrafficData } width={ 400 } height={ 300 } /> );
2534
+ const heights = Array.from( getBarRects() ).map( bar =>
2535
+ parseFloat( bar.getAttribute( 'height' ) || '0' )
2536
+ );
2537
+ expect( heights ).toHaveLength( 7 );
2538
+ const tallest = Math.max( ...heights );
2539
+ expect( Math.min( ...heights ) / tallest ).toBeGreaterThan( 0.9 );
2540
+ } );
2541
+ } );
2542
+
2068
2543
  describe( 'Comparison Series', () => {
2069
2544
  it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
2070
2545
  const data = [
@@ -0,0 +1,39 @@
1
+ import type {
2
+ BaseChartProps,
3
+ DataPointDate,
4
+ SeriesData,
5
+ SeriesChartLegendConfig,
6
+ SeriesVisibilityProps,
7
+ } from '../../types';
8
+ import type { RenderTooltipParams } from '../../visx/types';
9
+ import type { ReactNode } from 'react';
10
+
11
+ export type BandHighlightSelection = {
12
+ datum: DataPointDate;
13
+ key: string;
14
+ index: number;
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ };
20
+
21
+ export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps {
22
+ /**
23
+ * Legend configuration. Supports `collapseGroups` on top of the shared options.
24
+ */
25
+ legend?: SeriesChartLegendConfig;
26
+ renderTooltip?: ( params: RenderTooltipParams< DataPointDate > ) => ReactNode;
27
+ /** Place the tooltip beside its category without vertical flipping. */
28
+ tooltipPlacement?: 'auto' | 'beside';
29
+ /** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
30
+ tooltipAnchorTop?: number;
31
+ orientation?: 'horizontal' | 'vertical';
32
+ withPatterns?: boolean;
33
+ showZeroValues?: boolean;
34
+ /** Highlight the active band across the plot when tooltips are enabled. */
35
+ withBandHighlight?: boolean;
36
+ /** Receive active band bounds in SVG coordinates, or null when dismissed. */
37
+ onBandHighlightChange?: ( selection: BandHighlightSelection | null ) => void;
38
+ children?: ReactNode;
39
+ }