@automattic/charts 4.2.0 → 4.4.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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.cjs +533 -127
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +27 -2
  5. package/dist/index.d.ts +27 -2
  6. package/dist/index.js +535 -129
  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 +135 -96
  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/classified-bar-series.tsx +85 -0
  17. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
  18. package/src/charts/bar-chart/private/index.ts +2 -0
  19. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +28 -2
  20. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
  21. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  22. package/src/charts/bar-chart/private/use-bar-chart-options.ts +12 -39
  23. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  24. package/src/charts/bar-chart/test/bar-chart.test.tsx +614 -10
  25. package/src/charts/bar-chart/types.ts +43 -0
  26. package/src/charts/line-chart/line-chart.tsx +148 -35
  27. package/src/charts/line-chart/private/index.ts +1 -0
  28. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  29. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  30. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  31. package/src/charts/line-chart/types.ts +2 -1
  32. package/src/charts/private/readings.ts +34 -0
  33. package/src/charts/private/test/readings.test.ts +29 -0
  34. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  35. package/src/charts/private/whole-number-ticks.tsx +79 -0
  36. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  37. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  38. package/src/components/legend/private/base-legend.tsx +36 -21
  39. package/src/components/legend/test/legend.test.tsx +84 -0
  40. package/src/components/legend/types.ts +2 -0
  41. package/src/components/legend/utils/index.ts +0 -1
  42. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  43. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  44. package/src/hooks/use-chart-margin.tsx +7 -2
  45. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  46. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  47. package/src/types.ts +8 -0
  48. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -10,10 +10,11 @@ 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';
16
- import type { SeriesData } from '../../../types';
17
+ import type { DataPointDate, SeriesData } from '../../../types';
17
18
 
18
19
  // Mock useElementSize to return non-zero dimensions in jsdom so charts render
19
20
  const mockRefCallback = jest.fn();
@@ -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>
@@ -54,6 +60,70 @@ describe( 'BarChart', () => {
54
60
  );
55
61
  };
56
62
 
63
+ test.each(
64
+ [ 'vertical', 'horizontal' ].flatMap( orientation =>
65
+ [
66
+ [ 10, 20, 30 ],
67
+ [ -20, -10, -30 ],
68
+ [ -20, 10, 30 ],
69
+ ].map( values => ( { orientation, values } ) )
70
+ )
71
+ )(
72
+ 'adds datum classes without changing $orientation bar attributes for $values',
73
+ async ( { orientation, values } ) => {
74
+ const series = {
75
+ ...defaultProps.data[ 0 ],
76
+ data: defaultProps.data[ 0 ].data.map( ( datum, index ) => ( {
77
+ ...datum,
78
+ value: values[ index ],
79
+ } ) ),
80
+ };
81
+ const props = {
82
+ orientation,
83
+ data: [ series, { ...series, label: 'Series B' } ],
84
+ };
85
+ const view = renderWithTheme( props );
86
+ await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
87
+ const attributes = () =>
88
+ Array.from( getBarRects(), bar =>
89
+ [ 'x', 'y', 'width', 'height', 'fill', 'tabindex' ].map( attribute =>
90
+ bar.getAttribute( attribute )
91
+ )
92
+ );
93
+ const original = attributes();
94
+ view.unmount();
95
+ renderWithTheme( {
96
+ ...props,
97
+ barClassName: ( datum: DataPointDate ) =>
98
+ datum.value === values[ 0 ] ? 'first-value' : undefined,
99
+ } );
100
+ await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
101
+ expect( attributes() ).toEqual( original );
102
+ expect( getBarRects()[ 0 ] ).toHaveClass( 'visx-bar', 'first-value' );
103
+ expect( getBarRects()[ 1 ] ).not.toHaveClass( 'first-value' );
104
+ expect( getBarRects()[ 3 ] ).toHaveClass( 'first-value' );
105
+ }
106
+ );
107
+
108
+ test( 'applies tooltip style overrides while keeping keyboard tooltip content', async () => {
109
+ const user = userEvent.setup();
110
+ renderWithTheme( {
111
+ withTooltips: true,
112
+ barClassName: () => 'styled-bar',
113
+ tooltipStyle: { padding: 0, backgroundColor: 'transparent', boxShadow: 'none' },
114
+ } );
115
+ await user.tab();
116
+ await user.keyboard( '{ArrowRight}' );
117
+ const tooltip = await screen.findByRole( 'tooltip' );
118
+ expect( tooltip ).toHaveTextContent( 'Jan 1' );
119
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
120
+ padding: '0px',
121
+ // jsdom normalizes transparent in computed styles.
122
+ 'background-color': 'rgba(0, 0, 0, 0)',
123
+ 'box-shadow': 'none',
124
+ } );
125
+ } );
126
+
57
127
  describe( 'pointer selection and focus return', () => {
58
128
  const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
59
129
  beforeAll( () => {
@@ -134,7 +204,7 @@ describe( 'BarChart', () => {
134
204
  expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
135
205
  } );
136
206
 
137
- test( 'selects the nearer padded band before and after an unrelated rerender', async () => {
207
+ test( 'keeps band correction after BarGroup subscriptions on mount and rerender', async () => {
138
208
  const chart = ( className: string ) => (
139
209
  <GlobalChartsProvider>
140
210
  <BarChart { ...defaultProps } withTooltips className={ className } />
@@ -152,6 +222,63 @@ describe( 'BarChart', () => {
152
222
  await waitFor( () => expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Jan 1' ) );
153
223
  } );
154
224
 
225
+ test( 'clears the highlight callback when the pointer leaves the plot', async () => {
226
+ const onBandHighlightChange = jest.fn();
227
+ renderWithTheme( { withTooltips: true, withBandHighlight: true, onBandHighlightChange } );
228
+ const [ [ x, , width ] ] = barGeometry();
229
+ hover( x + width / 2, 150 );
230
+ await expect(
231
+ screen.findByTestId( 'bar-chart-band-highlight' )
232
+ ).resolves.toBeInTheDocument();
233
+ pointer( 'pointerout', x + width / 2, 150 );
234
+ await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
235
+ expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
236
+ } );
237
+
238
+ test.each( [ 'comparison', 'hidden' ] )(
239
+ 'selects the visible primary band with a %s series',
240
+ async mode => {
241
+ const onBandHighlightChange = jest.fn();
242
+ const onPointerUp = jest.fn();
243
+ renderWithTheme( {
244
+ withTooltips: true,
245
+ withBandHighlight: true,
246
+ onBandHighlightChange,
247
+ onPointerUp,
248
+ defaultHiddenSeries: mode === 'hidden' ? [ 'Other' ] : [],
249
+ data: [
250
+ { ...defaultProps.data[ 0 ], group: 'scores' },
251
+ {
252
+ label: 'Other',
253
+ group: 'scores',
254
+ options: mode === 'comparison' ? { type: 'comparison' } : {},
255
+ data: defaultProps.data[ 0 ].data,
256
+ },
257
+ ],
258
+ } );
259
+ const [ [ x, , width ] ] = barGeometry();
260
+ hover( x + width / 2, 150 );
261
+ const highlight = await screen.findByTestId( 'bar-chart-band-highlight' );
262
+ const otherSlot =
263
+ Number( highlight.getAttribute( 'x' ) ) +
264
+ Number( highlight.getAttribute( 'width' ) ) * 0.85;
265
+ hover( otherSlot, 150 );
266
+ pointer( 'pointerup', otherSlot, 150 );
267
+ for ( const callback of [ onBandHighlightChange, onPointerUp ] ) {
268
+ expect( callback ).not.toHaveBeenCalledWith(
269
+ expect.objectContaining( { key: 'Other' } )
270
+ );
271
+ expect( callback ).toHaveBeenLastCalledWith(
272
+ expect.objectContaining( {
273
+ key: 'Series A',
274
+ index: 0,
275
+ datum: defaultProps.data[ 0 ].data[ 0 ],
276
+ } )
277
+ );
278
+ }
279
+ }
280
+ );
281
+
155
282
  test( 'reports the tooltip datum when pressing and releasing in a padded gap', async () => {
156
283
  const onPointerDown = jest.fn();
157
284
  const onPointerUp = jest.fn();
@@ -224,7 +351,36 @@ describe( 'BarChart', () => {
224
351
  );
225
352
  } );
226
353
 
227
- test( 'reports band bounds without drawing an overlay and clears them on Escape', async () => {
354
+ test( 'warns once when highlight options lack tooltips across callback changes and remounts', () => {
355
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
356
+ try {
357
+ const onBandHighlightChange = jest.fn();
358
+ const { rerender, unmount } = renderWithTheme( {
359
+ withBandHighlight: true,
360
+ onBandHighlightChange,
361
+ } );
362
+ for ( let i = 0; i < 4; i++ ) {
363
+ rerender(
364
+ <GlobalChartsProvider>
365
+ <BarChart { ...defaultProps } withBandHighlight onBandHighlightChange={ jest.fn() } />
366
+ </GlobalChartsProvider>
367
+ );
368
+ }
369
+ unmount();
370
+ renderWithTheme( { withBandHighlight: true, onBandHighlightChange } );
371
+ expect( warn ).toHaveBeenCalledTimes( 1 );
372
+ expect( screen.getByRole( 'grid' ) ).toBeInTheDocument();
373
+ expect( warn ).toHaveBeenCalledWith(
374
+ '[Charts] BarChart: withBandHighlight and onBandHighlightChange require withTooltips.'
375
+ );
376
+ expect( onBandHighlightChange ).not.toHaveBeenCalled();
377
+ expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
378
+ } finally {
379
+ warn.mockRestore();
380
+ }
381
+ } );
382
+
383
+ test( 'reports band bounds and point indexes during keyboard navigation and clears them on Escape', async () => {
228
384
  const user = userEvent.setup();
229
385
  const onBandHighlightChange = jest.fn();
230
386
  renderWithTheme( { withTooltips: true, onBandHighlightChange } );
@@ -233,6 +389,8 @@ describe( 'BarChart', () => {
233
389
  expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
234
390
  expect.objectContaining( {
235
391
  datum: expect.objectContaining( { value: 10 } ),
392
+ key: 'Series A',
393
+ index: 0,
236
394
  x: expect.any( Number ),
237
395
  y: expect.any( Number ),
238
396
  width: expect.any( Number ),
@@ -240,6 +398,14 @@ describe( 'BarChart', () => {
240
398
  } )
241
399
  );
242
400
  expect( screen.queryByTestId( 'bar-chart-band-highlight' ) ).not.toBeInTheDocument();
401
+ await user.keyboard( '{ArrowRight}' );
402
+ expect( onBandHighlightChange ).toHaveBeenLastCalledWith(
403
+ expect.objectContaining( {
404
+ datum: expect.objectContaining( { value: 20 } ),
405
+ key: 'Series A',
406
+ index: 1,
407
+ } )
408
+ );
243
409
  await user.keyboard( '{Escape}' );
244
410
  await waitFor( () => expect( onBandHighlightChange ).toHaveBeenLastCalledWith( null ) );
245
411
  } );
@@ -282,19 +448,126 @@ describe( 'BarChart', () => {
282
448
  );
283
449
 
284
450
  test( 'aligns horizontal grid lines with explicit value ticks', () => {
285
- const { container } = renderWithTheme( {
451
+ renderWithTheme( {
286
452
  gridVisibility: 'x',
287
453
  options: {
288
454
  axis: { y: { tickValues: [ 0, 50, 100 ] } },
289
455
  scale: { y: { domain: [ 0, 100 ] } },
290
456
  },
291
457
  } );
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' );
458
+ const lines = getGridLines();
295
459
  expect( lines ).toHaveLength( 3 );
296
460
  } );
297
461
 
462
+ describe( 'Whole-number value ticks', () => {
463
+ const wholeNumberData: SeriesData[] = [
464
+ {
465
+ label: 'Series A',
466
+ data: [
467
+ { label: 'Mon', value: 0 },
468
+ { label: 'Tue', value: 1 },
469
+ { label: 'Wed', value: 1 },
470
+ ],
471
+ },
472
+ ];
473
+
474
+ test( 'labels a whole-number range smaller than the tick count once per whole number', () => {
475
+ renderWithTheme( { data: wholeNumberData } );
476
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
477
+ const ticks = within( chart )
478
+ .getAllByText( /^-?[\d.,]+$/ )
479
+ .map( el => el.textContent );
480
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
481
+ } );
482
+
483
+ test( 'labels a horizontal whole-number range once per whole number', () => {
484
+ renderWithTheme( { data: wholeNumberData, orientation: 'horizontal' } );
485
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
486
+ const ticks = within( chart )
487
+ .getAllByText( /^-?[\d.,]+$/ )
488
+ .map( el => el.textContent );
489
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
490
+ } );
491
+
492
+ test( 'draws a flat series of ones on a 0 and 1 axis', () => {
493
+ renderWithTheme( {
494
+ data: [
495
+ {
496
+ label: 'Series A',
497
+ data: [
498
+ { label: 'Mon', value: 1 },
499
+ { label: 'Tue', value: 1 },
500
+ ],
501
+ },
502
+ ],
503
+ } );
504
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
505
+ const ticks = within( chart )
506
+ .getAllByText( /^-?[\d.,]+$/ )
507
+ .map( el => el.textContent );
508
+ expect( ticks.sort() ).toEqual( [ '0', '1' ] );
509
+ } );
510
+
511
+ test.each( [ 'vertical', 'horizontal' ] as const )(
512
+ 'keeps every tick on a value domain the caller pinned (%s)',
513
+ orientation => {
514
+ const valueAxis = orientation === 'horizontal' ? 'x' : 'y';
515
+ renderWithTheme( {
516
+ orientation,
517
+ data: [
518
+ {
519
+ label: 'Series A',
520
+ data: [
521
+ { label: 'Mon', value: 0 },
522
+ { label: 'Tue', value: 0 },
523
+ ],
524
+ },
525
+ ],
526
+ options: {
527
+ [ `${ valueAxis }Scale` ]: { domain: [ 0, 1 ] },
528
+ axis: {
529
+ [ valueAxis ]: { tickFormat: ( value: number ) => `${ Math.round( value * 100 ) }%` },
530
+ },
531
+ },
532
+ } );
533
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
534
+ expect( within( chart ).getAllByText( /^\d+%$/ ) ).toHaveLength( 6 );
535
+ }
536
+ );
537
+
538
+ test( "keeps a caller's value tickValues", () => {
539
+ renderWithTheme( {
540
+ data: wholeNumberData,
541
+ options: { axis: { y: { tickValues: [ 0, 0.5, 1 ] } } },
542
+ } );
543
+ const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
544
+ expect( within( chart ).getAllByText( /^-?[\d.,]+$/ ) ).toHaveLength( 3 );
545
+ } );
546
+ } );
547
+
548
+ test( 'draws category grid lines at the x axis numTicks', () => {
549
+ const data: SeriesData[] = [
550
+ {
551
+ label: 'Series A',
552
+ data: Array.from( { length: 30 }, ( _, i ) => ( { label: `Day ${ i + 1 }`, value: i } ) ),
553
+ },
554
+ ];
555
+ const countColumns = ( numTicks: number ) => {
556
+ const { container, unmount } = renderWithTheme( {
557
+ data,
558
+ gridVisibility: 'y',
559
+ options: { axis: { x: { numTicks } } },
560
+ } );
561
+ // See the visx node constraint at getBarRects.
562
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
563
+ const count = container.querySelectorAll( '.visx-columns line' ).length;
564
+ unmount();
565
+ return count;
566
+ };
567
+
568
+ expect( countColumns( 15 ) ).toBeGreaterThan( countColumns( 4 ) );
569
+ } );
570
+
298
571
  describe( 'Data Validation', () => {
299
572
  test( 'handles empty data array', () => {
300
573
  renderWithTheme( { data: [] } );
@@ -330,18 +603,52 @@ describe( 'BarChart', () => {
330
603
  expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
331
604
  } );
332
605
 
333
- test( 'handles null or undefined values', () => {
606
+ test( 'renders a bucket with no reading instead of failing the whole chart', () => {
334
607
  renderWithTheme( {
335
608
  data: [
336
609
  {
337
610
  label: 'Series A',
338
611
  data: [
339
612
  { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
613
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
614
+ ],
615
+ options: {},
616
+ },
617
+ ],
618
+ } );
619
+ expect( screen.queryByText( /invalid data/i ) ).not.toBeInTheDocument();
620
+ expect( screen.getByRole( 'grid', { name: /bar chart/i } ) ).toBeInTheDocument();
621
+ } );
622
+
623
+ test( 'draws no bar for a bucket with no reading', () => {
624
+ renderWithTheme( {
625
+ data: [
626
+ {
627
+ label: 'Series A',
628
+ data: [
629
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
630
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
631
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
632
+ ],
633
+ options: {},
634
+ },
635
+ ],
636
+ } );
637
+ expect( getBarRects() ).toHaveLength( 2 );
638
+ } );
639
+
640
+ test( 'still rejects undefined values', () => {
641
+ renderWithTheme( {
642
+ data: [
643
+ {
644
+ label: 'Series A',
645
+ data: [
340
646
  {
341
- date: new Date( '2024-01-02' ),
647
+ date: new Date( '2024-01-01' ),
342
648
  value: undefined as number | undefined,
343
- label: 'Jan 2',
649
+ label: 'Jan 1',
344
650
  },
651
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
345
652
  ],
346
653
  options: {},
347
654
  },
@@ -434,6 +741,71 @@ describe( 'BarChart', () => {
434
741
  } );
435
742
  } );
436
743
 
744
+ describe( 'Grid tick counts', () => {
745
+ const getPositions = ( selector: string, coordinate: string ) => {
746
+ // eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
747
+ const lines = screen.getByRole( 'grid' ).querySelectorAll( selector );
748
+ return Array.from( lines, line => Number( line.getAttribute( coordinate ) ) );
749
+ };
750
+
751
+ test.each( [ 'x', 'y' ] )( 'aligns an explicit tick count with the %s axis', axis => {
752
+ renderWithTheme( {
753
+ data: [
754
+ {
755
+ label: 'Views',
756
+ data: Array.from( { length: 8 }, ( _, index ) => ( {
757
+ label: `Day ${ index + 1 }`,
758
+ value: ( index + 1 ) * 10,
759
+ } ) ),
760
+ },
761
+ ],
762
+ gridVisibility: 'xy',
763
+ options: {
764
+ axis: { [ axis ]: { numTicks: 2, tickFormat: String, axisClassName: 'test-value-axis' } },
765
+ },
766
+ } );
767
+ const grid = getPositions(
768
+ axis === 'x' ? '.visx-columns line' : '.visx-rows line',
769
+ `${ axis }1`
770
+ );
771
+ const ticks = getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` );
772
+ expect( ticks.length ).toBeGreaterThan( 0 );
773
+ expect( grid ).toEqual( ticks );
774
+ } );
775
+
776
+ test.each( [ false, true ] )(
777
+ 'preserves the default tick count (horizontal=%s)',
778
+ horizontal => {
779
+ const axis = horizontal ? 'x' : 'y';
780
+ const positionsFor = ( axisOptions: object ) => {
781
+ const { unmount } = renderWithTheme( {
782
+ orientation: horizontal ? 'horizontal' : 'vertical',
783
+ gridVisibility: 'xy',
784
+ options: {
785
+ axis: { [ axis ]: { axisClassName: 'test-value-axis', ...axisOptions } },
786
+ },
787
+ } );
788
+ const positions = {
789
+ grid: getPositions(
790
+ horizontal ? '.visx-columns line' : '.visx-rows line',
791
+ `${ axis }1`
792
+ ),
793
+ ticks: getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` ),
794
+ };
795
+ unmount();
796
+ return positions;
797
+ };
798
+
799
+ const omitted = positionsFor( {} );
800
+ const explicit = positionsFor( { numTicks: 4 } );
801
+
802
+ expect( omitted.ticks.length ).toBeGreaterThan( 0 );
803
+ expect( omitted.grid ).toEqual( omitted.ticks );
804
+ expect( omitted.grid ).toEqual( explicit.grid );
805
+ }
806
+ );
807
+ } );
808
+
437
809
  describe( 'Grid Visibility', () => {
438
810
  test( 'renders with different grid visibility options', () => {
439
811
  const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
@@ -1655,6 +2027,90 @@ describe( 'BarChart', () => {
1655
2027
  expect( tooltip ).toHaveTextContent( 'Previous period' );
1656
2028
  expect( tooltip ).toHaveTextContent( '25' );
1657
2029
  } );
2030
+
2031
+ test( 'reports a bucket with no reading as having no data', async () => {
2032
+ const user = userEvent.setup();
2033
+ renderWithTheme( {
2034
+ withTooltips: true,
2035
+ data: [
2036
+ {
2037
+ label: 'Series A',
2038
+ data: [
2039
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2040
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2041
+ ],
2042
+ options: {},
2043
+ },
2044
+ ],
2045
+ } );
2046
+
2047
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2048
+ await user.keyboard( '{ArrowRight}' );
2049
+
2050
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
2051
+ expect( tooltip ).toHaveTextContent( 'Jan 1: No data' );
2052
+ expect( tooltip ).not.toHaveTextContent( 'Jan 1: 0' );
2053
+ } );
2054
+
2055
+ test( 'keeps both periods in the tooltip when the comparison period has no reading', async () => {
2056
+ const user = userEvent.setup();
2057
+ renderWithTheme( {
2058
+ withTooltips: true,
2059
+ data: [
2060
+ {
2061
+ label: 'This period',
2062
+ group: 'views',
2063
+ data: [
2064
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
2065
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2066
+ ],
2067
+ },
2068
+ {
2069
+ label: 'Previous period',
2070
+ group: 'views',
2071
+ options: { type: 'comparison' as const },
2072
+ data: [
2073
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2074
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
2075
+ ],
2076
+ },
2077
+ ],
2078
+ } );
2079
+
2080
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2081
+ await user.keyboard( '{ArrowRight}' );
2082
+
2083
+ const tooltip = screen.getByTestId( 'chart-tooltip-0' );
2084
+ expect( tooltip ).toHaveTextContent( 'This period: 10' );
2085
+ expect( tooltip ).toHaveTextContent( 'Previous period: No data' );
2086
+ } );
2087
+
2088
+ test( 'draws no comparison shadow for a comparison bucket with no reading', () => {
2089
+ renderWithTheme( {
2090
+ data: [
2091
+ {
2092
+ label: 'This period',
2093
+ group: 'views',
2094
+ data: [
2095
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
2096
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2097
+ ],
2098
+ },
2099
+ {
2100
+ label: 'Previous period',
2101
+ group: 'views',
2102
+ options: { type: 'comparison' as const },
2103
+ data: [
2104
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2105
+ { date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
2106
+ ],
2107
+ },
2108
+ ],
2109
+ } );
2110
+
2111
+ expect( screen.queryByTestId( 'bar-chart-comparison-1-0' ) ).not.toBeInTheDocument();
2112
+ expect( screen.getByTestId( 'bar-chart-comparison-1-1' ) ).toBeInTheDocument();
2113
+ } );
1658
2114
  } );
1659
2115
 
1660
2116
  describe( 'Tab Key Navigation', () => {
@@ -1770,6 +2226,77 @@ describe( 'BarChart', () => {
1770
2226
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toBeInTheDocument();
1771
2227
  expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Series A' );
1772
2228
  } );
2229
+
2230
+ describe( 'with a leading bucket that has no reading', () => {
2231
+ const data = [
2232
+ {
2233
+ label: 'Series A',
2234
+ data: [
2235
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2236
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2237
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
2238
+ ],
2239
+ options: {},
2240
+ },
2241
+ ];
2242
+
2243
+ test( 'outlines nothing when the selected bucket has no bar', async () => {
2244
+ const user = userEvent.setup();
2245
+ renderWithTheme( { withTooltips: true, data } );
2246
+
2247
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2248
+ await user.keyboard( '{ArrowRight}' );
2249
+
2250
+ expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'No data' );
2251
+ expect( screen.queryByTestId( 'bar-chart-keyboard-highlight' ) ).not.toBeInTheDocument();
2252
+ } );
2253
+
2254
+ test( 'outlines the first rendered bar for the first bucket with a reading', async () => {
2255
+ const user = userEvent.setup();
2256
+ renderWithTheme( { withTooltips: true, data } );
2257
+
2258
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2259
+ await user.keyboard( '{ArrowRight}{ArrowRight}' );
2260
+
2261
+ expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent( '20' );
2262
+ expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2263
+ /\.visx-bar:nth-child\(1\) \{/
2264
+ );
2265
+ } );
2266
+
2267
+ test( 'skips the bars an earlier series left out when outlining a later series', async () => {
2268
+ const user = userEvent.setup();
2269
+ const twoSeries = [
2270
+ {
2271
+ label: 'Series A',
2272
+ data: [
2273
+ { date: new Date( '2024-01-01' ), value: null as number | null, label: 'Jan 1' },
2274
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
2275
+ { date: new Date( '2024-01-03' ), value: 30, label: 'Jan 3' },
2276
+ ],
2277
+ options: {},
2278
+ },
2279
+ {
2280
+ label: 'Series B',
2281
+ data: [
2282
+ { date: new Date( '2024-01-01' ), value: 5, label: 'Jan 1' },
2283
+ { date: new Date( '2024-01-02' ), value: 15, label: 'Jan 2' },
2284
+ { date: new Date( '2024-01-03' ), value: 25, label: 'Jan 3' },
2285
+ ],
2286
+ options: {},
2287
+ },
2288
+ ];
2289
+ renderWithTheme( { withTooltips: true, data: twoSeries } );
2290
+
2291
+ screen.getByRole( 'grid', { name: /bar chart/i } ).focus();
2292
+ await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}{ArrowRight}' );
2293
+
2294
+ expect( screen.getByTestId( 'chart-tooltip-3' ) ).toHaveTextContent( '15' );
2295
+ expect( screen.getByTestId( 'bar-chart-keyboard-highlight' ) ).toHaveTextContent(
2296
+ /\.visx-bar:nth-child\(4\) \{/
2297
+ );
2298
+ } );
2299
+ } );
1773
2300
  } );
1774
2301
 
1775
2302
  test( 'keyboard navigation works with custom tooltip renderer', async () => {
@@ -2065,6 +2592,83 @@ describe( 'BarChart', () => {
2065
2592
  } );
2066
2593
  } );
2067
2594
 
2595
+ describe( 'Value axis baseline', () => {
2596
+ it( 'starts the value axis at zero in horizontal charts', () => {
2597
+ const { result } = renderHook( () => useBarChartOptions( steadyTrafficData, true, {} ) );
2598
+ const xScale = result.current.xScale as { domain?: number[] };
2599
+ expect( xScale.domain ).toEqual( [ 0, 989 ] );
2600
+ } );
2601
+
2602
+ it( 'lets a caller opt out of the zero baseline', () => {
2603
+ const { result } = renderHook( () =>
2604
+ useBarChartOptions( steadyTrafficData, false, { yScale: { zero: false } } )
2605
+ );
2606
+ const yScale = result.current.yScale as { domain?: number[] };
2607
+ expect( yScale.domain ).toBeUndefined();
2608
+ } );
2609
+
2610
+ it( 'reads the opt-out from the x scale of a horizontal chart', () => {
2611
+ const { result } = renderHook( () =>
2612
+ useBarChartOptions( steadyTrafficData, true, { xScale: { zero: false } } )
2613
+ );
2614
+ const xScale = result.current.xScale as { domain?: number[] };
2615
+ expect( xScale.domain ).toBeUndefined();
2616
+ } );
2617
+
2618
+ it( 'keeps an explicit domain as given', () => {
2619
+ const { result } = renderHook( () =>
2620
+ useBarChartOptions( steadyTrafficData, false, { yScale: { domain: [ 900, 1000 ] } } )
2621
+ );
2622
+ const yScale = result.current.yScale as { domain?: number[] };
2623
+ expect( yScale.domain ).toEqual( [ 900, 1000 ] );
2624
+ } );
2625
+
2626
+ it( 'renders an explicit domain without stretching it to zero', () => {
2627
+ render(
2628
+ <BarChart
2629
+ data={ steadyTrafficData }
2630
+ width={ 400 }
2631
+ height={ 300 }
2632
+ options={ { yScale: { domain: [ 900, 1000 ] } } }
2633
+ />
2634
+ );
2635
+ const heights = Array.from( getBarRects() ).map( bar =>
2636
+ parseFloat( bar.getAttribute( 'height' ) || '0' )
2637
+ );
2638
+ // 921 sits at 21% of a 900–1000 axis; on a 0–1000 axis it would be 92%.
2639
+ expect( Math.min( ...heights ) / Math.max( ...heights ) ).toBeLessThan( 0.5 );
2640
+ } );
2641
+
2642
+ it( 'keeps zero in the domain when the caller opts out but a comparison series is present', () => {
2643
+ const data = [
2644
+ ...steadyTrafficData,
2645
+ {
2646
+ label: 'Previous',
2647
+ options: { type: 'comparison' as const },
2648
+ data: steadyTrafficData[ 0 ].data.map( point => ( {
2649
+ ...point,
2650
+ value: point.value - 10,
2651
+ } ) ),
2652
+ },
2653
+ ];
2654
+ const { result } = renderHook( () =>
2655
+ useBarChartOptions( data, false, { yScale: { zero: false } } )
2656
+ );
2657
+ const yScale = result.current.yScale as { domain?: number[] };
2658
+ expect( yScale.domain ).toEqual( [ 0, 989 ] );
2659
+ } );
2660
+
2661
+ it( 'renders every bar of steady traffic at most a few percent shorter than the tallest', () => {
2662
+ render( <BarChart data={ steadyTrafficData } width={ 400 } height={ 300 } /> );
2663
+ const heights = Array.from( getBarRects() ).map( bar =>
2664
+ parseFloat( bar.getAttribute( 'height' ) || '0' )
2665
+ );
2666
+ expect( heights ).toHaveLength( 7 );
2667
+ const tallest = Math.max( ...heights );
2668
+ expect( Math.min( ...heights ) / tallest ).toBeGreaterThan( 0.9 );
2669
+ } );
2670
+ } );
2671
+
2068
2672
  describe( 'Comparison Series', () => {
2069
2673
  it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
2070
2674
  const data = [