@automattic/charts 4.1.1 → 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 (97) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +1335 -348
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +30 -1
  6. package/dist/index.d.cts +129 -8
  7. package/dist/index.d.ts +129 -8
  8. package/dist/index.js +1336 -351
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +42 -10
  12. package/src/charts/area-chart/private/overlays.tsx +1 -2
  13. package/src/charts/area-chart/private/validate-data.ts +2 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +72 -1
  15. package/src/charts/bar-chart/bar-chart.tsx +137 -70
  16. package/src/charts/bar-chart/index.ts +1 -1
  17. package/src/charts/bar-chart/private/band-highlight.tsx +87 -0
  18. package/src/charts/bar-chart/private/band-scale.ts +13 -0
  19. package/src/charts/bar-chart/private/band-tooltip.tsx +138 -0
  20. package/src/charts/bar-chart/private/comparison-bars.tsx +5 -10
  21. package/src/charts/bar-chart/private/index.ts +3 -0
  22. package/src/charts/bar-chart/private/test/band-highlight.test.tsx +135 -0
  23. package/src/charts/bar-chart/private/test/band-tooltip.test.ts +26 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +100 -0
  25. package/src/charts/bar-chart/private/truncated-tick-component.tsx +1 -1
  26. package/src/charts/bar-chart/private/use-bar-chart-options.ts +13 -40
  27. package/src/charts/bar-chart/private/value-domain.ts +90 -0
  28. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +11 -23
  29. package/src/charts/bar-chart/test/bar-chart.test.tsx +1058 -19
  30. package/src/charts/bar-chart/types.ts +39 -0
  31. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -3
  32. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +1 -1
  33. package/src/charts/conversion-funnel-chart/types.ts +4 -2
  34. package/src/charts/geo-chart/types.ts +4 -2
  35. package/src/charts/heatmap-chart/build-calendar-data.ts +5 -11
  36. package/src/charts/heatmap-chart/build-month-calendar-data.ts +124 -0
  37. package/src/charts/heatmap-chart/heatmap-chart.module.scss +32 -0
  38. package/src/charts/heatmap-chart/heatmap-chart.tsx +191 -114
  39. package/src/charts/heatmap-chart/index.ts +6 -0
  40. package/src/charts/heatmap-chart/private/civil-day.ts +26 -0
  41. package/src/charts/heatmap-chart/private/column-groups.ts +71 -0
  42. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +19 -3
  43. package/src/charts/heatmap-chart/private/index.ts +2 -0
  44. package/src/charts/heatmap-chart/private/keyboard-navigation.ts +201 -0
  45. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +36 -0
  46. package/src/charts/heatmap-chart/test/build-month-calendar-data.test.ts +194 -0
  47. package/src/charts/heatmap-chart/test/column-groups.test.ts +83 -0
  48. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +428 -2
  49. package/src/charts/heatmap-chart/test/keyboard-navigation.test.ts +25 -0
  50. package/src/charts/heatmap-chart/test/use-month-calendar-heatmap-data.test.tsx +90 -0
  51. package/src/charts/heatmap-chart/test/zoned-month-calendar-data.test.ts +23 -0
  52. package/src/charts/heatmap-chart/types.ts +59 -3
  53. package/src/charts/heatmap-chart/use-month-calendar-heatmap-data.ts +34 -0
  54. package/src/charts/leaderboard-chart/types.ts +13 -14
  55. package/src/charts/line-chart/line-chart.tsx +149 -36
  56. package/src/charts/line-chart/private/index.ts +1 -0
  57. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  58. package/src/charts/line-chart/private/line-chart-glyph.tsx +17 -3
  59. package/src/charts/line-chart/private/nearest-pointer-events.tsx +155 -0
  60. package/src/charts/line-chart/test/line-chart.test.tsx +604 -9
  61. package/src/charts/line-chart/types.ts +4 -3
  62. package/src/charts/private/readings.ts +34 -0
  63. package/src/charts/private/test/readings.test.ts +29 -0
  64. package/src/charts/private/test/whole-number-ticks.test.tsx +57 -0
  65. package/src/charts/private/time-axis-options.ts +1 -1
  66. package/src/charts/private/whole-number-ticks.tsx +79 -0
  67. package/src/charts/private/with-responsive/with-responsive.tsx +2 -2
  68. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +1 -3
  69. package/src/charts/private/x-zoom/x-zoom.tsx +1 -2
  70. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +124 -0
  71. package/src/components/legend/hooks/use-chart-legend-items.ts +60 -2
  72. package/src/components/legend/private/base-legend.tsx +37 -22
  73. package/src/components/legend/test/legend.test.tsx +84 -0
  74. package/src/components/legend/types.ts +2 -0
  75. package/src/components/legend/utils/index.ts +0 -1
  76. package/src/components/tooltip/accessible-tooltip.tsx +128 -41
  77. package/src/components/tooltip/base-tooltip.module.scss +6 -1
  78. package/src/components/tooltip/private/bounded-tooltip.tsx +12 -6
  79. package/src/components/tooltip/test/accessible-tooltip.test.tsx +128 -0
  80. package/src/components/tooltip/test/bounded-tooltip.test.tsx +17 -0
  81. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +7 -0
  82. package/src/components/tooltip/xy-chart-tooltip.tsx +9 -10
  83. package/src/hooks/test/use-chart-margin.test.tsx +35 -0
  84. package/src/hooks/test/use-element-size.test.tsx +13 -13
  85. package/src/hooks/use-chart-margin.tsx +7 -2
  86. package/src/hooks/use-xychart-theme.ts +1 -1
  87. package/src/index.ts +7 -1
  88. package/src/providers/chart-context/global-charts-provider.tsx +1 -1
  89. package/src/providers/chart-context/test/chart-context.test.tsx +4 -4
  90. package/src/providers/chart-context/themes.ts +1 -0
  91. package/src/styles/test/chart-scope.test.ts +1 -1
  92. package/src/types.ts +14 -13
  93. package/src/utils/format-metric-value.ts +4 -4
  94. package/src/utils/get-edge-tick-widths.ts +1 -1
  95. package/src/utils/get-styles.ts +1 -1
  96. package/src/visx/types.ts +7 -10
  97. package/src/components/legend/utils/label-transform-factory.ts +0 -24
@@ -1,6 +1,7 @@
1
1
  import { render, screen, within } from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import { GlobalChartsProvider } from '../../../providers';
4
+ import { buildMonthCalendarHeatmapData } from '../build-month-calendar-data';
4
5
  import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart';
5
6
  import type { HeatmapColumn } from '../types';
6
7
 
@@ -84,6 +85,45 @@ describe( 'HeatmapChart', () => {
84
85
  await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
85
86
  } );
86
87
 
88
+ test( 'draws the light tooltip box by default', async () => {
89
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
90
+ await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
91
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
92
+ const box = screen.getByTestId( 'bounded-tooltip' );
93
+ expect( box ).not.toHaveClass( 'surface' );
94
+ expect( box ).toHaveStyle( { backgroundColor: 'rgb(255, 255, 255)' } );
95
+ expect( box ).toHaveStyle( { zIndex: 3 } );
96
+ } );
97
+
98
+ test( 'draws the dark variant on the package tooltip surface, not the visx box', async () => {
99
+ renderChart( {
100
+ withTooltips: true,
101
+ tooltipVariant: 'dark',
102
+ rowLabels: [ 'Mon', 'Tue', 'Wed' ],
103
+ } );
104
+ await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
105
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
106
+ const box = screen.getByTestId( 'bounded-tooltip' );
107
+ expect( box ).toHaveClass( 'surface' );
108
+ expect( box ).not.toHaveStyle( { backgroundColor: 'rgb(255, 255, 255)' } );
109
+ expect( box ).not.toHaveStyle( { color: 'rgb(102, 102, 102)' } );
110
+ } );
111
+
112
+ test( 'merges tooltipStyle over the variant box styles', async () => {
113
+ renderChart( {
114
+ withTooltips: true,
115
+ tooltipVariant: 'dark',
116
+ tooltipStyle: { padding: '2px 4px' },
117
+ rowLabels: [ 'Mon', 'Tue', 'Wed' ],
118
+ } );
119
+ await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
120
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
121
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
122
+ padding: '2px 4px',
123
+ zIndex: 3,
124
+ } );
125
+ } );
126
+
87
127
  test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => {
88
128
  renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
89
129
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -115,6 +155,35 @@ describe( 'HeatmapChart', () => {
115
155
  expect( screen.getByText( /more/i ) ).toBeInTheDocument();
116
156
  } );
117
157
 
158
+ test( 'draws the legend as separate swatches by default and as one bar on request', () => {
159
+ const { rerender } = render(
160
+ <GlobalChartsProvider>
161
+ <HeatmapChart width={ 500 } height={ 300 } data={ data }>
162
+ <HeatmapChart.Legend />
163
+ </HeatmapChart>
164
+ </GlobalChartsProvider>
165
+ );
166
+ const scale = screen.getByTestId( 'heatmap-legend-scale' );
167
+ expect( scale ).not.toHaveClass( 'heatmap-chart__legend-scale--bar' );
168
+ expect( within( scale ).getAllByTestId( 'heatmap-legend-swatch' ) ).toHaveLength( 5 );
169
+
170
+ rerender(
171
+ <GlobalChartsProvider>
172
+ <HeatmapChart width={ 500 } height={ 300 } data={ data }>
173
+ <HeatmapChart.Legend variant="bar" steps={ 4 } />
174
+ </HeatmapChart>
175
+ </GlobalChartsProvider>
176
+ );
177
+ expect( screen.getByTestId( 'heatmap-legend-scale' ) ).toHaveClass(
178
+ 'heatmap-chart__legend-scale--bar'
179
+ );
180
+ expect(
181
+ within( screen.getByTestId( 'heatmap-legend-scale' ) ).getAllByTestId(
182
+ 'heatmap-legend-swatch'
183
+ )
184
+ ).toHaveLength( 4 );
185
+ } );
186
+
118
187
  test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
119
188
  renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
120
189
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -425,12 +494,15 @@ describe( 'HeatmapChart keyboard tooltip', () => {
425
494
  } );
426
495
 
427
496
  describe( 'HeatmapChart tooltip position', () => {
497
+ // Restore only this spy: `restoreAllMocks` would also unspy jest-console, and
498
+ // every later `toHaveWarned` in the file would find no spy.
499
+ let rects: jest.SpyInstance;
428
500
  afterEach( () => {
429
- jest.restoreAllMocks();
501
+ rects.mockRestore();
430
502
  } );
431
503
 
432
504
  test( 'places the box relative to the chart root, at the pointer plus the offsets', async () => {
433
- mockRects();
505
+ rects = mockRects();
434
506
  renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
435
507
 
436
508
  await userEvent.setup().pointer( {
@@ -523,3 +595,357 @@ describe( 'HeatmapChart summary column', () => {
523
595
  expect( within( screen.getByRole( 'tooltip' ) ).getByText( '400' ) ).toBeInTheDocument();
524
596
  } );
525
597
  } );
598
+
599
+ describe( 'HeatmapChart grid placement', () => {
600
+ test( 'omits the column-label row when no column has a label', () => {
601
+ renderChart( { data: data.map( column => ( { ...column, label: undefined } ) ) } );
602
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
603
+ expect( grid.style.gridTemplateRows.startsWith( 'auto' ) ).toBe( false );
604
+ // Three data rows, no header row.
605
+ expect( within( grid ).getAllByRole( 'row', { hidden: true } ) ).toHaveLength( 3 );
606
+ } );
607
+
608
+ test( 'keeps the column-label row when a column has a label', () => {
609
+ renderChart();
610
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
611
+ expect( grid.style.gridTemplateRows.startsWith( 'auto ' ) ).toBe( true );
612
+ expect( within( grid ).getAllByRole( 'row', { hidden: true } ) ).toHaveLength( 4 );
613
+ } );
614
+
615
+ test( 'places every cell and label on an explicit track', () => {
616
+ renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
617
+ const cell = screen
618
+ .getAllByTestId( 'heatmap-cell' )
619
+ .find( element => element.dataset.column === '1' && element.dataset.row === '2' );
620
+ // Column 1 sits on track line 3 (after the row-label track); row 2 sits on
621
+ // grid row 4 (after the label row).
622
+ expect( cell?.style.gridColumn ).toBe( '3' );
623
+ expect( cell?.style.gridRow ).toBe( '4' );
624
+ expect( screen.getByText( 'Wed' ) ).toHaveStyle( { gridColumn: '1' } );
625
+ expect( screen.getByText( 'Wed' ) ).toHaveStyle( { gridRow: '4' } );
626
+ expect( screen.getByText( 'W2' ) ).toHaveStyle( { gridColumn: '3' } );
627
+ expect( screen.getByText( 'W2' ) ).toHaveStyle( { gridRow: '1' } );
628
+ } );
629
+ } );
630
+
631
+ describe( 'HeatmapChart column groups', () => {
632
+ const grouped = [
633
+ { data: [ { value: 1 }, { value: 2 } ] },
634
+ { data: [ { value: 3 }, { value: 4 } ] },
635
+ { data: [ { value: 5 }, { value: 6 } ] },
636
+ { data: [ { value: 7 }, { value: 8 } ] },
637
+ ];
638
+ const columnGroups = [
639
+ { label: 'Jan', span: 2 },
640
+ { label: 'Feb', span: 2 },
641
+ ];
642
+
643
+ test( 'draws one label per group beneath the grid, outside the accessibility tree', () => {
644
+ renderChart( { data: grouped, columnGroups } );
645
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
646
+ const labels = within( grid ).getAllByTestId( 'heatmap-group-label' );
647
+ expect( labels.map( label => label.textContent ) ).toEqual( [ 'Jan', 'Feb' ] );
648
+ const rows = within( grid ).getAllByRole( 'row', { hidden: true } );
649
+ const labelRow = rows[ rows.length - 1 ];
650
+ expect( labelRow ).toHaveAttribute( 'aria-hidden', 'true' );
651
+ expect( within( labelRow ).getAllByTestId( 'heatmap-group-label' ) ).toHaveLength( 2 );
652
+ // Two data rows plus the label row; no header row (no column labels).
653
+ expect( grid ).toHaveStyle( { gridTemplateRows: 'repeat(2, minmax(0px, 1fr)) auto' } );
654
+ } );
655
+
656
+ test( 'spans each label over its group and skips the gap track between groups', () => {
657
+ renderChart( { data: grouped, columnGroups } );
658
+ const [ jan, feb ] = screen.getAllByTestId( 'heatmap-group-label' );
659
+ expect( jan ).toHaveStyle( { gridColumn: '2 / span 2' } );
660
+ // Line 4 is the gap track, so Feb starts on 5.
661
+ expect( feb ).toHaveStyle( { gridColumn: '5 / span 2' } );
662
+ expect( feb ).toHaveStyle( { gridRow: '3' } );
663
+ const cell = screen
664
+ .getAllByTestId( 'heatmap-cell' )
665
+ .find( element => element.dataset.column === '2' && element.dataset.row === '0' );
666
+ expect( cell?.style.gridColumn ).toBe( '5' );
667
+ } );
668
+
669
+ test( 'inserts a gap track from the theme groupGap between groups', () => {
670
+ render(
671
+ <GlobalChartsProvider theme={ { heatmapChart: { groupGap: 30 } } }>
672
+ <HeatmapChart width={ 500 } height={ 300 } data={ grouped } columnGroups={ columnGroups } />
673
+ </GlobalChartsProvider>
674
+ );
675
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
676
+ expect( grid ).toHaveStyle( {
677
+ gridTemplateColumns:
678
+ 'auto minmax(0px, 1fr) minmax(0px, 1fr) 30px minmax(0px, 1fr) minmax(0px, 1fr)',
679
+ } );
680
+ } );
681
+
682
+ test( 'lets the gaps share the leftover width in compact mode, down to groupGap', () => {
683
+ renderChart( { data: grouped, columnGroups, compact: true } );
684
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
685
+ expect( grid.style.gridTemplateColumns ).toContain( 'minmax(24px, 1fr)' );
686
+ expect( grid.className ).toMatch( /flex-gaps/ );
687
+ } );
688
+
689
+ test( 'still counts only data columns for assistive technology', () => {
690
+ renderChart( { data: grouped, columnGroups } );
691
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toHaveAttribute(
692
+ 'aria-colcount',
693
+ '4'
694
+ );
695
+ } );
696
+
697
+ test( 'draws no group row and warns once on an unusable span', () => {
698
+ renderChart( { data: grouped, columnGroups: [ { label: 'Jan', span: 5 } ] } );
699
+ expect( screen.queryByTestId( 'heatmap-group-label' ) ).not.toBeInTheDocument();
700
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toHaveStyle( {
701
+ gridTemplateRows: 'repeat(2, minmax(0px, 1fr))',
702
+ } );
703
+ expect( console ).toHaveWarned();
704
+ } );
705
+
706
+ test( 'keyboard navigation crosses a group boundary', async () => {
707
+ renderChart( { data: grouped, columnGroups } );
708
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
709
+ const user = userEvent.setup();
710
+ grid.focus();
711
+ await user.keyboard( '{ArrowRight}{ArrowRight}{ArrowRight}' );
712
+ expect( grid ).toHaveAttribute(
713
+ 'aria-activedescendant',
714
+ expect.stringMatching( /-cell-2-0$/ )
715
+ );
716
+ } );
717
+
718
+ test( 'names a cell by its group, column and row when it has no label of its own', () => {
719
+ renderChart( {
720
+ data: grouped.map( ( column, index ) => ( { ...column, label: `W${ index + 1 }` } ) ),
721
+ columnGroups,
722
+ rowLabels: [ 'Mon', 'Tue' ],
723
+ } );
724
+ expect( screen.getByRole( 'gridcell', { name: 'Jan W1 Mon: 1' } ) ).toBeInTheDocument();
725
+ expect( screen.getByRole( 'gridcell', { name: 'Feb W3 Tue: 6' } ) ).toBeInTheDocument();
726
+ } );
727
+
728
+ test( 'leaves an ungrouped tail and a labelled cell out of the group name', () => {
729
+ renderChart( {
730
+ data: [ ...grouped, { data: [ { value: 9, label: 'Total Mon' }, { value: 10 } ] } ],
731
+ columnGroups,
732
+ rowLabels: [ 'Mon', 'Tue' ],
733
+ } );
734
+ expect( screen.getByRole( 'gridcell', { name: 'Total Mon: 9' } ) ).toBeInTheDocument();
735
+ expect( screen.getByRole( 'gridcell', { name: 'Tue: 10' } ) ).toBeInTheDocument();
736
+ } );
737
+
738
+ test( 'keeps column labels on the first row and group labels on the last with both set', () => {
739
+ renderChart( {
740
+ data: grouped.map( ( column, index ) => ( { ...column, label: `W${ index + 1 }` } ) ),
741
+ columnGroups,
742
+ } );
743
+ expect( screen.getByText( 'W3' ) ).toHaveStyle( { gridColumn: '5', gridRow: '1' } );
744
+ expect( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] ).toHaveStyle( { gridRow: '2' } );
745
+ const [ jan ] = screen.getAllByTestId( 'heatmap-group-label' );
746
+ expect( jan ).toHaveStyle( { gridRow: '4' } );
747
+ } );
748
+ } );
749
+
750
+ describe( 'HeatmapChart calendar navigation', () => {
751
+ const calendar = ( range: { start: string; end: string }, days: string[] ) =>
752
+ buildMonthCalendarHeatmapData(
753
+ Object.fromEntries( days.map( ( day, index ) => [ day, index + 1 ] ) ),
754
+ range,
755
+ { locale: 'en-US' }
756
+ );
757
+
758
+ const mountCalendar = ( result: ReturnType< typeof calendar > ) => {
759
+ renderChart( { ...result, keyboardNavigation: 'calendar' } );
760
+ return screen.getByRole( 'grid', { name: /heatmap/i } );
761
+ };
762
+
763
+ const selectedName = ( grid: HTMLElement ) =>
764
+ within( grid )
765
+ .getAllByRole( 'gridcell' )
766
+ .find( cell => cell.id === grid.getAttribute( 'aria-activedescendant' ) )
767
+ ?.getAttribute( 'aria-label' );
768
+
769
+ // Mon Jan 29 to Fri Feb 2 2024: the range crosses from row 4 of January into row 0 of February.
770
+ const boundary = calendar( { start: '2024-01-29', end: '2024-02-02' }, [
771
+ '2024-01-29',
772
+ '2024-01-30',
773
+ '2024-01-31',
774
+ '2024-02-01',
775
+ '2024-02-02',
776
+ ] );
777
+
778
+ test( 'places hidden and placeholder slots on the grid like measured cells', () => {
779
+ mountCalendar( boundary );
780
+ // Column 1 is the row-label track; February starts one gap track after January's 7 columns.
781
+ expect( screen.getByRole( 'gridcell', { name: /Jan 29/ } ) ).toHaveStyle( {
782
+ gridColumn: '2',
783
+ gridRow: '5',
784
+ } );
785
+ expect( screen.getByRole( 'gridcell', { name: /Feb 1/ } ) ).toHaveStyle( {
786
+ gridColumn: '13',
787
+ gridRow: '1',
788
+ } );
789
+ const hidden = screen.getAllByTestId( 'heatmap-cell-hidden' );
790
+ const placeholders = screen.getAllByTestId( 'heatmap-cell-placeholder' );
791
+ // January: Jan 1 is a Monday, so its 31 days fill 5 rows; 11 slots are hidden.
792
+ expect( hidden ).toHaveLength( 11 + 42 - 29 );
793
+ expect( placeholders ).toHaveLength( 31 - 3 + 29 - 2 );
794
+ // Rows render in order, so the first hidden slot is February's Mon Jan 29.
795
+ expect( hidden[ 0 ] ).toHaveStyle( { gridColumn: '10', gridRow: '1' } );
796
+ expect( placeholders[ 0 ] ).toHaveStyle( { gridColumn: '2', gridRow: '1' } ); // Mon Jan 1
797
+ } );
798
+
799
+ test( 'reaches every measured day across a month boundary with Right and Left', async () => {
800
+ const grid = mountCalendar( boundary );
801
+ const user = userEvent.setup();
802
+ grid.focus();
803
+ const visited: ( string | null | undefined )[] = [];
804
+ for ( let press = 0; press < 5; press++ ) {
805
+ await user.keyboard( '{ArrowRight}' );
806
+ visited.push( selectedName( grid ) );
807
+ }
808
+ expect( visited ).toEqual( [
809
+ 'Mon, Jan 29, 2024: 1',
810
+ 'Tue, Jan 30, 2024: 2',
811
+ 'Wed, Jan 31, 2024: 3',
812
+ 'Thu, Feb 1, 2024: 4',
813
+ 'Fri, Feb 2, 2024: 5',
814
+ ] );
815
+ // The end of the range holds the selection.
816
+ await user.keyboard( '{ArrowRight}' );
817
+ expect( selectedName( grid ) ).toBe( 'Fri, Feb 2, 2024: 5' );
818
+ await user.keyboard( '{ArrowLeft}{ArrowLeft}' );
819
+ expect( selectedName( grid ) ).toBe( 'Wed, Jan 31, 2024: 3' );
820
+ } );
821
+
822
+ test( 'steps a week with Down and Up, into the neighboring month past its edge', async () => {
823
+ const grid = mountCalendar(
824
+ calendar( { start: '2024-01-22', end: '2024-02-12' }, [ '2024-01-29', '2024-02-05' ] )
825
+ );
826
+ const user = userEvent.setup();
827
+ grid.focus();
828
+ await user.keyboard( '{ArrowRight}' ); // Mon Jan 22
829
+ await user.keyboard( '{ArrowDown}' );
830
+ expect( selectedName( grid ) ).toBe( 'Mon, Jan 29, 2024: 1' );
831
+ await user.keyboard( '{ArrowDown}' );
832
+ expect( selectedName( grid ) ).toBe( 'Mon, Feb 5, 2024: 2' );
833
+ await user.keyboard( '{ArrowDown}' );
834
+ expect( selectedName( grid ) ).toBe( 'Mon, Feb 12, 2024: No data' );
835
+ await user.keyboard( '{ArrowUp}{ArrowUp}' );
836
+ expect( selectedName( grid ) ).toBe( 'Mon, Jan 29, 2024: 1' );
837
+ } );
838
+
839
+ test( 'jumps a month with Page Down and Page Up, keeping the slot or taking the nearest day', async () => {
840
+ const grid = mountCalendar( calendar( { start: '2024-01-01', end: '2024-03-31' }, [] ) );
841
+ const user = userEvent.setup();
842
+ grid.focus();
843
+ await user.keyboard( '{ArrowRight}' ); // Mon Jan 1
844
+ await user.keyboard( '{PageDown}' );
845
+ // February's first-week Monday slot is Jan 29, hidden there, so the nearest day after it.
846
+ expect( selectedName( grid ) ).toBe( 'Thu, Feb 1, 2024: No data' );
847
+ await user.keyboard( '{PageDown}' );
848
+ expect( selectedName( grid ) ).toBe( 'Fri, Mar 1, 2024: No data' );
849
+ // Back up, the same first-week Friday slot holds a day in both months.
850
+ await user.keyboard( '{PageUp}' );
851
+ expect( selectedName( grid ) ).toBe( 'Fri, Feb 2, 2024: No data' );
852
+ await user.keyboard( '{PageUp}' );
853
+ expect( selectedName( grid ) ).toBe( 'Fri, Jan 5, 2024: No data' );
854
+ await user.keyboard( '{PageUp}' );
855
+ expect( selectedName( grid ) ).toBe( 'Fri, Jan 5, 2024: No data' );
856
+ } );
857
+
858
+ test( 'starts on the first measured day rather than the first Monday', async () => {
859
+ const grid = mountCalendar( calendar( { start: '2024-02-01', end: '2024-02-29' }, [] ) );
860
+ const user = userEvent.setup();
861
+ grid.focus();
862
+ await user.keyboard( '{ArrowRight}' );
863
+ expect( selectedName( grid ) ).toBe( 'Thu, Feb 1, 2024: No data' );
864
+ } );
865
+
866
+ test( 'drops a selection the data no longer holds instead of navigating from it', async () => {
867
+ const two = calendar( { start: '2024-01-01', end: '2024-02-29' }, [] );
868
+ const one = calendar( { start: '2024-01-01', end: '2024-01-31' }, [] );
869
+ const chart = ( result: typeof two ) => (
870
+ <GlobalChartsProvider>
871
+ <HeatmapChart width={ 500 } height={ 300 } { ...result } keyboardNavigation="calendar" />
872
+ </GlobalChartsProvider>
873
+ );
874
+ const { rerender } = render( chart( two ) );
875
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
876
+ const user = userEvent.setup();
877
+ grid.focus();
878
+ await user.keyboard( '{ArrowRight}{PageDown}' );
879
+ expect( selectedName( grid ) ).toBe( 'Thu, Feb 1, 2024: No data' );
880
+
881
+ rerender( chart( one ) );
882
+ expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
883
+ await user.keyboard( '{ArrowDown}' );
884
+ expect( selectedName( grid ) ).toBe( 'Mon, Jan 1, 2024: No data' );
885
+ } );
886
+
887
+ test( 'ignores Page keys in grid navigation', async () => {
888
+ renderChart( { data } );
889
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
890
+ const user = userEvent.setup();
891
+ grid.focus();
892
+ await user.keyboard( '{PageDown}' );
893
+ expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
894
+ } );
895
+ } );
896
+
897
+ describe( 'HeatmapChart naming and focus', () => {
898
+ test( 'names the grid from ariaLabel when given', () => {
899
+ renderChart( { ariaLabel: 'Monthly posting activity' } );
900
+ expect( screen.getByRole( 'grid', { name: 'Monthly posting activity' } ) ).toBeInTheDocument();
901
+ expect( screen.queryByRole( 'grid', { name: /heatmap chart/i } ) ).not.toBeInTheDocument();
902
+ } );
903
+
904
+ test( 'scrolls the keyboard-selected cell into view', async () => {
905
+ // jsdom has no scrollIntoView; the assertion is that the chart asks for it.
906
+ const scrollIntoView = jest.fn();
907
+ Element.prototype.scrollIntoView = scrollIntoView;
908
+ try {
909
+ renderChart();
910
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
911
+ const user = userEvent.setup();
912
+ grid.focus();
913
+ await user.keyboard( '{ArrowRight}' );
914
+ expect( scrollIntoView ).toHaveBeenLastCalledWith( { block: 'nearest', inline: 'nearest' } );
915
+ expect( ( scrollIntoView.mock.instances[ 0 ] as Element ).id ).toBe(
916
+ grid.getAttribute( 'aria-activedescendant' )
917
+ );
918
+ } finally {
919
+ // Back to jsdom's default, which has no scrollIntoView.
920
+ delete ( Element.prototype as Partial< Element > ).scrollIntoView;
921
+ }
922
+ } );
923
+
924
+ test( 'does not scroll again when the data is replaced but the selection stays', async () => {
925
+ const scrollIntoView = jest.fn();
926
+ Element.prototype.scrollIntoView = scrollIntoView;
927
+ try {
928
+ const { rerender } = renderChart();
929
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
930
+ const user = userEvent.setup();
931
+ grid.focus();
932
+ await user.keyboard( '{ArrowRight}' );
933
+ expect( scrollIntoView ).toHaveBeenCalledTimes( 1 );
934
+
935
+ rerender(
936
+ <GlobalChartsProvider>
937
+ <HeatmapChart width={ 500 } height={ 300 } data={ [ ...data ] } />
938
+ </GlobalChartsProvider>
939
+ );
940
+ expect( scrollIntoView ).toHaveBeenCalledTimes( 1 );
941
+ } finally {
942
+ delete ( Element.prototype as Partial< Element > ).scrollIntoView;
943
+ }
944
+ } );
945
+
946
+ test( 'renders the empty state without a warning when static groups await data', () => {
947
+ renderChart( { data: [], columnGroups: [ { label: 'Q1', span: 3 } ] } );
948
+ expect( screen.getByText( 'No data available' ) ).toBeInTheDocument();
949
+ expect( console ).not.toHaveWarned();
950
+ } );
951
+ } );
@@ -0,0 +1,25 @@
1
+ import { stepCalendarCell, stepGridCell } from '../private/keyboard-navigation';
2
+ import type { NavigationKey } from '../private/keyboard-navigation';
3
+
4
+ describe( 'keyboard navigation with a stale selection', () => {
5
+ // Two 7-column month blocks shrank to one while the selection stayed in the second.
6
+ const grid = { columns: 7, rows: 6, isInert: () => false };
7
+ const blocks = [ { start: 0, end: 7 } ];
8
+ const stale = { column: 10, row: 0 };
9
+ const keys: NavigationKey[] = [
10
+ 'ArrowLeft',
11
+ 'ArrowRight',
12
+ 'ArrowUp',
13
+ 'ArrowDown',
14
+ 'PageUp',
15
+ 'PageDown',
16
+ ];
17
+
18
+ test.each( keys )( 'calendar %s keeps the selection instead of throwing', key => {
19
+ expect( stepCalendarCell( grid, blocks, stale, key ) ).toBeUndefined();
20
+ } );
21
+
22
+ test.each( keys )( 'grid %s keeps the selection', key => {
23
+ expect( stepGridCell( grid, stale, key ) ).toBeUndefined();
24
+ } );
25
+ } );
@@ -0,0 +1,90 @@
1
+ import { render } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../../providers';
3
+ import { useMonthCalendarHeatmapData } from '../use-month-calendar-heatmap-data';
4
+ import type { MonthCalendarHeatmapOptions, MonthCalendarHeatmapResult } from '../types';
5
+
6
+ const range = { start: '2026-08-01', end: '2026-09-14' };
7
+ const valueByDay = { '2026-08-03': 4 };
8
+
9
+ // Read from Intl rather than spelled out: short month names drift between ICU builds.
10
+ const monthLabels = ( locale: string ) => {
11
+ const format = new Intl.DateTimeFormat( locale, {
12
+ month: 'short',
13
+ calendar: 'gregory',
14
+ timeZone: 'UTC',
15
+ } );
16
+ return [
17
+ format.format( new Date( '2026-08-01T00:00:00Z' ) ),
18
+ format.format( new Date( '2026-09-01T00:00:00Z' ) ),
19
+ ];
20
+ };
21
+
22
+ describe( 'useMonthCalendarHeatmapData', () => {
23
+ let result: MonthCalendarHeatmapResult;
24
+ let renders = 0;
25
+
26
+ const Calendar = ( {
27
+ options,
28
+ end = range.end,
29
+ }: {
30
+ options?: MonthCalendarHeatmapOptions;
31
+ end?: string;
32
+ } ) => {
33
+ result = useMonthCalendarHeatmapData( valueByDay, { start: range.start, end }, options );
34
+ renders++;
35
+ return null;
36
+ };
37
+
38
+ beforeEach( () => {
39
+ renders = 0;
40
+ } );
41
+
42
+ it( "takes the provider's locale", () => {
43
+ render(
44
+ <GlobalChartsProvider locale="de-DE">
45
+ <Calendar />
46
+ </GlobalChartsProvider>
47
+ );
48
+ expect( result.columnGroups.map( group => group.label ) ).toEqual( monthLabels( 'de-DE' ) );
49
+ } );
50
+
51
+ it( 'lets an explicit locale win over the provider', () => {
52
+ render(
53
+ <GlobalChartsProvider locale="de-DE">
54
+ <Calendar options={ { locale: 'fr-FR' } } />
55
+ </GlobalChartsProvider>
56
+ );
57
+ expect( result.columnGroups.map( group => group.label ) ).toEqual( monthLabels( 'fr-FR' ) );
58
+ } );
59
+
60
+ it( 'keeps the same result across a rebuilt options object', () => {
61
+ const { rerender } = render(
62
+ <GlobalChartsProvider>
63
+ <Calendar options={ { weekStartsOn: 1 } } />
64
+ </GlobalChartsProvider>
65
+ );
66
+ const first = result;
67
+ rerender(
68
+ <GlobalChartsProvider>
69
+ <Calendar options={ { weekStartsOn: 1 } } />
70
+ </GlobalChartsProvider>
71
+ );
72
+ expect( renders ).toBeGreaterThan( 1 );
73
+ expect( result ).toBe( first );
74
+ } );
75
+
76
+ it( 'rebuilds when the range changes', () => {
77
+ const { rerender } = render(
78
+ <GlobalChartsProvider>
79
+ <Calendar />
80
+ </GlobalChartsProvider>
81
+ );
82
+ expect( result.columnGroups ).toHaveLength( 2 );
83
+ rerender(
84
+ <GlobalChartsProvider>
85
+ <Calendar end="2026-10-01" />
86
+ </GlobalChartsProvider>
87
+ );
88
+ expect( result.columnGroups ).toHaveLength( 3 );
89
+ } );
90
+ } );
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-chatham.mjs
3
+ */
4
+
5
+ // Chatham runs ahead of UTC, so a local-time setter would land the grid a day off.
6
+ import { buildMonthCalendarHeatmapData } from '../build-month-calendar-data';
7
+
8
+ const range = { start: '2026-08-01', end: '2026-09-14' };
9
+
10
+ describe( 'buildMonthCalendarHeatmapData in a zone ahead of UTC', () => {
11
+ test( 'keys the grid on civil days, not the runtime zone', () => {
12
+ const { data, columnGroups } = buildMonthCalendarHeatmapData(
13
+ { '2026-08-01': 1, '2026-09-14': 2 },
14
+ range,
15
+ { locale: 'en-US' }
16
+ );
17
+ expect( columnGroups.map( group => group.label ) ).toEqual( [ 'Aug', 'Sep' ] );
18
+ // Sat Aug 1 2026: block 0, weekday 5, week 0. Mon Sep 14: block 1, weekday 0, week 2.
19
+ expect( data[ 5 ].data[ 0 ] ).toMatchObject( { label: 'Sat, Aug 1, 2026', value: 1 } );
20
+ expect( data[ 7 ].data[ 2 ] ).toMatchObject( { label: 'Mon, Sep 14, 2026', value: 2 } );
21
+ expect( data[ 7 + 1 ].data[ 2 ].placeholder ).toBe( true ); // Tue Sep 15
22
+ } );
23
+ } );