@automattic/charts 3.1.1 → 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 (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/TOKENS.md +80 -46
  3. package/dist/index.cjs +1224 -662
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -23
  6. package/dist/index.d.cts +230 -162
  7. package/dist/index.d.ts +230 -162
  8. package/dist/index.js +1224 -663
  9. package/dist/index.js.map +1 -1
  10. package/package.json +10 -10
  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 +38 -11
  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-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  21. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  22. package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
  23. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  24. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  25. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  26. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  27. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  28. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  29. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  30. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  31. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  32. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  33. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  34. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  35. package/src/charts/heatmap-chart/types.ts +2 -2
  36. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  37. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  38. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  39. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  40. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  41. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  42. package/src/charts/line-chart/line-chart.module.scss +10 -18
  43. package/src/charts/line-chart/line-chart.tsx +109 -25
  44. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  45. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  46. package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
  47. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  48. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  49. package/src/charts/line-chart/types.ts +4 -1
  50. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  51. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  52. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  53. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  54. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  55. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  56. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  57. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  58. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  59. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  60. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  61. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  62. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  63. package/src/charts/private/test/time-axis.test.ts +9 -69
  64. package/src/charts/private/time-axis-options.ts +106 -0
  65. package/src/charts/private/time-axis.ts +318 -136
  66. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  67. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  68. package/src/charts/sparkline/sparkline.tsx +9 -3
  69. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  70. package/src/charts/sparkline/types.ts +2 -2
  71. package/src/components/legend/private/base-legend.module.scss +9 -5
  72. package/src/components/tooltip/accessible-tooltip.tsx +38 -10
  73. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  74. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  75. package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
  76. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  77. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  78. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  79. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  80. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  81. package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
  82. package/src/hooks/use-xychart-theme.ts +33 -23
  83. package/src/index.ts +4 -0
  84. package/src/providers/chart-context/global-charts-provider.tsx +64 -54
  85. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  86. package/src/providers/chart-context/index.ts +1 -0
  87. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  88. package/src/providers/chart-context/private/series-palette.ts +35 -0
  89. package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
  90. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  91. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
  92. package/src/providers/chart-context/themes.ts +7 -47
  93. package/src/providers/chart-context/types.ts +3 -0
  94. package/src/styles/chart-scope.scss +63 -58
  95. package/src/styles/test/chart-scope.test.ts +25 -40
  96. package/src/test-utils/series-fixtures.ts +31 -0
  97. package/src/types.ts +103 -73
  98. package/src/utils/bucket-info.ts +87 -0
  99. package/src/utils/color-utils.ts +0 -27
  100. package/src/utils/date-formatting.ts +174 -0
  101. package/src/utils/get-styles.ts +7 -1
  102. package/src/utils/index.ts +3 -0
  103. package/src/utils/test/bucket-info.test.ts +185 -0
  104. package/src/utils/test/color-utils.test.ts +1 -48
  105. package/src/utils/test/date-formatting.test.ts +89 -0
  106. package/src/utils/test/get-styles.test.ts +10 -7
  107. package/src/utils/test/merge-themes.test.ts +18 -46
  108. package/src/visx/types.ts +29 -2
  109. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  110. package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
  111. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
  112. package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
  113. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  114. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
  115. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
  116. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,7 +1,8 @@
1
- import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
1
+ import { useTooltip } from '@visx/tooltip';
2
2
  import { Stack } from '@wordpress/ui';
3
3
  import clsx from 'clsx';
4
4
  import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
5
+ import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
5
6
  import { usePrefersReducedMotion } from '../../hooks';
6
7
  import {
7
8
  GlobalChartsProvider,
@@ -9,11 +10,11 @@ import {
9
10
  ChartScopeContext,
10
11
  useChartId,
11
12
  useChartRegistration,
12
- useGlobalChartsTheme,
13
13
  useGlobalChartsContext,
14
14
  } from '../../providers';
15
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
16
- import { formatPercentage, hexToRgba } from '../../utils';
15
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
16
+ import { useStandaloneScopeClass } from '../../providers/chart-scope';
17
+ import { formatPercentage } from '../../utils';
17
18
  import styles from './conversion-funnel-chart.module.scss';
18
19
  import { useFunnelSelection } from './private';
19
20
  import type { FunnelStep, ConversionFunnelChartProps } from './types';
@@ -53,7 +54,6 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
53
54
  renderTooltip,
54
55
  } ) => {
55
56
  const chartId = useChartId( providedChartId );
56
- const { conversionFunnelChart: conversionFunnelChartSettings } = useGlobalChartsTheme();
57
57
  const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
58
58
  const chartRef = useRef< HTMLDivElement >( null );
59
59
  const selectedBarRef = useRef< HTMLDivElement | null >( null );
@@ -66,27 +66,9 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
66
66
  // Use custom hook for selection management
67
67
  const { handleBarClick, handleBarKeyDown, clearSelection, getStepState } =
68
68
  useFunnelSelection( hideTooltip );
69
- const {
70
- containerRef: portalContainerRef,
71
- TooltipInPortal,
72
- containerBounds,
73
- } = useTooltipInPortal( {
74
- // use TooltipWithBounds for boundary detection
75
- detectBounds: true,
76
- // when tooltip containers are scrolled, this will correctly update the Tooltip position
77
- scroll: true,
78
- } );
79
-
80
- // `useTooltipInPortal` returns a fresh `containerRef` closure on every render, so depending on it directly would give this callback a new identity every render — React then detaches and reattaches the ref on every commit, flushing the scope context to null and back. Reading it through a ref keeps the callback's identity genuinely stable.
81
- const portalContainerRefRef = useRef( portalContainerRef );
82
- useEffect( () => {
83
- portalContainerRefRef.current = portalContainerRef;
84
- }, [ portalContainerRef ] );
85
-
86
- // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep all three assignments, in the same order, and keep it firing on unmount (node === null).
69
+ const standaloneScopeClass = useStandaloneScopeClass();
70
+ // Stable identity so React doesn't detach/reattach (and re-render the scope context) on every commit. Keep it firing on unmount (node === null).
87
71
  const setChartRef = useCallback( ( node: HTMLDivElement | null ) => {
88
- // Set containerRef for @visx coordinate system
89
- portalContainerRefRef.current( node );
90
72
  chartRef.current = node;
91
73
  setScopeNode( node );
92
74
  }, [] );
@@ -110,43 +92,32 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
110
92
  [ showTooltip ]
111
93
  );
112
94
 
113
- // Helper function to get tooltip coordinates for mouse events
114
- // Use clientX/Y and subtract containerBounds to cancel out any stale offset.
115
- // TooltipInPortal calculates: tooltipLeft + containerBounds.left + scrollX
116
- // By passing (clientX - containerBounds.left), we get correct page coordinates
117
- // regardless of whether bounds are stale (e.g., after dashboard customization).
118
- const getMouseTooltipCoords = useCallback(
119
- ( event: React.MouseEvent ) => {
120
- // Don't return coords until container bounds are measured
121
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
122
- return null;
123
- }
95
+ // The chart root positions the tooltip, so coordinates are taken relative to it.
96
+ const getMouseTooltipCoords = useCallback( ( event: React.MouseEvent ) => {
97
+ const bounds = chartRef.current?.getBoundingClientRect();
98
+ if ( ! bounds ) {
99
+ return null;
100
+ }
124
101
 
125
- return {
126
- x: event.clientX - containerBounds.left,
127
- y: event.clientY - containerBounds.top,
128
- };
129
- },
130
- [ containerBounds.width, containerBounds.height, containerBounds.left, containerBounds.top ]
131
- );
132
-
133
- // Helper function to get tooltip coordinates for keyboard events
134
- // Use fresh getBoundingClientRect() and subtract containerBounds to cancel out stale offset.
135
- const getKeyboardTooltipCoords = useCallback(
136
- ( event: React.KeyboardEvent ) => {
137
- // Don't return coords until container bounds are measured
138
- if ( containerBounds.width === 0 || containerBounds.height === 0 ) {
139
- return null;
140
- }
102
+ return {
103
+ x: event.clientX - bounds.left,
104
+ y: event.clientY - bounds.top,
105
+ };
106
+ }, [] );
141
107
 
142
- const rect = event.currentTarget.getBoundingClientRect();
143
- // Calculate center of element in viewport coordinates, then subtract containerBounds
144
- const x = rect.left + rect.width / 2 - containerBounds.left;
145
- const y = rect.top - containerBounds.top;
146
- return { x, y };
147
- },
148
- [ containerBounds.width, containerBounds.height, containerBounds.left, containerBounds.top ]
149
- );
108
+ // Keyboard interaction anchors the tooltip at the top centre of the focused step.
109
+ const getKeyboardTooltipCoords = useCallback( ( event: React.KeyboardEvent ) => {
110
+ const bounds = chartRef.current?.getBoundingClientRect();
111
+ if ( ! bounds ) {
112
+ return null;
113
+ }
114
+
115
+ const rect = event.currentTarget.getBoundingClientRect();
116
+ return {
117
+ x: rect.left + rect.width / 2 - bounds.left,
118
+ y: rect.top - bounds.top,
119
+ };
120
+ }, [] );
150
121
 
151
122
  // Helper function to handle step interaction (both click and keyboard)
152
123
  const handleStepInteraction = useCallback(
@@ -246,32 +217,26 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
246
217
  // Resolve height: explicit height prop > style.height > default 100%
247
218
  const resolvedHeight = height ?? style?.height ?? '100%';
248
219
 
249
- // Get component settings from theme with fallbacks
250
- const { primaryColor, backgroundColor, positiveChangeColor, negativeChangeColor } =
251
- conversionFunnelChartSettings;
252
-
253
- // Resolve bar color using getElementStyles with primaryColor as override
220
+ // The bar takes the first palette slot; move `--a8c-charts-color-series-1` to change it.
254
221
  const { color: barColor } = getElementStyles
255
- ? getElementStyles( {
256
- index: 0,
257
- overrideColor: primaryColor,
258
- } )
259
- : { color: primaryColor || '#000000' };
222
+ ? getElementStyles( { index: 0 } )
223
+ : { color: '#000000' };
260
224
 
261
- // Determine change indicator color
262
225
  const isPositiveChange = changeIndicator?.startsWith( '+' );
263
- const changeColor = isPositiveChange ? positiveChangeColor : negativeChangeColor;
264
-
265
- // Create light background version of primary color if not set
266
- const barBackgroundColor =
267
- backgroundColor || hexToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)';
226
+ // Only for `renderMainMetric`, which hands a consumer's own markup something to paint with.
227
+ const changeColor = isPositiveChange ? CATALOG_POINTERS.trendUp : CATALOG_POINTERS.trendDown;
268
228
 
269
229
  // Default main metric rendering function
270
230
  const renderDefaultMainMetric = () => (
271
231
  <>
272
232
  <span className={ styles[ 'main-rate' ] }>{ formatPercentage( mainRate ) }</span>
273
233
  { changeIndicator && (
274
- <span className={ styles[ 'change-indicator' ] } style={ { color: changeColor } }>
234
+ <span
235
+ className={ clsx(
236
+ styles[ 'change-indicator' ],
237
+ styles[ isPositiveChange ? 'change-indicator--positive' : 'change-indicator--negative' ]
238
+ ) }
239
+ >
275
240
  { changeIndicator }
276
241
  </span>
277
242
  ) }
@@ -322,6 +287,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
322
287
  data-testid="conversion-funnel-chart"
323
288
  ref={ setScopeNode }
324
289
  className={ clsx(
290
+ 'conversion-funnel-chart',
325
291
  styles[ 'conversion-funnel-chart' ],
326
292
  loading && styles[ 'conversion-funnel-chart--loading' ],
327
293
  className
@@ -339,112 +305,104 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
339
305
  const maxRate = Math.max( ...steps.map( step => step.rate ) );
340
306
 
341
307
  return (
342
- <>
343
- <Stack
344
- direction="column"
345
- gap="xl"
346
- data-testid="conversion-funnel-chart"
347
- ref={ setChartRef }
348
- className={ clsx(
349
- styles[ 'conversion-funnel-chart' ],
350
- loading && styles[ 'conversion-funnel-chart--loading' ],
351
- className
308
+ <Stack
309
+ direction="column"
310
+ gap="xl"
311
+ data-testid="conversion-funnel-chart"
312
+ ref={ setChartRef }
313
+ className={ clsx(
314
+ 'conversion-funnel-chart',
315
+ styles[ 'conversion-funnel-chart' ],
316
+ loading && styles[ 'conversion-funnel-chart--loading' ],
317
+ className
318
+ ) }
319
+ style={ { ...style, height: resolvedHeight } }
320
+ >
321
+ <ChartScopeContext.Provider value={ scopeNode }>
322
+ { /* Main Metric */ }
323
+ { renderMainMetric ? (
324
+ renderMainMetric( {
325
+ mainRate,
326
+ changeIndicator,
327
+ className: styles[ 'main-metric' ],
328
+ changeColor,
329
+ } )
330
+ ) : (
331
+ <Stack direction="row" align="baseline" gap="sm" className={ styles[ 'main-metric' ] }>
332
+ { renderDefaultMainMetric() }
333
+ </Stack>
352
334
  ) }
353
- style={ { ...style, height: resolvedHeight } }
354
- >
355
- <ChartScopeContext.Provider value={ scopeNode }>
356
- { /* Main Metric */ }
357
- { renderMainMetric ? (
358
- renderMainMetric( {
359
- mainRate,
360
- changeIndicator,
361
- className: styles[ 'main-metric' ],
362
- changeColor,
363
- } )
364
- ) : (
365
- <Stack direction="row" align="baseline" gap="sm" className={ styles[ 'main-metric' ] }>
366
- { renderDefaultMainMetric() }
367
- </Stack>
368
- ) }
369
335
 
370
- { /* Funnel Steps */ }
371
- <Stack
372
- direction="row"
373
- align="flex-end"
374
- gap="lg"
375
- className={ styles[ 'funnel-container' ] }
376
- >
377
- { steps.map( ( step, index ) => {
378
- const barHeight = ( step.rate / maxRate ) * 100;
379
- const { isBlurred } = getStepState( step.id );
380
-
381
- return (
336
+ { /* Funnel Steps */ }
337
+ <Stack direction="row" align="flex-end" gap="lg" className={ styles[ 'funnel-container' ] }>
338
+ { steps.map( ( step, index ) => {
339
+ const barHeight = ( step.rate / maxRate ) * 100;
340
+ const { isBlurred } = getStepState( step.id );
341
+
342
+ return (
343
+ <Stack
344
+ key={ step.id }
345
+ direction="column"
346
+ data-testid="funnel-step"
347
+ className={ clsx(
348
+ styles[ 'funnel-step' ],
349
+ isColorPaletteResolved && styles[ 'funnel-step--animated' ],
350
+ isBlurred && styles[ 'funnel-step--blurred' ]
351
+ ) }
352
+ gap="xl"
353
+ >
354
+ { /* Step Label and Rate */ }
355
+ <Stack direction="column" gap="xs">
356
+ { renderStepLabel ? (
357
+ renderStepLabel( {
358
+ step,
359
+ index,
360
+ className: styles[ 'step-label' ],
361
+ } )
362
+ ) : (
363
+ <span className={ styles[ 'step-label' ] }>{ step.label }</span>
364
+ ) }
365
+ { renderStepRate ? (
366
+ renderStepRate( {
367
+ step,
368
+ index,
369
+ className: styles[ 'step-rate' ],
370
+ } )
371
+ ) : (
372
+ <span className={ styles[ 'step-rate' ] }>
373
+ { formatPercentage( step.rate ) }
374
+ </span>
375
+ ) }
376
+ </Stack>
377
+
378
+ { /* Funnel Bar */ }
382
379
  <Stack
383
- key={ step.id }
384
380
  direction="column"
385
- data-testid="funnel-step"
386
- className={ clsx(
387
- styles[ 'funnel-step' ],
388
- isColorPaletteResolved && styles[ 'funnel-step--animated' ],
389
- isBlurred && styles[ 'funnel-step--blurred' ]
390
- ) }
391
- gap="xl"
381
+ justify="flex-end"
382
+ className={ styles[ 'bar-container' ] }
383
+ onClick={ stepHandlers.get( step.id )?.onClick }
384
+ onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
385
+ role="button"
386
+ tabIndex={ isBlurred ? -1 : 0 }
387
+ aria-label={ step.label }
392
388
  >
393
- { /* Step Label and Rate */ }
394
- <Stack direction="column" gap="xs">
395
- { renderStepLabel ? (
396
- renderStepLabel( {
397
- step,
398
- index,
399
- className: styles[ 'step-label' ],
400
- } )
401
- ) : (
402
- <span className={ styles[ 'step-label' ] }>{ step.label }</span>
403
- ) }
404
- { renderStepRate ? (
405
- renderStepRate( {
406
- step,
407
- index,
408
- className: styles[ 'step-rate' ],
409
- } )
410
- ) : (
411
- <span className={ styles[ 'step-rate' ] }>
412
- { formatPercentage( step.rate ) }
413
- </span>
414
- ) }
415
- </Stack>
416
-
417
- { /* Funnel Bar */ }
418
- <Stack
419
- direction="column"
420
- justify="flex-end"
421
- className={ styles[ 'bar-container' ] }
422
- onClick={ stepHandlers.get( step.id )?.onClick }
423
- onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
424
- role="button"
425
- tabIndex={ isBlurred ? -1 : 0 }
426
- aria-label={ step.label }
427
- style={ { backgroundColor: barBackgroundColor } }
428
- >
429
- <div
430
- className={ clsx( styles[ 'funnel-bar' ], {
431
- [ styles[ 'funnel-bar--animated' ] ]:
432
- animation && ! loading && ! prefersReducedMotion,
433
- } ) }
434
- style={ {
435
- height: `${ barHeight }%`,
436
- backgroundColor: barColor,
437
- } }
438
- />
439
- </Stack>
389
+ <div
390
+ className={ clsx( styles[ 'funnel-bar' ], {
391
+ [ styles[ 'funnel-bar--animated' ] ]:
392
+ animation && ! loading && ! prefersReducedMotion,
393
+ } ) }
394
+ style={ {
395
+ height: `${ barHeight }%`,
396
+ backgroundColor: barColor,
397
+ } }
398
+ />
440
399
  </Stack>
441
- );
442
- } ) }
443
- </Stack>
444
- </ChartScopeContext.Provider>
445
- </Stack>
400
+ </Stack>
401
+ );
402
+ } ) }
403
+ </Stack>
404
+ </ChartScopeContext.Provider>
446
405
 
447
- { /* Tooltip Portal */ }
448
406
  { tooltipOpen &&
449
407
  tooltipData &&
450
408
  ( () => {
@@ -462,18 +420,16 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
462
420
  if ( ! tooltipContent ) return null;
463
421
 
464
422
  return (
465
- <TooltipInPortal
466
- // set this to random so it correctly updates with parent bounds
467
- key={ Math.random() }
423
+ <BoundedTooltip
468
424
  top={ tooltipTop }
469
425
  left={ tooltipLeft }
470
- className={ clsx( CHART_SCOPE_CLASS, styles[ 'tooltip-wrapper' ] ) }
426
+ className={ clsx( standaloneScopeClass, styles[ 'tooltip-wrapper' ] ) }
471
427
  >
472
428
  { tooltipContent }
473
- </TooltipInPortal>
429
+ </BoundedTooltip>
474
430
  );
475
431
  } )() }
476
- </>
432
+ </Stack>
477
433
  );
478
434
  };
479
435
 
@@ -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
  }