@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
@@ -473,3 +473,49 @@ describe( 'ConversionFunnelChart', () => {
473
473
  } );
474
474
  } );
475
475
  } );
476
+
477
+ // Chart container at (100, 50); the tooltip box measures 120x40. Everything
478
+ // else the charts measure (wrapper, clipping lookups) reports the container.
479
+ const mockRects = () =>
480
+ jest.spyOn( Element.prototype, 'getBoundingClientRect' ).mockImplementation( function (
481
+ this: Element
482
+ ) {
483
+ const box = this.classList.contains( 'visx-tooltip' );
484
+ return {
485
+ left: box ? 0 : 100,
486
+ top: box ? 0 : 50,
487
+ width: box ? 120 : 400,
488
+ height: box ? 40 : 300,
489
+ right: box ? 120 : 500,
490
+ bottom: box ? 40 : 350,
491
+ x: box ? 0 : 100,
492
+ y: box ? 0 : 50,
493
+ toJSON: () => ( {} ),
494
+ } as DOMRect;
495
+ } );
496
+
497
+ describe( 'ConversionFunnelChart tooltip position', () => {
498
+ afterEach( () => {
499
+ jest.restoreAllMocks();
500
+ } );
501
+
502
+ it( 'places the box relative to the chart root, at the click plus the offsets', async () => {
503
+ mockRects();
504
+ const user = userEvent.setup();
505
+ renderWithoutTheme( <ConversionFunnelChart { ...defaultProps } /> );
506
+
507
+ const bar = screen.getAllByRole( 'button' )[ 0 ];
508
+ await user.pointer( [
509
+ { target: bar, coords: { clientX: 180, clientY: 140 } },
510
+ { keys: '[MouseLeft]', target: bar, coords: { clientX: 180, clientY: 140 } },
511
+ ] );
512
+
513
+ // (180, 140) is (80, 90) inside the root; the chart lifts the anchor 10px
514
+ // above the click, and the box adds 10px each way.
515
+ const box = screen.getByTestId( 'bounded-tooltip' );
516
+ expect( screen.getByTestId( 'conversion-funnel-chart' ) ).toContainElement( box );
517
+ expect( box ).toHaveStyle( {
518
+ transform: 'translate(90px, 90px)',
519
+ } );
520
+ } );
521
+ } );
@@ -1,5 +1,6 @@
1
1
  .container {
2
2
  position: relative;
3
+ background-color: var(--a8c-charts-color-background, #fff);
3
4
 
4
5
  // Google gives its HTML tooltip no width, so the box shrink-to-fits against
5
6
  // whatever room is left beside the hovered feature. Near a map edge that
@@ -14,6 +14,7 @@ import {
14
14
  useChartScopeElement,
15
15
  useGlobalChartsContext,
16
16
  } from '../../providers';
17
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
17
18
  import { lightenHexColor, normalizeColorToHex } from '../../utils/color-utils';
18
19
  import { resolveCssVariable } from '../../utils/resolve-css-var';
19
20
  import { sanitizeHtml } from '../../utils/sanitize-html';
@@ -144,13 +145,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
144
145
  onError,
145
146
  renderPlaceholder,
146
147
  } ) => {
147
- const {
148
- getElementStyles,
149
- theme: {
150
- geoChart: { featureFillColor },
151
- backgroundColor,
152
- },
153
- } = useGlobalChartsContext();
148
+ const { getElementStyles } = useGlobalChartsContext();
154
149
  const scopeElement = useChartScopeElement();
155
150
  const containerRef = useRef< HTMLDivElement >( null );
156
151
  const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
@@ -212,10 +207,10 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
212
207
  const lightColorHex = lightenHexColor( fullColorHex, 0.8 );
213
208
  // Use normalizeColorToHex to ensure HSL/RGB values from CSS variables are converted to hex. Google Charts takes a resolved-hex snapshot at render, so this does not live-update on a theme change without a re-render — an accepted asymmetry vs CSS-painted elements.
214
209
  const backgroundColorHex =
215
- normalizeColorToHex( backgroundColor, scopeElement, resolveCssVariable ) ||
210
+ normalizeColorToHex( CATALOG_POINTERS.background, scopeElement, resolveCssVariable ) ||
216
211
  DEFAULT_BACKGROUND_COLOR;
217
212
  const defaultFillColorHex =
218
- normalizeColorToHex( featureFillColor, scopeElement, resolveCssVariable ) ||
213
+ normalizeColorToHex( CATALOG_POINTERS.surfaceSecondary, scopeElement, resolveCssVariable ) ||
219
214
  DEFAULT_FEATURE_FILL_COLOR;
220
215
 
221
216
  // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
@@ -306,7 +301,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
306
301
  ref={ containerRef }
307
302
  className={ clsx( 'geo-chart', styles.container, className ) }
308
303
  data-testid="geo-chart"
309
- style={ { width, height, backgroundColor } }
304
+ style={ { width, height } }
310
305
  >
311
306
  <Chart
312
307
  chartType="GeoChart"
@@ -142,7 +142,7 @@ describe( 'GeoChart', () => {
142
142
  } );
143
143
 
144
144
  describe( 'Chart Options', () => {
145
- test( 'configures color axis with theme colors', () => {
145
+ test( 'configures a two-stop color axis from the palette', () => {
146
146
  renderWithTheme();
147
147
 
148
148
  const chartOptions = screen.getByTestId( 'chart-options' );
@@ -152,7 +152,7 @@ describe( 'GeoChart', () => {
152
152
  expect( options.colorAxis.colors ).toHaveLength( 2 );
153
153
  } );
154
154
 
155
- test( 'sets datalessRegionColor from theme', () => {
155
+ test( 'sets datalessRegionColor from the surface-secondary role', () => {
156
156
  renderWithTheme();
157
157
 
158
158
  const chartOptions = screen.getByTestId( 'chart-options' );
@@ -1,13 +1,14 @@
1
1
  // Focus-ring width shared across the grid and cell-selected focus outlines. A
2
2
  // Sass var, not a custom property: a compile-time constant scoped to this
3
3
  // module, so it needs no runtime cascade and never lands on the DOM.
4
- $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
4
+ $focus-ring-width: var(--wpds-border-width-focus);
5
5
 
6
6
  .heatmap-chart {
7
+ position: relative;
7
8
  width: 100%;
8
9
  height: 100%;
9
10
  min-height: 0;
10
- font-size: var(--wpds-typography-font-size-sm, 12px);
11
+ font-size: var(--wpds-typography-font-size-sm);
11
12
  }
12
13
 
13
14
  // Height-capped grids are content-sized vertically, so the wrapper hugs them
@@ -18,7 +19,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
18
19
  }
19
20
 
20
21
  .heatmap-chart__empty {
21
- padding: var(--wpds-dimension-padding-lg, 16px);
22
+ padding: var(--wpds-dimension-padding-lg);
22
23
  color: var(--a8c-charts-color-label-secondary);
23
24
  }
24
25
 
@@ -28,9 +29,9 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
28
29
  display: grid;
29
30
  // --a8c-charts-dimension-heatmap-cell-gap is component-emitted from JS per
30
31
  // render, not a catalog entry. Do not collapse this to a bare var(); the
31
- // inner WPDS fallback is the only value this property has when the JS var
32
- // is unset.
33
- gap: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
32
+ // inner WPDS token (fallback injected at build) is the only value this
33
+ // property has when the JS var is unset.
34
+ gap: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
34
35
  flex: 1 1 0;
35
36
  width: 100%;
36
37
  // Override flex's auto minimum so short widgets do not scroll.
@@ -39,9 +40,9 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
39
40
  &:focus-visible {
40
41
  outline:
41
42
  $focus-ring-width solid
42
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
43
+ var(--wpds-color-stroke-focus);
43
44
  outline-offset: 2px;
44
- border-radius: var(--wpds-border-radius-sm, 2px);
45
+ border-radius: var(--wpds-border-radius-sm);
45
46
  }
46
47
  }
47
48
 
@@ -68,7 +69,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
68
69
  .heatmap-chart__col-label,
69
70
  .heatmap-chart__row-label {
70
71
  color: var(--a8c-charts-color-label-secondary);
71
- font-size: var(--wpds-typography-font-size-xs, 11px);
72
+ font-size: var(--wpds-typography-font-size-xs);
72
73
  white-space: nowrap;
73
74
  overflow: visible;
74
75
  }
@@ -82,7 +83,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
82
83
  align-self: center;
83
84
  justify-self: end;
84
85
  text-align: right;
85
- padding-inline-end: var(--wpds-dimension-padding-xs, 4px);
86
+ padding-inline-end: var(--wpds-dimension-padding-xs);
86
87
  }
87
88
 
88
89
  .heatmap-chart__cell {
@@ -112,22 +113,53 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
112
113
  background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
113
114
  }
114
115
 
116
+ // A roll-up beside the grid, on another scale from it: no fill, its figure
117
+ // always printed and emphasised.
118
+ .heatmap-chart__cell--summary {
119
+ background: none;
120
+ }
121
+
122
+ .heatmap-chart__col-label--summary {
123
+ font-weight: var(--wpds-typography-font-weight-emphasis);
124
+ }
125
+
126
+ .heatmap-chart__cell--summary .heatmap-chart__cell-value {
127
+ font-weight: var(--wpds-typography-font-weight-emphasis);
128
+ }
129
+
130
+ // Compact rows are `compactCellSize` tall, so the figure shrinks to fit them.
131
+ .heatmap-chart__grid--compact .heatmap-chart__cell--summary .heatmap-chart__cell-value {
132
+ font-size: var(--wpds-typography-font-size-xs);
133
+ line-height: 1;
134
+ }
135
+
136
+ // Deliberately on top of the grid gap: one extra gap sets a summary column
137
+ // apart from the data beside it, on cells and label alike, and the auto track
138
+ // grows by the same amount.
139
+ .heatmap-chart__gap-start {
140
+ margin-inline-start: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
141
+ }
142
+
143
+ .heatmap-chart__gap-end {
144
+ margin-inline-end: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs));
145
+ }
146
+
115
147
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
116
148
  // an empty cell.
117
149
  .heatmap-chart__cell--filled {
118
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-heatmap-background, #fff));
150
+ background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
119
151
  }
120
152
 
121
153
  .heatmap-chart__cell--selected {
122
154
  outline:
123
155
  $focus-ring-width solid
124
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
156
+ var(--wpds-color-stroke-focus);
125
157
  outline-offset: calc(-1 * #{$focus-ring-width});
126
158
  }
127
159
 
128
160
  .heatmap-chart__cell-value {
129
161
  color: var(--a8c-charts-color-label);
130
- font-size: var(--wpds-typography-font-size-md, 13px);
162
+ font-size: var(--wpds-typography-font-size-md);
131
163
  }
132
164
 
133
165
  // Light text on fills dark enough to out-contrast dark text (decided in JS from
@@ -136,9 +168,13 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
136
168
  color: var(--a8c-charts-color-label-inverse);
137
169
  }
138
170
 
171
+ .heatmap-chart__legend-label {
172
+ color: var(--a8c-charts-color-label);
173
+ }
174
+
139
175
  .heatmap-chart__legend-swatch {
140
- width: var(--wpds-dimension-size-3xs, 12px);
141
- height: var(--wpds-dimension-size-3xs, 12px);
176
+ width: var(--wpds-dimension-size-3xs);
177
+ height: var(--wpds-dimension-size-3xs);
142
178
  border-radius: var(--a8c-charts-border-radius-cell);
143
- background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-heatmap-background, #fff));
179
+ background: color-mix(in sRGB, var(--a8c-charts-color-heatmap-primary) calc((0.15 + 0.85 * var(--a8c-charts-heatmap-cell-intensity)) * 100%), var(--a8c-charts-color-background, #fff));
144
180
  }
@@ -1,8 +1,9 @@
1
1
  import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
2
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
2
+ import { useTooltip } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
5
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
6
7
  import {
7
8
  GlobalChartsProvider,
8
9
  useChartId,
@@ -10,7 +11,8 @@ import {
10
11
  useGlobalChartsContext,
11
12
  GlobalChartsContext,
12
13
  } from '../../providers';
13
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
14
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
15
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
14
16
  import { attachSubComponents } from '../../utils';
15
17
  import {
16
18
  isValidHexColor,
@@ -41,6 +43,10 @@ import type { CSSProperties, FC } from 'react';
41
43
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
42
44
  const CELL_MIX_FLOOR = 0.15;
43
45
 
46
+ // One instance, not a `[]` default in the signature: `buildTooltipData` keys on
47
+ // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
48
+ const NO_ROW_LABELS: string[] = [];
49
+
44
50
  const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
45
51
  data,
46
52
  chartId: providedChartId,
@@ -53,7 +59,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
53
59
  maxCellHeight,
54
60
  minCellWidth,
55
61
  minCellHeight,
56
- rowLabels = [],
62
+ rowLabels = NO_ROW_LABELS,
57
63
  primaryColor,
58
64
  gap = 'md',
59
65
  withTooltips = false,
@@ -69,23 +75,28 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
69
75
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
70
76
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
71
77
  useTooltip< HeatmapTooltipData >();
72
- const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( {
73
- detectBounds: true,
74
- scroll: true,
75
- } );
76
- // Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which
77
- // is a new object each render and would loop the effect via showTooltip.
78
- const containerBoundsRef = useRef( containerBounds );
79
- containerBoundsRef.current = containerBounds;
78
+ const standaloneScopeClass = useStandaloneScopeClass();
79
+ const containerRef = useRef< HTMLDivElement >( null );
80
+ // The chart root positions the tooltip, so pointer and cell coordinates are
81
+ // measured against it — found by its id rather than by walking up, so
82
+ // whatever ChartLayout wraps the grid in cannot shift the origin.
83
+ const getTooltipOrigin = useCallback(
84
+ () =>
85
+ containerRef.current
86
+ ?.closest( `[data-chart-id="heatmap-chart-${ chartId }"]` )
87
+ ?.getBoundingClientRect() ?? null,
88
+ [ chartId ]
89
+ );
80
90
 
81
91
  const { color: primaryColorHex } = getElementStyles( {
82
92
  index: 0,
83
- overrideColor: primaryColor || heatmapChartSettings.primaryColor,
93
+ overrideColor: primaryColor,
84
94
  } );
85
95
 
86
- // Resolve the background against this chart's own scope element (not the provider's), matching where `--a8c-charts-color-heatmap-background` is substituted for the cell blend below — a chart-level override otherwise disagrees with a provider-level read.
96
+ // The cell blend substitutes this role at the cell; this read happens at the scope
97
+ // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
87
98
  const chartBackgroundHex = normalizeColorToHex(
88
- theme.backgroundColor,
99
+ CATALOG_POINTERS.background,
89
100
  scopeElement,
90
101
  resolveCssVariable
91
102
  );
@@ -212,20 +223,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
212
223
  if ( ! withTooltips ) {
213
224
  return;
214
225
  }
226
+ const origin = getTooltipOrigin();
227
+ if ( ! origin ) {
228
+ return;
229
+ }
215
230
  const target = event.currentTarget;
216
231
  const columnIndex = Number( target.dataset.column );
217
232
  const rowIndex = Number( target.dataset.row );
218
- // Read bounds from the ref (like the keyboard-tooltip effect) so this
219
- // callback stays stable across renders.
220
- const bounds = containerBoundsRef.current;
221
- // TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor.
222
233
  showTooltip( {
223
- tooltipLeft: event.clientX - bounds.left,
224
- tooltipTop: event.clientY - bounds.top,
234
+ tooltipLeft: event.clientX - origin.left,
235
+ tooltipTop: event.clientY - origin.top,
225
236
  tooltipData: buildTooltipData( columnIndex, rowIndex ),
226
237
  } );
227
238
  },
228
- [ withTooltips, showTooltip, buildTooltipData ]
239
+ [ withTooltips, showTooltip, buildTooltipData, getTooltipOrigin ]
229
240
  );
230
241
 
231
242
  const handleCellMouseLeave = useCallback( () => {
@@ -241,6 +252,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
241
252
  if ( ! withTooltips || selectedIndex === undefined ) {
242
253
  return;
243
254
  }
255
+ const origin = getTooltipOrigin();
256
+ if ( ! origin ) {
257
+ return;
258
+ }
244
259
  const col = Math.floor( selectedIndex / rows );
245
260
  const row = selectedIndex % rows;
246
261
  const cell =
@@ -248,13 +263,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
248
263
  ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
249
264
  : null;
250
265
  const rect = cell?.getBoundingClientRect();
251
- const bounds = containerBoundsRef.current;
252
266
  showTooltip( {
253
- tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0,
254
- tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0,
267
+ tooltipLeft: rect ? rect.left + rect.width / 2 - origin.left : 0,
268
+ tooltipTop: rect ? rect.top + rect.height / 2 - origin.top : 0,
255
269
  tooltipData: buildTooltipData( col, row ),
256
270
  } );
257
- }, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] );
271
+ }, [
272
+ selectedIndex,
273
+ withTooltips,
274
+ rows,
275
+ chartId,
276
+ buildTooltipData,
277
+ showTooltip,
278
+ getTooltipOrigin,
279
+ ] );
258
280
 
259
281
  const defaultRenderTooltip = useCallback(
260
282
  ( info: HeatmapTooltipData ) => (
@@ -294,10 +316,18 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
294
316
  const rowTrack = compact
295
317
  ? 'var(--a8c-charts-dimension-heatmap-cell-size)'
296
318
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
319
+ // A summary column takes a content-sized track: a roll-up is wider than a
320
+ // cell, and a shared track would stretch every cell to fit it. `max-content`
321
+ // as the max keeps the leftover width out of it once the data tracks hit
322
+ // `maxCellWidth`, where a plain `auto` would absorb it.
323
+ const columnTracks = data.some( column => column.summary )
324
+ ? data
325
+ .map( column => ( column.summary ? 'minmax(auto, max-content)' : columnTrack ) )
326
+ .join( ' ' )
327
+ : `repeat(${ columns }, ${ columnTrack })`;
297
328
  const gridStyle: Record< string, string | number > = {
298
329
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
299
- '--a8c-charts-color-heatmap-background': theme.backgroundColor,
300
- gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
330
+ gridTemplateColumns: `auto ${ columnTracks }`,
301
331
  gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
302
332
  };
303
333
  if ( compact ) {
@@ -305,6 +335,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
305
335
  gridStyle[ '--a8c-charts-dimension-heatmap-cell-size' ] = `${ compactCellSize }px`;
306
336
  }
307
337
 
338
+ // A summary column sits one gap apart from the data on either side; two
339
+ // summaries side by side share no extra gap.
340
+ const summaryGaps = ( columnIndex: number ) => {
341
+ if ( ! data[ columnIndex ]?.summary ) {
342
+ return {};
343
+ }
344
+
345
+ return {
346
+ [ styles[ 'heatmap-chart__gap-start' ] ]:
347
+ columnIndex > 0 && ! data[ columnIndex - 1 ]?.summary,
348
+ [ styles[ 'heatmap-chart__gap-end' ] ]:
349
+ columnIndex < columns - 1 && ! data[ columnIndex + 1 ]?.summary,
350
+ };
351
+ };
352
+
308
353
  const activeDescendant =
309
354
  selectedIndex !== undefined
310
355
  ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
@@ -357,7 +402,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
357
402
  { data.map( ( column, columnIndex ) => (
358
403
  <span
359
404
  key={ `col-${ columnIndex }` }
360
- className={ styles[ 'heatmap-chart__col-label' ] }
405
+ className={ clsx( styles[ 'heatmap-chart__col-label' ], {
406
+ [ styles[ 'heatmap-chart__col-label--summary' ] ]: column.summary,
407
+ ...summaryGaps( columnIndex ),
408
+ } ) }
361
409
  >
362
410
  { column.label }
363
411
  </span>
@@ -416,7 +464,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
416
464
 
417
465
  const value = cell?.value ?? null;
418
466
  const present = isPresent( value );
419
- const normalized = present ? getNormalizedValue( value, extent ) : 0;
467
+ // A summary cell is on another scale, so it takes no fill.
468
+ const filled = present && ! column.summary;
469
+ const normalized = filled ? getNormalizedValue( value, extent ) : 0;
420
470
  const flatIndex = columnIndex * rows + rowIndex;
421
471
  const info = buildTooltipData( columnIndex, rowIndex );
422
472
  const accessibleName =
@@ -432,7 +482,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
432
482
  <div
433
483
  key={ `cell-${ columnIndex }-${ rowIndex }` }
434
484
  id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` }
435
- data-testid="heatmap-cell"
485
+ data-testid={ column.summary ? 'heatmap-cell-summary' : 'heatmap-cell' }
436
486
  role="gridcell"
437
487
  // Focus stays on the grid (aria-activedescendant); cells are
438
488
  // focusable but out of the tab order.
@@ -442,14 +492,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
442
492
  data-column={ columnIndex }
443
493
  data-row={ rowIndex }
444
494
  className={ clsx( styles[ 'heatmap-chart__cell' ], {
445
- [ styles[ 'heatmap-chart__cell--filled' ] ]: present,
495
+ [ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
446
496
  [ styles[ 'heatmap-chart__cell--strong' ] ]:
447
- present && cellHasLightText( normalized ),
497
+ filled && cellHasLightText( normalized ),
498
+ [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
499
+ ...summaryGaps( columnIndex ),
448
500
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
449
501
  selectedIndex === flatIndex,
450
502
  } ) }
451
503
  style={
452
- present
504
+ filled
453
505
  ? ( {
454
506
  '--a8c-charts-heatmap-cell-intensity': normalized,
455
507
  } as CSSProperties )
@@ -458,7 +510,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
458
510
  onMouseMove={ handleCellMouseMove }
459
511
  onMouseLeave={ handleCellMouseLeave }
460
512
  >
461
- { drawValues && present && (
513
+ { ( drawValues || column.summary ) && present && (
462
514
  <span className={ styles[ 'heatmap-chart__cell-value' ] }>
463
515
  { /* Compact display; tooltip and aria-label keep full precision. */ }
464
516
  { formatNumberCompact( value ) }
@@ -472,11 +524,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
472
524
  } ) }
473
525
  </div>
474
526
  { withTooltips && tooltipOpen && tooltipData && (
475
- <TooltipInPortal top={ tooltipTop } left={ tooltipLeft }>
476
- <div className={ CHART_SCOPE_CLASS } role="tooltip" tabIndex={ -1 }>
527
+ <BoundedTooltip top={ tooltipTop } left={ tooltipLeft }>
528
+ <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
477
529
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
478
530
  </div>
479
- </TooltipInPortal>
531
+ </BoundedTooltip>
480
532
  ) }
481
533
  </ChartLayout>
482
534
  </ChartInstanceContext.Provider>
@@ -1,4 +1,5 @@
1
1
  export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
2
2
  export { buildCalendarHeatmapData } from './private';
3
- export type { CalendarHeatmapResult } from './private';
3
+ export { useCalendarHeatmapData } from './use-calendar-heatmap-data';
4
+ export type { CalendarHeatmapOptions, CalendarHeatmapResult } from './private';
4
5
  export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';