@automattic/charts 3.2.0 → 4.1.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 (135) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/SECURITY.md +1 -0
  3. package/TOKENS.md +53 -58
  4. package/dist/index.cjs +1483 -664
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.css +93 -29
  7. package/dist/index.d.cts +233 -247
  8. package/dist/index.d.ts +233 -247
  9. package/dist/index.js +1484 -667
  10. package/dist/index.js.map +1 -1
  11. package/package.json +7 -6
  12. package/src/charts/area-chart/area-chart.tsx +39 -25
  13. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  14. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  15. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  16. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  17. package/src/charts/area-chart/types.ts +4 -7
  18. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  19. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  20. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  21. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  23. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  24. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  25. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  26. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  27. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  28. package/src/charts/heatmap-chart/heatmap-chart.module.scss +52 -16
  29. package/src/charts/heatmap-chart/heatmap-chart.tsx +89 -37
  30. package/src/charts/heatmap-chart/index.ts +2 -1
  31. package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -89
  32. package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/private/index.ts +1 -1
  35. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
  36. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
  37. package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
  38. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +142 -12
  39. package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
  40. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
  41. package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
  42. package/src/charts/heatmap-chart/types.ts +8 -2
  43. package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
  44. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  45. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  46. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  47. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  48. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  49. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  50. package/src/charts/leaderboard-chart/types.ts +2 -2
  51. package/src/charts/line-chart/line-chart.module.scss +10 -18
  52. package/src/charts/line-chart/line-chart.tsx +128 -26
  53. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  54. package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
  55. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  56. package/src/charts/line-chart/test/line-chart.test.tsx +174 -41
  57. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  58. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  59. package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
  60. package/src/charts/line-chart/types.ts +17 -3
  61. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  62. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  63. package/src/charts/pie-chart/test/pie-chart.test.tsx +59 -12
  64. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  65. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  66. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  67. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  68. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  69. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  70. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  71. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  72. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  73. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  74. package/src/charts/private/test/time-axis.test.ts +9 -69
  75. package/src/charts/private/time-axis-options.ts +106 -0
  76. package/src/charts/private/time-axis.ts +318 -136
  77. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  78. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  79. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  80. package/src/charts/sparkline/types.ts +1 -1
  81. package/src/components/legend/private/base-legend.module.scss +9 -5
  82. package/src/components/tooltip/accessible-tooltip.tsx +21 -17
  83. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  84. package/src/components/tooltip/private/bounded-tooltip.tsx +227 -0
  85. package/src/components/tooltip/test/accessible-tooltip.test.tsx +30 -4
  86. package/src/components/tooltip/test/bounded-tooltip.test.tsx +225 -0
  87. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +379 -0
  88. package/src/components/tooltip/xy-chart-tooltip.tsx +326 -0
  89. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  90. package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
  91. package/src/hooks/test/use-chart-margin.test.tsx +173 -3
  92. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  93. package/src/hooks/use-chart-data-transform.ts +10 -3
  94. package/src/hooks/use-chart-margin.tsx +54 -9
  95. package/src/hooks/use-xychart-theme.ts +25 -9
  96. package/src/index.ts +8 -0
  97. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  98. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  99. package/src/providers/chart-context/index.ts +1 -0
  100. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  101. package/src/providers/chart-context/private/series-palette.ts +3 -13
  102. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  103. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  104. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  105. package/src/providers/chart-context/themes.ts +7 -52
  106. package/src/providers/chart-context/types.ts +3 -0
  107. package/src/styles/chart-scope.scss +39 -62
  108. package/src/styles/test/chart-scope.test.ts +19 -47
  109. package/src/test-utils/series-fixtures.ts +31 -0
  110. package/src/test-utils/zoned-parsing-suite.ts +90 -0
  111. package/src/types.ts +79 -166
  112. package/src/utils/bucket-info.ts +87 -0
  113. package/src/utils/color-utils.ts +0 -27
  114. package/src/utils/date-formatting.ts +157 -0
  115. package/src/utils/date-parsing.ts +103 -52
  116. package/src/utils/get-edge-tick-widths.ts +33 -0
  117. package/src/utils/index.ts +4 -0
  118. package/src/utils/test/bucket-info.test.ts +185 -0
  119. package/src/utils/test/color-utils.test.ts +1 -48
  120. package/src/utils/test/date-formatting.test.ts +89 -0
  121. package/src/utils/test/date-parsing.auckland.test.ts +6 -0
  122. package/src/utils/test/date-parsing.santiago.test.ts +6 -0
  123. package/src/utils/test/date-parsing.test.ts +16 -1
  124. package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
  125. package/src/utils/test/get-styles.test.ts +10 -7
  126. package/src/utils/test/merge-themes.test.ts +18 -46
  127. package/src/utils/warn-once.ts +23 -0
  128. package/src/visx/types.ts +56 -5
  129. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  130. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  131. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  132. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  133. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  134. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  135. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -352,24 +352,21 @@ describe( 'HeatmapChart', () => {
352
352
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
353
353
  } );
354
354
 
355
- test( 'resolves primaryColor from the chart theme', () => {
356
- render(
357
- <GlobalChartsProvider theme={ { heatmapChart: { primaryColor: '#0a0b0c' } } }>
358
- <HeatmapChart width={ 500 } height={ 300 } data={ data } />
359
- </GlobalChartsProvider>
360
- );
361
- const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
362
- expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
363
- } );
355
+ test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
356
+ const scope = document.createElement( 'div' );
357
+ scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
358
+ document.body.appendChild( scope );
364
359
 
365
- test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => {
366
360
  render(
367
- <GlobalChartsProvider theme={ { colors: [ '#0a0b0c' ] } }>
361
+ <GlobalChartsProvider>
368
362
  <HeatmapChart width={ 500 } height={ 300 } data={ data } />
369
- </GlobalChartsProvider>
363
+ </GlobalChartsProvider>,
364
+ { container: scope }
370
365
  );
371
366
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
372
367
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#0a0b0c' );
368
+
369
+ document.body.removeChild( scope );
373
370
  } );
374
371
 
375
372
  test( 'the unresponsive export pins explicit width and height', () => {
@@ -393,3 +390,136 @@ describe( 'HeatmapChart', () => {
393
390
  expect( chart ).not.toHaveStyle( { height: '300px' } );
394
391
  } );
395
392
  } );
393
+
394
+ // Chart container at (100, 50); the tooltip box measures 120x40. Everything
395
+ // else the charts measure (wrapper, clipping lookups) reports the container.
396
+ const mockRects = () =>
397
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
398
+ this: Element
399
+ ) {
400
+ const box = this.classList.contains( 'visx-tooltip' );
401
+ return {
402
+ left: box ? 0 : 100,
403
+ top: box ? 0 : 50,
404
+ width: box ? 120 : 400,
405
+ height: box ? 40 : 300,
406
+ right: box ? 120 : 500,
407
+ bottom: box ? 40 : 350,
408
+ x: box ? 0 : 100,
409
+ y: box ? 0 : 50,
410
+ toJSON: () => ( {} ),
411
+ } as DOMRect;
412
+ } );
413
+
414
+ describe( 'HeatmapChart keyboard tooltip', () => {
415
+ test( 'opens on the selected cell without row labels', async () => {
416
+ renderChart( { withTooltips: true } );
417
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
418
+ const user = userEvent.setup();
419
+
420
+ grid.focus();
421
+ await user.keyboard( '{ArrowDown}' );
422
+
423
+ expect( within( screen.getByRole( 'tooltip' ) ).getByText( '1' ) ).toBeInTheDocument();
424
+ } );
425
+ } );
426
+
427
+ describe( 'HeatmapChart tooltip position', () => {
428
+ afterEach( () => {
429
+ jest.restoreAllMocks();
430
+ } );
431
+
432
+ test( 'places the box relative to the chart root, at the pointer plus the offsets', async () => {
433
+ mockRects();
434
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
435
+
436
+ await userEvent.setup().pointer( {
437
+ target: screen.getAllByTestId( 'heatmap-cell' )[ 0 ],
438
+ coords: { clientX: 180, clientY: 140 },
439
+ } );
440
+
441
+ // (180, 140) is (80, 90) inside the root; the box adds 10px each way.
442
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
443
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
444
+ transform: 'translate(90px, 100px)',
445
+ } );
446
+ } );
447
+ } );
448
+
449
+ describe( 'HeatmapChart summary column', () => {
450
+ const withTotals: HeatmapColumn[] = [
451
+ ...data,
452
+ { label: 'Total', summary: true, data: [ { value: 400 }, { value: 200 }, { value: null } ] },
453
+ ];
454
+
455
+ test( 'keeps the summary column out of the color scale', () => {
456
+ renderChart( { data: withTotals } );
457
+ // 4 is still the data maximum, so it keeps full intensity.
458
+ expect(
459
+ screen
460
+ .getByRole( 'gridcell', { name: 'W2: 4' } )
461
+ .style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' )
462
+ ).toBe( '1' );
463
+ expect(
464
+ screen
465
+ .getByRole( 'gridcell', { name: 'Total: 400' } )
466
+ .style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' )
467
+ ).toBe( '' );
468
+ } );
469
+
470
+ test( 'draws every summary cell, printing its figure even in compact mode', () => {
471
+ renderChart( { data: withTotals, compact: true } );
472
+ expect( screen.getAllByTestId( 'heatmap-cell-summary' ) ).toHaveLength( 3 );
473
+ expect( screen.getByRole( 'gridcell', { name: 'Total: 400' } ) ).toHaveTextContent( '400' );
474
+ expect( screen.getByRole( 'gridcell', { name: 'Total: No data' } ) ).toBeEmptyDOMElement();
475
+ expect( screen.getByRole( 'grid' ) ).toHaveAttribute( 'aria-colcount', '3' );
476
+ } );
477
+
478
+ test( 'sets a summary apart from the data on either side, but not from another summary', () => {
479
+ renderChart( {
480
+ data: [
481
+ { label: 'Lead', summary: true, data: [ { value: 7 }, { value: 8 }, { value: 9 } ] },
482
+ ...withTotals,
483
+ { label: 'Mean', summary: true, data: [ { value: 2 }, { value: 1 }, { value: 3 } ] },
484
+ ],
485
+ } );
486
+
487
+ const lead = screen.getByRole( 'gridcell', { name: 'Lead: 7' } );
488
+ expect( lead ).toHaveClass( 'heatmap-chart__gap-end' );
489
+ expect( lead ).not.toHaveClass( 'heatmap-chart__gap-start' );
490
+
491
+ const total = screen.getByRole( 'gridcell', { name: 'Total: 400' } );
492
+ expect( total ).toHaveClass( 'heatmap-chart__gap-start' );
493
+ expect( total ).not.toHaveClass( 'heatmap-chart__gap-end' );
494
+
495
+ const mean = screen.getByRole( 'gridcell', { name: 'Mean: 2' } );
496
+ expect( mean ).not.toHaveClass( 'heatmap-chart__gap-start' );
497
+ expect( mean ).not.toHaveClass( 'heatmap-chart__gap-end' );
498
+ expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).not.toHaveClass(
499
+ 'heatmap-chart__gap-start'
500
+ );
501
+ } );
502
+
503
+ test( 'keeps the summary track content-sized when the data tracks are capped', () => {
504
+ renderChart( { data: withTotals, maxCellWidth: 32 } );
505
+
506
+ expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toHaveStyle( {
507
+ gridTemplateColumns: 'auto minmax(0px, 32px) minmax(0px, 32px) minmax(auto, max-content)',
508
+ } );
509
+ } );
510
+
511
+ test( 'reaches the summary column by keyboard, with its tooltip', async () => {
512
+ renderChart( { data: withTotals, withTooltips: true } );
513
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
514
+ const user = userEvent.setup();
515
+
516
+ grid.focus();
517
+ await user.keyboard( '{ArrowDown}{ArrowRight}{ArrowRight}' );
518
+
519
+ expect( grid ).toHaveAttribute(
520
+ 'aria-activedescendant',
521
+ expect.stringMatching( /-cell-2-0$/ )
522
+ );
523
+ expect( within( screen.getByRole( 'tooltip' ) ).getByText( '400' ) ).toBeInTheDocument();
524
+ } );
525
+ } );
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-los-angeles.mjs
3
+ */
4
+
5
+ // Los Angeles runs behind UTC, the half `zoned-calendar-data` cannot cover: a label
6
+ // formatter that stops reading UTC renders the proxy on the previous day only here.
7
+ import { render } from '@testing-library/react';
8
+ import { GlobalChartsProvider } from '../../../providers';
9
+ import { useCalendarHeatmapData } from '../use-calendar-heatmap-data';
10
+ import type { DataPointDate } from '../../../types';
11
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from '../private';
12
+
13
+ // 2026-08-03 08:00 in Asia/Tokyo, 2026-08-02 16:00 in America/Los_Angeles.
14
+ const series: DataPointDate[] = [ { date: new Date( '2026-08-02T23:00:00Z' ), value: 7 } ];
15
+
16
+ // Aug 3 in UTC, Aug 2 in this file's zone, so a bucket tells the two apart.
17
+ const runtimeVsUtc: DataPointDate[] = [ { date: new Date( '2026-08-03T05:00:00Z' ), value: 7 } ];
18
+
19
+ describe( 'useCalendarHeatmapData', () => {
20
+ let result: CalendarHeatmapResult;
21
+ let renders = 0;
22
+
23
+ const Calendar = ( {
24
+ options,
25
+ points = series,
26
+ }: {
27
+ options?: CalendarHeatmapOptions;
28
+ points?: DataPointDate[];
29
+ } ) => {
30
+ result = useCalendarHeatmapData( points, { weekStartsOn: 1, ...options } );
31
+ renders++;
32
+ return null;
33
+ };
34
+
35
+ beforeEach( () => {
36
+ renders = 0;
37
+ } );
38
+
39
+ const bucketedLabels = () =>
40
+ result.data
41
+ .flatMap( column => column.data )
42
+ .filter( cell => cell.value !== null )
43
+ .map( cell => cell.label );
44
+
45
+ it( "takes the provider's locale and time zone", () => {
46
+ render(
47
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
48
+ <Calendar />
49
+ </GlobalChartsProvider>
50
+ );
51
+
52
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
53
+ expect( result.rowLabels ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
54
+ } );
55
+
56
+ it( 'lets an option win over the provider', () => {
57
+ render(
58
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
59
+ <Calendar options={ { timeZone: 'America/Los_Angeles' } } />
60
+ </GlobalChartsProvider>
61
+ );
62
+
63
+ expect( bucketedLabels() ).toEqual( [ 'So., 2. Aug. 2026' ] );
64
+ } );
65
+
66
+ it( 'reads an explicit undefined as unset rather than as an override', () => {
67
+ render(
68
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
69
+ <Calendar options={ { locale: undefined, timeZone: undefined } } />
70
+ </GlobalChartsProvider>
71
+ );
72
+
73
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
74
+ } );
75
+
76
+ it( 'falls back to the runtime with no provider around it', () => {
77
+ render( <Calendar points={ runtimeVsUtc } /> );
78
+
79
+ expect( result.rowLabels ).toEqual( [ 'Mon', '', 'Wed', '', 'Fri', '', '' ] );
80
+ // rowLabels only prove the locale fell back; the bucket proves the zone did.
81
+ expect( bucketedLabels() ).toEqual( [ 'Sun, Aug 2, 2026' ] );
82
+ } );
83
+
84
+ it( 'holds its result across a re-render that rebuilds the options object', () => {
85
+ const { rerender } = render(
86
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
87
+ <Calendar options={ { gridSpan: { start: '2026-07-06' } } } />
88
+ </GlobalChartsProvider>
89
+ );
90
+ const first = result;
91
+
92
+ rerender(
93
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
94
+ <Calendar options={ { gridSpan: { start: '2026-07-06' } } } />
95
+ </GlobalChartsProvider>
96
+ );
97
+
98
+ expect( renders ).toBeGreaterThan( 1 );
99
+ expect( result ).toBe( first );
100
+ } );
101
+
102
+ it( 'rebuilds when the provider changes zone', () => {
103
+ const { rerender } = render(
104
+ <GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
105
+ <Calendar />
106
+ </GlobalChartsProvider>
107
+ );
108
+ expect( bucketedLabels() ).toEqual( [ 'Mo., 3. Aug. 2026' ] );
109
+
110
+ rerender(
111
+ <GlobalChartsProvider locale="de-DE" timeZone="America/Los_Angeles">
112
+ <Calendar />
113
+ </GlobalChartsProvider>
114
+ );
115
+
116
+ expect( bucketedLabels() ).toEqual( [ 'So., 2. Aug. 2026' ] );
117
+ } );
118
+ } );
@@ -11,6 +11,14 @@ describe( 'getValueExtent', () => {
11
11
  expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] );
12
12
  } );
13
13
 
14
+ test( 'leaves summary columns out of the extent', () => {
15
+ const withTotals: HeatmapColumn[] = [
16
+ ...data,
17
+ { label: 'Total', summary: true, data: [ { value: 500 }, { value: 900 }, { value: null } ] },
18
+ ];
19
+ expect( getValueExtent( withTotals ) ).toEqual( [ 0, 20 ] );
20
+ } );
21
+
14
22
  test( 'returns [0, 0] for all-empty data', () => {
15
23
  expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
16
24
  } );
@@ -0,0 +1,248 @@
1
+ /**
2
+ * @jest-environment <rootDir>/tests/environment-chatham.mjs
3
+ */
4
+
5
+ // Chatham runs ahead of UTC. A worker behind it renders a UTC-midnight proxy on
6
+ // its own day, so it cannot tell the grid's UTC arithmetic from local arithmetic.
7
+ import { buildCalendarHeatmapData } from '../private/build-calendar-data';
8
+ import type { DataPointDate } from '../../../types';
9
+
10
+ const labelsCarryingValues = ( data: ReturnType< typeof buildCalendarHeatmapData >[ 'data' ] ) =>
11
+ data.flatMap( column => column.data ).filter( cell => cell.value !== null );
12
+
13
+ // 2026-08-03 08:00 in Asia/Tokyo, 2026-08-02 16:00 in America/Los_Angeles.
14
+ const nearMidnight: DataPointDate[] = [ { date: new Date( '2026-08-02T23:00:00Z' ), value: 7 } ];
15
+
16
+ describe( 'buildCalendarHeatmapData bucketing in a host time zone', () => {
17
+ test( "buckets an instant on the host's day, not the runtime's", () => {
18
+ const tokyo = buildCalendarHeatmapData( nearMidnight, {
19
+ weekStartsOn: 1,
20
+ timeZone: 'Asia/Tokyo',
21
+ } );
22
+ const losAngeles = buildCalendarHeatmapData( nearMidnight, {
23
+ weekStartsOn: 1,
24
+ timeZone: 'America/Los_Angeles',
25
+ } );
26
+
27
+ expect( labelsCarryingValues( tokyo.data ).map( cell => cell.label ) ).toEqual( [
28
+ 'Mon, Aug 3, 2026',
29
+ ] );
30
+ expect( labelsCarryingValues( losAngeles.data ).map( cell => cell.label ) ).toEqual( [
31
+ 'Sun, Aug 2, 2026',
32
+ ] );
33
+ } );
34
+
35
+ test( 'places the grid on the host zone week, so the two zones differ by a column', () => {
36
+ // Aug 2 2026 is a Sunday and Aug 3 a Monday, so a Monday week start puts the
37
+ // two readings in different weeks entirely.
38
+ const tokyo = buildCalendarHeatmapData( nearMidnight, {
39
+ weekStartsOn: 1,
40
+ timeZone: 'Asia/Tokyo',
41
+ } );
42
+
43
+ expect( tokyo.data[ 0 ].data[ 0 ].value ).toBe( 7 );
44
+ expect( tokyo.data[ 0 ].data[ 6 ].hidden ).toBe( true );
45
+ } );
46
+
47
+ test( 'a UTC offset works where an IANA name would', () => {
48
+ const { data } = buildCalendarHeatmapData( nearMidnight, {
49
+ weekStartsOn: 1,
50
+ timeZone: '+09:00',
51
+ } );
52
+
53
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
54
+ 'Mon, Aug 3, 2026',
55
+ ] );
56
+ } );
57
+ } );
58
+
59
+ describe( 'buildCalendarHeatmapData date semantics', () => {
60
+ test( 'a bare yyyy-MM-dd is a calendar day, and no zone moves it', () => {
61
+ const series: DataPointDate[] = [ { dateString: '2024-01-03', value: 5 } ];
62
+
63
+ for ( const timeZone of [ undefined, 'Asia/Tokyo', 'America/Los_Angeles' ] ) {
64
+ const { data } = buildCalendarHeatmapData( series, { weekStartsOn: 1, timeZone } );
65
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
66
+ 'Wed, Jan 3, 2024',
67
+ ] );
68
+ }
69
+ } );
70
+
71
+ test( 'a datetime with no offset keeps the day it was written with', () => {
72
+ const series: DataPointDate[] = [ { dateString: '2024-01-03T23:30:00', value: 5 } ];
73
+ const { data } = buildCalendarHeatmapData( series, {
74
+ weekStartsOn: 1,
75
+ timeZone: 'Pacific/Kiritimati',
76
+ } );
77
+
78
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
79
+ 'Wed, Jan 3, 2024',
80
+ ] );
81
+ } );
82
+
83
+ test( 'a string carrying an offset is an instant, and the host zone re-dates it', () => {
84
+ const series: DataPointDate[] = [ { dateString: '2024-01-03T23:30:00Z', value: 5 } ];
85
+ const { data } = buildCalendarHeatmapData( series, {
86
+ weekStartsOn: 1,
87
+ timeZone: 'Asia/Tokyo',
88
+ } );
89
+
90
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
91
+ 'Thu, Jan 4, 2024',
92
+ ] );
93
+ } );
94
+
95
+ test( 'drops an instant no four-digit day key can hold', () => {
96
+ // `Date` reaches year 275760; a wider key would also sort before every other.
97
+ const outOfRange = [ { date: new Date( 1e15 ), value: 3 } ];
98
+
99
+ expect( buildCalendarHeatmapData( outOfRange ) ).toEqual( { data: [], rowLabels: [] } );
100
+ expect(
101
+ labelsCarryingValues(
102
+ buildCalendarHeatmapData( [ ...outOfRange, { dateString: '2024-01-03', value: 5 } ], {
103
+ weekStartsOn: 1,
104
+ } ).data
105
+ ).map( cell => cell.label )
106
+ ).toEqual( [ 'Wed, Jan 3, 2024' ] );
107
+ expect( console ).toHaveWarned();
108
+ } );
109
+
110
+ test( 'falls through to dateString when date is an Invalid Date', () => {
111
+ const { data } = buildCalendarHeatmapData(
112
+ [ { date: new Date( 'not a date' ), dateString: '2024-01-03', value: 5 } ],
113
+ { weekStartsOn: 1 }
114
+ );
115
+
116
+ expect( labelsCarryingValues( data ).map( cell => cell.label ) ).toEqual( [
117
+ 'Wed, Jan 3, 2024',
118
+ ] );
119
+ } );
120
+
121
+ test( 'drops a written date that names no real day', () => {
122
+ expect( buildCalendarHeatmapData( [ { dateString: '2024-02-30', value: 5 } ] ) ).toEqual( {
123
+ data: [],
124
+ rowLabels: [],
125
+ } );
126
+ expect( console ).toHaveWarned();
127
+ } );
128
+ } );
129
+
130
+ describe( 'buildCalendarHeatmapData grid bounds are compared as calendar days', () => {
131
+ // Fri Jan 5 2024, 14:00 UTC: a time of day must not make a bound look earlier than
132
+ // the series and widen the grid.
133
+ const afternoon = [ { date: new Date( '2024-01-05T14:00:00Z' ), value: 5 } ];
134
+
135
+ test( 'a bound equal to the series start does not widen the grid', () => {
136
+ const { data } = buildCalendarHeatmapData( afternoon, {
137
+ weekStartsOn: 1,
138
+ timeZone: 'UTC',
139
+ gridSpan: { start: '2024-01-05' },
140
+ } );
141
+
142
+ // Mon Jan 1 to Thu Jan 4 precede the bound: the ragged edge, not filler.
143
+ expect( data[ 0 ].data.slice( 0, 4 ).map( cell => cell.hidden ) ).toEqual( [
144
+ true,
145
+ true,
146
+ true,
147
+ true,
148
+ ] );
149
+ expect( data[ 0 ].data.slice( 0, 4 ).some( cell => cell.placeholder ) ).toBe( false );
150
+ } );
151
+
152
+ test( 'a bound genuinely earlier still widens it', () => {
153
+ const { data } = buildCalendarHeatmapData( afternoon, {
154
+ weekStartsOn: 1,
155
+ timeZone: 'UTC',
156
+ gridSpan: { start: '2024-01-04' },
157
+ } );
158
+
159
+ expect( data[ 0 ].data.slice( 0, 4 ).every( cell => cell.placeholder === true ) ).toBe( true );
160
+ } );
161
+
162
+ test( 'a bound genuinely later widens it too', () => {
163
+ const { data } = buildCalendarHeatmapData( afternoon, {
164
+ weekStartsOn: 1,
165
+ timeZone: 'UTC',
166
+ gridSpan: { end: '2024-01-12' },
167
+ } );
168
+
169
+ expect( data ).toHaveLength( 2 );
170
+ expect( data[ 1 ].data.slice( 0, 5 ).every( cell => cell.placeholder === true ) ).toBe( true );
171
+ } );
172
+
173
+ test( 'warns once and falls back on a bound that names no day', () => {
174
+ const { data } = buildCalendarHeatmapData( afternoon, {
175
+ weekStartsOn: 1,
176
+ timeZone: 'UTC',
177
+ gridSpan: { start: '2023-12' },
178
+ } );
179
+
180
+ expect( data ).toHaveLength( 1 );
181
+ expect( console ).toHaveWarned();
182
+ } );
183
+ } );
184
+
185
+ describe( 'buildCalendarHeatmapData labels in a host locale', () => {
186
+ // Three whole December weeks, so the first month keeps its label.
187
+ const series: DataPointDate[] = [
188
+ { dateString: '2023-12-11', value: 1 },
189
+ { dateString: '2024-01-03', value: 5 },
190
+ ];
191
+
192
+ test( 'writes row, month and cell labels in the locale', () => {
193
+ const { data, rowLabels } = buildCalendarHeatmapData( series, {
194
+ weekStartsOn: 1,
195
+ locale: 'de-DE',
196
+ } );
197
+
198
+ expect( rowLabels ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
199
+ expect( data.map( column => column.label ) ).toEqual( [ 'Dez', '', '', 'Jan' ] );
200
+ expect( data[ 3 ].data[ 2 ].label ).toBe( 'Mi., 3. Jan. 2024' );
201
+ } );
202
+
203
+ test( 'does not let the locale override an explicit weekStartsOn', () => {
204
+ // de-DE starts its week on Monday; the caller asked for Sunday.
205
+ const { rowLabels } = buildCalendarHeatmapData( series, {
206
+ weekStartsOn: 0,
207
+ locale: 'de-DE',
208
+ } );
209
+
210
+ expect( rowLabels ).toEqual( [ 'So', '', 'Di', '', 'Do', '', '' ] );
211
+ } );
212
+
213
+ test( 'repairs a WordPress locale and falls back rather than throwing on a bad one', () => {
214
+ expect(
215
+ buildCalendarHeatmapData( series, { weekStartsOn: 1, locale: 'de_DE' } ).rowLabels
216
+ ).toEqual( [ 'Mo', '', 'Mi', '', 'Fr', '', '' ] );
217
+
218
+ expect( () =>
219
+ buildCalendarHeatmapData( series, {
220
+ weekStartsOn: 1,
221
+ locale: 'not a tag',
222
+ timeZone: 'Nowhere/Fictional',
223
+ } )
224
+ ).not.toThrow();
225
+ expect( console ).toHaveWarned();
226
+ } );
227
+ } );
228
+
229
+ describe( 'buildCalendarHeatmapData across a DST transition', () => {
230
+ // Chile moves its clocks on 2024-09-08, at midnight rather than in the small
231
+ // hours, which is where local-midnight day arithmetic loses a day.
232
+ const daily: DataPointDate[] = Array.from( { length: 14 }, ( _, index ) => ( {
233
+ date: new Date( Date.UTC( 2024, 8, 2 + index, 16 ) ),
234
+ value: index,
235
+ } ) );
236
+
237
+ test( 'draws every day exactly once', () => {
238
+ const { data } = buildCalendarHeatmapData( daily, {
239
+ weekStartsOn: 1,
240
+ timeZone: 'America/Santiago',
241
+ } );
242
+
243
+ expect( data ).toHaveLength( 2 );
244
+ expect( labelsCarryingValues( data ) ).toHaveLength( 14 );
245
+ expect( data[ 0 ].data.map( cell => cell.value ) ).toEqual( [ 0, 1, 2, 3, 4, 5, 6 ] );
246
+ expect( data[ 1 ].data.map( cell => cell.value ) ).toEqual( [ 7, 8, 9, 10, 11, 12, 13 ] );
247
+ } );
248
+ } );
@@ -28,6 +28,12 @@ export type HeatmapColumn = {
28
28
  /** x-axis label for this column. Empty/omitted renders blank. */
29
29
  label?: string;
30
30
  data: HeatmapCell[];
31
+ /**
32
+ * A per-row roll-up such as a total or an average. Left out of the color
33
+ * scale, drawn unfilled with its figure always printed, on an `auto` track
34
+ * one gap apart from the data beside it. The chart never computes it.
35
+ */
36
+ summary?: boolean;
31
37
  };
32
38
 
33
39
  export type HeatmapTooltipData = {
@@ -69,8 +75,8 @@ export interface HeatmapChartProps
69
75
  /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
70
76
  minCellHeight?: number;
71
77
  /**
72
- * Color the cell scale interpolates toward at the highest value
73
- * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
78
+ * Color the cell scale interpolates toward at the highest value. Defaults to the
79
+ * first series palette slot, `--a8c-charts-color-series-1`.
74
80
  */
75
81
  primaryColor?: string;
76
82
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
@@ -0,0 +1,42 @@
1
+ import { useMemo } from 'react';
2
+ import { useChartFormatting } from '../../providers';
3
+ import { buildCalendarHeatmapData } from './private';
4
+ import type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
5
+ import type { DataPointDate } from '../../types';
6
+
7
+ /**
8
+ * `buildCalendarHeatmapData` with the locale and zone taken from
9
+ * `GlobalChartsProvider` where the caller names neither.
10
+ *
11
+ * @param series - Points to bucket. Held by reference, so a caller that rebuilds it each render defeats the memo.
12
+ * @param options - As for `buildCalendarHeatmapData`; `locale` and `timeZone` win over the provider.
13
+ * @return Columns and row labels for `HeatmapChart`.
14
+ */
15
+ export const useCalendarHeatmapData = (
16
+ series: DataPointDate[],
17
+ options: CalendarHeatmapOptions = {}
18
+ ): CalendarHeatmapResult => {
19
+ const formatting = useChartFormatting();
20
+
21
+ // `??` rather than a spread, so an explicit `undefined` in `options` reads as
22
+ // "unset" and still picks the provider up.
23
+ const locale = options.locale ?? formatting.locale;
24
+ const timeZone = options.timeZone ?? formatting.timeZone;
25
+ const { weekStartsOn, hideOutOfRangeDays } = options;
26
+ const gridStart = options.gridSpan?.start;
27
+ const gridEnd = options.gridSpan?.end;
28
+
29
+ // Options are spread into primitives: the object itself is rebuilt on each render,
30
+ // even by a caller that memoized its own, and would defeat this memo.
31
+ return useMemo(
32
+ () =>
33
+ buildCalendarHeatmapData( series, {
34
+ locale,
35
+ timeZone,
36
+ weekStartsOn,
37
+ hideOutOfRangeDays,
38
+ gridSpan: { start: gridStart, end: gridEnd },
39
+ } ),
40
+ [ series, locale, timeZone, weekStartsOn, hideOutOfRangeDays, gridStart, gridEnd ]
41
+ );
42
+ };
@@ -13,8 +13,8 @@ export const SUBPIXEL_TOLERANCE = 0.5;
13
13
  * Counts how many leading rows fit inside the content container.
14
14
  *
15
15
  * Rows are read from the DOM rather than derived from constants: row height
16
- * depends on the theme's row gap, label wrapping, and whatever a caller renders
17
- * as a label, so any restated number would drift.
16
+ * depends on `--a8c-charts-dimension-leaderboard-row-gap`, label wrapping, and
17
+ * whatever a caller renders as a label, so any restated number would drift.
18
18
  *
19
19
  * Every row is a direct grid child marked with `data-row-index`; interactive rows
20
20
  * use a button wrapper and non-interactive rows use a div wrapper.
@@ -1,6 +1,6 @@
1
1
  import { __ } from '@wordpress/i18n';
2
2
  import { useMemo } from 'react';
3
- import { useGlobalChartsTheme, useGlobalChartsContext } from '../../../providers';
3
+ import { useGlobalChartsContext } from '../../../providers';
4
4
  import type { BaseLegendItem } from '../../../components/legend';
5
5
  import type { LeaderboardEntry } from '../../../types';
6
6
 
@@ -35,7 +35,6 @@ export function useLeaderboardLegendItems( {
35
35
  comparison?: string;
36
36
  };
37
37
  } ): BaseLegendItem[] {
38
- const { leaderboardChart: leaderboardChartSettings } = useGlobalChartsTheme();
39
38
  const { getElementStyles } = useGlobalChartsContext();
40
39
 
41
40
  return useMemo( () => {
@@ -48,7 +47,7 @@ export function useLeaderboardLegendItems( {
48
47
  // Add current period legend item
49
48
  const { color: resolvedPrimaryColor } = getElementStyles( {
50
49
  index: 0,
51
- overrideColor: primaryColor || leaderboardChartSettings.primaryColor,
50
+ overrideColor: primaryColor,
52
51
  } );
53
52
 
54
53
  items.push( {
@@ -60,7 +59,7 @@ export function useLeaderboardLegendItems( {
60
59
  if ( withComparison && ! withOverlayLabel ) {
61
60
  const { color: resolvedSecondaryColor } = getElementStyles( {
62
61
  index: 1,
63
- overrideColor: secondaryColor || leaderboardChartSettings.secondaryColor,
62
+ overrideColor: secondaryColor,
64
63
  } );
65
64
 
66
65
  items.push( {
@@ -76,7 +75,6 @@ export function useLeaderboardLegendItems( {
76
75
  secondaryColor,
77
76
  withComparison,
78
77
  legendLabels,
79
- leaderboardChartSettings,
80
78
  getElementStyles,
81
79
  withOverlayLabel,
82
80
  ] );