@automattic/charts 3.2.0 → 4.0.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 (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. 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 {
@@ -115,19 +116,19 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
115
116
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
116
117
  // an empty cell.
117
118
  .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));
119
+ 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
120
  }
120
121
 
121
122
  .heatmap-chart__cell--selected {
122
123
  outline:
123
124
  $focus-ring-width solid
124
- var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
125
+ var(--wpds-color-stroke-focus);
125
126
  outline-offset: calc(-1 * #{$focus-ring-width});
126
127
  }
127
128
 
128
129
  .heatmap-chart__cell-value {
129
130
  color: var(--a8c-charts-color-label);
130
- font-size: var(--wpds-typography-font-size-md, 13px);
131
+ font-size: var(--wpds-typography-font-size-md);
131
132
  }
132
133
 
133
134
  // Light text on fills dark enough to out-contrast dark text (decided in JS from
@@ -136,9 +137,13 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
136
137
  color: var(--a8c-charts-color-label-inverse);
137
138
  }
138
139
 
140
+ .heatmap-chart__legend-label {
141
+ color: var(--a8c-charts-color-label);
142
+ }
143
+
139
144
  .heatmap-chart__legend-swatch {
140
- width: var(--wpds-dimension-size-3xs, 12px);
141
- height: var(--wpds-dimension-size-3xs, 12px);
145
+ width: var(--wpds-dimension-size-3xs);
146
+ height: var(--wpds-dimension-size-3xs);
142
147
  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));
148
+ 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
149
  }
@@ -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,
@@ -69,23 +71,28 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
69
71
  const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
70
72
  const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
71
73
  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;
74
+ const standaloneScopeClass = useStandaloneScopeClass();
75
+ const containerRef = useRef< HTMLDivElement >( null );
76
+ // The chart root positions the tooltip, so pointer and cell coordinates are
77
+ // measured against it — found by its id rather than by walking up, so
78
+ // whatever ChartLayout wraps the grid in cannot shift the origin.
79
+ const getTooltipOrigin = useCallback(
80
+ () =>
81
+ containerRef.current
82
+ ?.closest( `[data-chart-id="heatmap-chart-${ chartId }"]` )
83
+ ?.getBoundingClientRect() ?? null,
84
+ [ chartId ]
85
+ );
80
86
 
81
87
  const { color: primaryColorHex } = getElementStyles( {
82
88
  index: 0,
83
- overrideColor: primaryColor || heatmapChartSettings.primaryColor,
89
+ overrideColor: primaryColor,
84
90
  } );
85
91
 
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.
92
+ // The cell blend substitutes this role at the cell; this read happens at the scope
93
+ // element, so an override on the chart's own class makes the two disagree. CHARTS-255.
87
94
  const chartBackgroundHex = normalizeColorToHex(
88
- theme.backgroundColor,
95
+ CATALOG_POINTERS.background,
89
96
  scopeElement,
90
97
  resolveCssVariable
91
98
  );
@@ -212,20 +219,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
212
219
  if ( ! withTooltips ) {
213
220
  return;
214
221
  }
222
+ const origin = getTooltipOrigin();
223
+ if ( ! origin ) {
224
+ return;
225
+ }
215
226
  const target = event.currentTarget;
216
227
  const columnIndex = Number( target.dataset.column );
217
228
  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
229
  showTooltip( {
223
- tooltipLeft: event.clientX - bounds.left,
224
- tooltipTop: event.clientY - bounds.top,
230
+ tooltipLeft: event.clientX - origin.left,
231
+ tooltipTop: event.clientY - origin.top,
225
232
  tooltipData: buildTooltipData( columnIndex, rowIndex ),
226
233
  } );
227
234
  },
228
- [ withTooltips, showTooltip, buildTooltipData ]
235
+ [ withTooltips, showTooltip, buildTooltipData, getTooltipOrigin ]
229
236
  );
230
237
 
231
238
  const handleCellMouseLeave = useCallback( () => {
@@ -241,6 +248,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
241
248
  if ( ! withTooltips || selectedIndex === undefined ) {
242
249
  return;
243
250
  }
251
+ const origin = getTooltipOrigin();
252
+ if ( ! origin ) {
253
+ return;
254
+ }
244
255
  const col = Math.floor( selectedIndex / rows );
245
256
  const row = selectedIndex % rows;
246
257
  const cell =
@@ -248,13 +259,20 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
248
259
  ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
249
260
  : null;
250
261
  const rect = cell?.getBoundingClientRect();
251
- const bounds = containerBoundsRef.current;
252
262
  showTooltip( {
253
- tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0,
254
- tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0,
263
+ tooltipLeft: rect ? rect.left + rect.width / 2 - origin.left : 0,
264
+ tooltipTop: rect ? rect.top + rect.height / 2 - origin.top : 0,
255
265
  tooltipData: buildTooltipData( col, row ),
256
266
  } );
257
- }, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] );
267
+ }, [
268
+ selectedIndex,
269
+ withTooltips,
270
+ rows,
271
+ chartId,
272
+ buildTooltipData,
273
+ showTooltip,
274
+ getTooltipOrigin,
275
+ ] );
258
276
 
259
277
  const defaultRenderTooltip = useCallback(
260
278
  ( info: HeatmapTooltipData ) => (
@@ -296,7 +314,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
296
314
  : `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
297
315
  const gridStyle: Record< string, string | number > = {
298
316
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
299
- '--a8c-charts-color-heatmap-background': theme.backgroundColor,
300
317
  gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
301
318
  gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
302
319
  };
@@ -472,11 +489,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
472
489
  } ) }
473
490
  </div>
474
491
  { withTooltips && tooltipOpen && tooltipData && (
475
- <TooltipInPortal top={ tooltipTop } left={ tooltipLeft }>
476
- <div className={ CHART_SCOPE_CLASS } role="tooltip" tabIndex={ -1 }>
492
+ <BoundedTooltip top={ tooltipTop } left={ tooltipLeft }>
493
+ <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
477
494
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
478
495
  </div>
479
- </TooltipInPortal>
496
+ </BoundedTooltip>
480
497
  ) }
481
498
  </ChartLayout>
482
499
  </ChartInstanceContext.Provider>
@@ -53,6 +53,9 @@ const toDate = ( point: DataPointDate ): Date | null => {
53
53
  return null;
54
54
  };
55
55
 
56
+ // Labels stay English on the browser's calendar, unlike the time axis: this is a
57
+ // plain function the host calls outside the provider, so `GlobalChartsProvider`'s
58
+ // `locale` and `timeZone` cannot reach it. CHARTS-266 has the detail.
56
59
  export const buildCalendarHeatmapData = (
57
60
  series: DataPointDate[],
58
61
  options: {
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
15
15
 
16
16
  export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
17
17
  const context = useContext( HeatmapContext );
18
- const { legend, backgroundColor } = useGlobalChartsTheme();
18
+ const { legend } = useGlobalChartsTheme();
19
19
  if ( ! context ) {
20
20
  return null;
21
21
  }
@@ -24,7 +24,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
24
24
 
25
25
  return (
26
26
  <Stack direction="row" gap="xs" align="center">
27
- <Text variant="body-sm" style={ labelStyle }>
27
+ <Text
28
+ variant="body-sm"
29
+ className={ styles[ 'heatmap-chart__legend-label' ] }
30
+ style={ labelStyle }
31
+ >
28
32
  { lessLabel ?? __( 'Less', 'jetpack-charts' ) }
29
33
  </Text>
30
34
  <Stack direction="row" gap="xs">
@@ -38,7 +42,6 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
38
42
  style={
39
43
  {
40
44
  '--a8c-charts-color-heatmap-primary': primaryColorHex,
41
- '--a8c-charts-color-heatmap-background': backgroundColor,
42
45
  '--a8c-charts-heatmap-cell-intensity': intensity,
43
46
  } as CSSProperties
44
47
  }
@@ -46,7 +49,11 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
46
49
  );
47
50
  } ) }
48
51
  </Stack>
49
- <Text variant="body-sm" style={ labelStyle }>
52
+ <Text
53
+ variant="body-sm"
54
+ className={ styles[ 'heatmap-chart__legend-label' ] }
55
+ style={ labelStyle }
56
+ >
50
57
  { moreLabel ?? __( 'More', 'jetpack-charts' ) }
51
58
  </Text>
52
59
  </Stack>
@@ -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,45 @@ 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 tooltip position', () => {
415
+ afterEach( () => {
416
+ jest.restoreAllMocks();
417
+ } );
418
+
419
+ test( 'places the box relative to the chart root, at the pointer plus the offsets', async () => {
420
+ mockRects();
421
+ renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
422
+
423
+ await userEvent.setup().pointer( {
424
+ target: screen.getAllByTestId( 'heatmap-cell' )[ 0 ],
425
+ coords: { clientX: 180, clientY: 140 },
426
+ } );
427
+
428
+ // (180, 140) is (80, 90) inside the root; the box adds 10px each way.
429
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
430
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
431
+ transform: 'translate(90px, 100px)',
432
+ } );
433
+ } );
434
+ } );
@@ -69,8 +69,8 @@ export interface HeatmapChartProps
69
69
  /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
70
70
  minCellHeight?: number;
71
71
  /**
72
- * Color the cell scale interpolates toward at the highest value
73
- * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
72
+ * Color the cell scale interpolates toward at the highest value. Defaults to the
73
+ * first series palette slot, `--a8c-charts-color-series-1`.
74
74
  */
75
75
  primaryColor?: string;
76
76
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
@@ -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
  ] );