@automattic/charts 2.0.1 → 3.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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +109 -50
  3. package/dist/index.cjs +529 -233
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +118 -55
  6. package/dist/index.d.cts +90 -48
  7. package/dist/index.d.ts +90 -48
  8. package/dist/index.js +527 -235
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +25 -25
  13. package/src/charts/area-chart/private/overlays.tsx +3 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  15. package/src/charts/area-chart/types.ts +4 -3
  16. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  17. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  21. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  22. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  23. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
  26. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  28. package/src/charts/heatmap-chart/types.ts +8 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  30. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  32. package/src/charts/leaderboard-chart/types.ts +0 -2
  33. package/src/charts/line-chart/line-chart.module.scss +12 -7
  34. package/src/charts/line-chart/line-chart.tsx +32 -28
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  36. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  37. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  38. package/src/charts/line-chart/types.ts +4 -3
  39. package/src/charts/pie-chart/pie-chart.tsx +12 -14
  40. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  41. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  42. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
  43. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  44. package/src/charts/private/chart-instance-context/index.ts +3 -0
  45. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  46. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  47. package/src/charts/private/svg-empty-state/index.ts +1 -0
  48. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  49. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  50. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  51. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  52. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  53. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  54. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  55. package/src/components/legend/legend.tsx +4 -4
  56. package/src/components/legend/private/base-legend.module.scss +1 -1
  57. package/src/components/legend/private/base-legend.tsx +70 -19
  58. package/src/components/legend/test/legend.test.tsx +100 -4
  59. package/src/components/tooltip/accessible-tooltip.tsx +17 -4
  60. package/src/components/tooltip/base-tooltip.tsx +8 -1
  61. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  62. package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
  63. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  64. package/src/hooks/index.ts +1 -1
  65. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  66. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  67. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  68. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  69. package/src/hooks/use-xychart-theme.ts +6 -4
  70. package/src/index.ts +12 -3
  71. package/src/providers/chart-context/global-charts-provider.tsx +127 -30
  72. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  73. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  74. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  75. package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
  76. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  77. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  78. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  79. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  81. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  82. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  83. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  84. package/src/providers/chart-context/themes.ts +18 -27
  85. package/src/providers/chart-context/types.ts +7 -4
  86. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  87. package/src/providers/chart-scope/index.ts +3 -0
  88. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  89. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  90. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  91. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  92. package/src/providers/index.ts +1 -0
  93. package/src/styles/chart-scope-class.ts +6 -0
  94. package/src/styles/chart-scope.scss +83 -0
  95. package/src/styles/test/chart-scope.test.ts +165 -0
  96. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  97. package/src/types.ts +12 -34
  98. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  99. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  100. package/src/charts/private/grid-control/index.ts +0 -1
  101. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
  102. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -1,16 +1,18 @@
1
1
  import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
2
2
  import { Stack } from '@wordpress/ui';
3
3
  import clsx from 'clsx';
4
- import { type FC, useRef, useMemo, useEffect, useCallback, useContext } from 'react';
4
+ import { type FC, useRef, useMemo, useEffect, useState, useCallback, useContext } from 'react';
5
5
  import { usePrefersReducedMotion } from '../../hooks';
6
6
  import {
7
7
  GlobalChartsProvider,
8
8
  GlobalChartsContext,
9
+ ChartScopeContext,
9
10
  useChartId,
10
11
  useChartRegistration,
11
12
  useGlobalChartsTheme,
12
13
  useGlobalChartsContext,
13
14
  } from '../../providers';
15
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
14
16
  import { formatPercentage, hexToRgba } from '../../utils';
15
17
  import styles from './conversion-funnel-chart.module.scss';
16
18
  import { useFunnelSelection } from './private';
@@ -55,6 +57,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
55
57
  const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
56
58
  const chartRef = useRef< HTMLDivElement >( null );
57
59
  const selectedBarRef = useRef< HTMLDivElement | null >( null );
60
+ const [ scopeNode, setScopeNode ] = useState< HTMLElement | null >( null );
58
61
 
59
62
  // Use @visx/tooltip hooks for tooltip positioning
60
63
  const { tooltipData, tooltipLeft, tooltipTop, tooltipOpen, showTooltip, hideTooltip } =
@@ -74,6 +77,20 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
74
77
  scroll: true,
75
78
  } );
76
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).
87
+ const setChartRef = useCallback( ( node: HTMLDivElement | null ) => {
88
+ // Set containerRef for @visx coordinate system
89
+ portalContainerRefRef.current( node );
90
+ chartRef.current = node;
91
+ setScopeNode( node );
92
+ }, [] );
93
+
77
94
  // Wrapper to clear selectedBarRef after clearing selection
78
95
  const clearSelectionAndRef = useCallback( () => {
79
96
  clearSelection();
@@ -303,6 +320,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
303
320
  align="center"
304
321
  justify="center"
305
322
  data-testid="conversion-funnel-chart"
323
+ ref={ setScopeNode }
306
324
  className={ clsx(
307
325
  styles[ 'conversion-funnel-chart' ],
308
326
  loading && styles[ 'conversion-funnel-chart--loading' ],
@@ -326,11 +344,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
326
344
  direction="column"
327
345
  gap="xl"
328
346
  data-testid="conversion-funnel-chart"
329
- ref={ node => {
330
- // Set containerRef for @visx coordinate system
331
- portalContainerRef( node );
332
- chartRef.current = node;
333
- } }
347
+ ref={ setChartRef }
334
348
  className={ clsx(
335
349
  styles[ 'conversion-funnel-chart' ],
336
350
  loading && styles[ 'conversion-funnel-chart--loading' ],
@@ -338,89 +352,96 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
338
352
  ) }
339
353
  style={ { ...style, height: resolvedHeight } }
340
354
  >
341
- { /* Main Metric */ }
342
- { renderMainMetric ? (
343
- renderMainMetric( {
344
- mainRate,
345
- changeIndicator,
346
- className: styles[ 'main-metric' ],
347
- changeColor,
348
- } )
349
- ) : (
350
- <Stack direction="row" align="baseline" gap="sm" className={ styles[ 'main-metric' ] }>
351
- { renderDefaultMainMetric() }
352
- </Stack>
353
- ) }
354
-
355
- { /* Funnel Steps */ }
356
- <Stack direction="row" align="flex-end" gap="lg" className={ styles[ 'funnel-container' ] }>
357
- { steps.map( ( step, index ) => {
358
- const barHeight = ( step.rate / maxRate ) * 100;
359
- const { isBlurred } = getStepState( step.id );
360
-
361
- return (
362
- <Stack
363
- key={ step.id }
364
- direction="column"
365
- data-testid="funnel-step"
366
- className={ clsx(
367
- styles[ 'funnel-step' ],
368
- isColorPaletteResolved && styles[ 'funnel-step--animated' ],
369
- isBlurred && styles[ 'funnel-step--blurred' ]
370
- ) }
371
- gap="xl"
372
- >
373
- { /* Step Label and Rate */ }
374
- <Stack direction="column" gap="xs">
375
- { renderStepLabel ? (
376
- renderStepLabel( {
377
- step,
378
- index,
379
- className: styles[ 'step-label' ],
380
- } )
381
- ) : (
382
- <span className={ styles[ 'step-label' ] }>{ step.label }</span>
383
- ) }
384
- { renderStepRate ? (
385
- renderStepRate( {
386
- step,
387
- index,
388
- className: styles[ 'step-rate' ],
389
- } )
390
- ) : (
391
- <span className={ styles[ 'step-rate' ] }>
392
- { formatPercentage( step.rate ) }
393
- </span>
394
- ) }
395
- </Stack>
396
-
397
- { /* Funnel Bar */ }
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
+
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 (
398
382
  <Stack
383
+ key={ step.id }
399
384
  direction="column"
400
- justify="flex-end"
401
- className={ styles[ 'bar-container' ] }
402
- onClick={ stepHandlers.get( step.id )?.onClick }
403
- onKeyDown={ stepHandlers.get( step.id )?.onKeyDown }
404
- role="button"
405
- tabIndex={ isBlurred ? -1 : 0 }
406
- aria-label={ step.label }
407
- style={ { backgroundColor: barBackgroundColor } }
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"
408
392
  >
409
- <div
410
- className={ clsx( styles[ 'funnel-bar' ], {
411
- [ styles[ 'funnel-bar--animated' ] ]:
412
- animation && ! loading && ! prefersReducedMotion,
413
- } ) }
414
- style={ {
415
- height: `${ barHeight }%`,
416
- backgroundColor: barColor,
417
- } }
418
- />
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>
419
440
  </Stack>
420
- </Stack>
421
- );
422
- } ) }
423
- </Stack>
441
+ );
442
+ } ) }
443
+ </Stack>
444
+ </ChartScopeContext.Provider>
424
445
  </Stack>
425
446
 
426
447
  { /* Tooltip Portal */ }
@@ -446,7 +467,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
446
467
  key={ Math.random() }
447
468
  top={ tooltipTop }
448
469
  left={ tooltipLeft }
449
- className={ styles[ 'tooltip-wrapper' ] }
470
+ className={ clsx( CHART_SCOPE_CLASS, styles[ 'tooltip-wrapper' ] ) }
450
471
  >
451
472
  { tooltipContent }
452
473
  </TooltipInPortal>
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { useContext } from 'react';
3
+ import { ChartScopeContext } from '../../../providers/chart-scope';
4
+ import ConversionFunnelChart from '../conversion-funnel-chart';
5
+ import type { FunnelStep } from '../types';
6
+
7
+ const steps: FunnelStep[] = [
8
+ { id: 'sessions', label: 'Sessions', rate: 100, count: 10000 },
9
+ { id: 'purchase', label: 'Purchase', rate: 10.3, count: 1030 },
10
+ ];
11
+
12
+ const ScopeProbe = () => {
13
+ const scopeNode = useContext( ChartScopeContext );
14
+ return <span data-testid="scope-probe">{ scopeNode ? 'has-scope' : 'no-scope' }</span>;
15
+ };
16
+
17
+ const renderScopeProbe = () => <ScopeProbe />;
18
+
19
+ describe( 'ConversionFunnelChart chart scope', () => {
20
+ it( 'publishes a non-null scope node after transitioning from empty to populated steps', () => {
21
+ // The normal loading pattern: steps arrives empty first (empty-state branch renders), then populated (main branch renders). The scope node must be republished on that transition, not frozen at whatever the first mount saw.
22
+ const { rerender } = render(
23
+ <ConversionFunnelChart mainRate={ 0 } steps={ [] } renderMainMetric={ renderScopeProbe } />
24
+ );
25
+
26
+ rerender(
27
+ <ConversionFunnelChart
28
+ mainRate={ 10.3 }
29
+ steps={ steps }
30
+ renderMainMetric={ renderScopeProbe }
31
+ />
32
+ );
33
+
34
+ expect( screen.getByTestId( 'scope-probe' ) ).toHaveTextContent( 'has-scope' );
35
+ } );
36
+ } );
@@ -1,3 +1,11 @@
1
1
  .container {
2
2
  position: relative;
3
+
4
+ // Google gives its HTML tooltip no width, so the box shrink-to-fits against
5
+ // whatever room is left beside the hovered feature. Near a map edge that
6
+ // breaks even a short label across lines. Sizing to content keeps each line
7
+ // whole and grows the box instead.
8
+ :global(.google-visualization-tooltip) {
9
+ white-space: nowrap;
10
+ }
3
11
  }
@@ -8,7 +8,12 @@ import { Chart, type GoogleChartPackages, type ReactGoogleChartEvent } from 'rea
8
8
  /**
9
9
  * Internal dependencies
10
10
  */
11
- import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } from '../../providers';
11
+ import {
12
+ GlobalChartsContext,
13
+ GlobalChartsProvider,
14
+ useChartScopeElement,
15
+ useGlobalChartsContext,
16
+ } from '../../providers';
12
17
  import { lightenHexColor, normalizeColorToHex } from '../../utils/color-utils';
13
18
  import { resolveCssVariable } from '../../utils/resolve-css-var';
14
19
  import { sanitizeHtml } from '../../utils/sanitize-html';
@@ -146,6 +151,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
146
151
  backgroundColor,
147
152
  },
148
153
  } = useGlobalChartsContext();
154
+ const scopeElement = useChartScopeElement();
149
155
  const containerRef = useRef< HTMLDivElement >( null );
150
156
  const reportedErrorIdsRef = useRef< Set< string > >( new Set() );
151
157
 
@@ -204,11 +210,13 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
204
210
  // Google charts doesn't accept CSS variables, so we need to convert them to hex colors
205
211
  const fullColorHex = getElementStyles( { index: 0 } ).color;
206
212
  const lightColorHex = lightenHexColor( fullColorHex, 0.8 );
207
- // Use normalizeColorToHex to ensure HSL/RGB values from CSS variables are converted to hex
213
+ // 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.
208
214
  const backgroundColorHex =
209
- normalizeColorToHex( backgroundColor, null, resolveCssVariable ) || DEFAULT_BACKGROUND_COLOR;
215
+ normalizeColorToHex( backgroundColor, scopeElement, resolveCssVariable ) ||
216
+ DEFAULT_BACKGROUND_COLOR;
210
217
  const defaultFillColorHex =
211
- normalizeColorToHex( featureFillColor, null, resolveCssVariable ) || DEFAULT_FEATURE_FILL_COLOR;
218
+ normalizeColorToHex( featureFillColor, scopeElement, resolveCssVariable ) ||
219
+ DEFAULT_FEATURE_FILL_COLOR;
212
220
 
213
221
  // Identify HTML tooltip column indices and sanitize their content to prevent XSS.
214
222
  const sanitizedData = useMemo( () => {
@@ -19,13 +19,17 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
19
19
 
20
20
  .heatmap-chart__empty {
21
21
  padding: var(--wpds-dimension-padding-lg, 16px);
22
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
22
+ color: var(--a8c-charts-color-label-secondary);
23
23
  }
24
24
 
25
25
  // Track template is set inline because CSS `repeat()` won't take a `var()`
26
26
  // count. ARIA rows are `display: contents` so their cells join this grid.
27
27
  .heatmap-chart__grid {
28
28
  display: grid;
29
+ // --a8c-charts-dimension-heatmap-cell-gap is component-emitted from JS per
30
+ // 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.
29
33
  gap: var(--a8c-charts-dimension-heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
30
34
  flex: 1 1 0;
31
35
  width: 100%;
@@ -35,7 +39,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
35
39
  &:focus-visible {
36
40
  outline:
37
41
  $focus-ring-width solid
38
- var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
42
+ var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
39
43
  outline-offset: 2px;
40
44
  border-radius: var(--wpds-border-radius-sm, 2px);
41
45
  }
@@ -63,7 +67,7 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
63
67
 
64
68
  .heatmap-chart__col-label,
65
69
  .heatmap-chart__row-label {
66
- color: var(--a8c-charts-color-label-secondary, var(--wpds-color-foreground-content-neutral-weak, #707070));
70
+ color: var(--a8c-charts-color-label-secondary);
67
71
  font-size: var(--wpds-typography-font-size-xs, 11px);
68
72
  white-space: nowrap;
69
73
  overflow: visible;
@@ -87,9 +91,9 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
87
91
  justify-content: center;
88
92
  min-width: 0;
89
93
  min-height: 0;
90
- border-radius: var(--wpds-border-radius-sm, 2px);
94
+ border-radius: var(--a8c-charts-border-radius-cell);
91
95
  // Empty (no-data) default; cells with a value override it below.
92
- background: var(--a8c-charts-color-track, var(--wpds-color-background-track-neutral-weak, #f0f0f0));
96
+ background: var(--a8c-charts-color-track);
93
97
  }
94
98
 
95
99
  // An empty grid slot (calendar ragged edges): keeps its track but paints and
@@ -99,6 +103,15 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
99
103
  pointer-events: none;
100
104
  }
101
105
 
106
+ // Grid filler: keeps its slot so the grid reads as continuous, but stands for a
107
+ // day nothing was measured for — so it captures no pointer, and its track is
108
+ // faded to keep it apart from a measured day that simply scored zero. Mixed
109
+ // towards `transparent` rather than a lighter grey so it holds in either theme.
110
+ .heatmap-chart__cell--placeholder {
111
+ pointer-events: none;
112
+ background: color-mix(in sRGB, var(--a8c-charts-color-track) 45%, transparent);
113
+ }
114
+
102
115
  // Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
103
116
  // an empty cell.
104
117
  .heatmap-chart__cell--filled {
@@ -108,24 +121,24 @@ $focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-fo
108
121
  .heatmap-chart__cell--selected {
109
122
  outline:
110
123
  $focus-ring-width solid
111
- var(--a8c-charts-color-focus, var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9)));
124
+ var(--wpds-color-stroke-focus, var(--wp-admin-theme-color, #3858e9));
112
125
  outline-offset: calc(-1 * #{$focus-ring-width});
113
126
  }
114
127
 
115
128
  .heatmap-chart__cell-value {
116
- color: var(--a8c-charts-color-label, var(--wpds-color-foreground-content-neutral, #1e1e1e));
129
+ color: var(--a8c-charts-color-label);
117
130
  font-size: var(--wpds-typography-font-size-md, 13px);
118
131
  }
119
132
 
120
133
  // Light text on fills dark enough to out-contrast dark text (decided in JS from
121
134
  // the blended fill luminance).
122
135
  .heatmap-chart__cell--strong .heatmap-chart__cell-value {
123
- color: var(--a8c-charts-color-label-inverse, var(--wpds-color-foreground-interactive-neutral-strong, #f0f0f0));
136
+ color: var(--a8c-charts-color-label-inverse);
124
137
  }
125
138
 
126
139
  .heatmap-chart__legend-swatch {
127
140
  width: var(--wpds-dimension-size-3xs, 12px);
128
141
  height: var(--wpds-dimension-size-3xs, 12px);
129
- border-radius: var(--wpds-border-radius-sm, 2px);
142
+ border-radius: var(--a8c-charts-border-radius-cell);
130
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));
131
144
  }
@@ -6,9 +6,11 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r
6
6
  import {
7
7
  GlobalChartsProvider,
8
8
  useChartId,
9
+ useChartScopeElement,
9
10
  useGlobalChartsContext,
10
11
  GlobalChartsContext,
11
12
  } from '../../providers';
13
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
12
14
  import { attachSubComponents } from '../../utils';
13
15
  import {
14
16
  isValidHexColor,
@@ -16,10 +18,11 @@ import {
16
18
  normalizeColorToHex,
17
19
  prefersLightText,
18
20
  } from '../../utils/color-utils';
21
+ import { resolveCssVariable } from '../../utils/resolve-css-var';
19
22
  import { Center } from '../private/center';
20
23
  import { useChartChildren } from '../private/chart-composition';
24
+ import { ChartInstanceContext } from '../private/chart-instance-context';
21
25
  import { ChartLayout } from '../private/chart-layout';
22
- import { SingleChartContext } from '../private/single-chart-context';
23
26
  import { withResponsive } from '../private/with-responsive';
24
27
  import styles from './heatmap-chart.module.scss';
25
28
  import {
@@ -58,7 +61,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
58
61
  children,
59
62
  } ) => {
60
63
  const chartId = useChartId( providedChartId );
61
- const { getElementStyles, resolveThemeColor, theme } = useGlobalChartsContext();
64
+ const { getElementStyles, theme } = useGlobalChartsContext();
65
+ const scopeElement = useChartScopeElement();
62
66
  const { heatmapChart: heatmapChartSettings } = theme;
63
67
  const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
64
68
 
@@ -79,9 +83,12 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
79
83
  overrideColor: primaryColor || heatmapChartSettings.primaryColor,
80
84
  } );
81
85
 
82
- // Resolve the background in the provider's theme scope so the blended-fill text
83
- // color tracks a themed (e.g. dark) background.
84
- const chartBackgroundHex = resolveThemeColor( theme.backgroundColor );
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.
87
+ const chartBackgroundHex = normalizeColorToHex(
88
+ theme.backgroundColor,
89
+ scopeElement,
90
+ resolveCssVariable
91
+ );
85
92
 
86
93
  // Choose text color from the blended fill, not the raw value.
87
94
  // If either color cannot resolve to hex, keep dark text.
@@ -129,8 +136,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
129
136
  hideTooltip();
130
137
  }, [ hideTooltip ] );
131
138
 
132
- const isCellHidden = useCallback(
133
- ( col: number, row: number ) => data[ col ]?.data[ row ]?.hidden === true,
139
+ // Both empty-slot kinds are skipped by navigation: `hidden` paints nothing,
140
+ // `placeholder` paints an empty cell, and neither has a value to report.
141
+ const isCellInert = useCallback(
142
+ ( col: number, row: number ) => {
143
+ const cell = data[ col ]?.data[ row ];
144
+ return cell?.hidden === true || cell?.placeholder === true;
145
+ },
134
146
  [ data ]
135
147
  );
136
148
 
@@ -156,7 +168,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
156
168
  // Start at the first navigable cell (a calendar's leading edge
157
169
  // slots may be hidden).
158
170
  for ( let index = 0; index < columns * rows; index++ ) {
159
- if ( ! isCellHidden( Math.floor( index / rows ), index % rows ) ) {
171
+ if ( ! isCellInert( Math.floor( index / rows ), index % rows ) ) {
160
172
  setSelectedIndex( index );
161
173
  return;
162
174
  }
@@ -184,7 +196,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
184
196
  do {
185
197
  col += stepCol;
186
198
  row += stepRow;
187
- } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellHidden( col, row ) );
199
+ } while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellInert( col, row ) );
188
200
 
189
201
  if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
190
202
  return;
@@ -192,7 +204,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
192
204
 
193
205
  setSelectedIndex( col * rows + row );
194
206
  },
195
- [ rows, columns, selectedIndex, hideTooltip, isCellHidden ]
207
+ [ rows, columns, selectedIndex, hideTooltip, isCellInert ]
196
208
  );
197
209
 
198
210
  const handleCellMouseMove = useCallback(
@@ -306,7 +318,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
306
318
 
307
319
  return (
308
320
  <HeatmapContext.Provider value={ heatmapContext }>
309
- <SingleChartContext.Provider value={ { chartId } }>
321
+ <ChartInstanceContext.Provider value={ { chartId } }>
310
322
  <ChartLayout
311
323
  legendPosition="bottom"
312
324
  // Legend renders via trailingContent, not the legend slot.
@@ -384,6 +396,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
384
396
  );
385
397
  }
386
398
 
399
+ // Filler: drawn like an empty cell so the grid fills its
400
+ // container, but it stands for a day nothing was measured
401
+ // for, so it reports nothing to a pointer or a screen
402
+ // reader either.
403
+ if ( cell?.placeholder ) {
404
+ return (
405
+ <div
406
+ key={ `cell-${ columnIndex }-${ rowIndex }` }
407
+ data-testid="heatmap-cell-placeholder"
408
+ aria-hidden="true"
409
+ className={ clsx(
410
+ styles[ 'heatmap-chart__cell' ],
411
+ styles[ 'heatmap-chart__cell--placeholder' ]
412
+ ) }
413
+ />
414
+ );
415
+ }
416
+
387
417
  const value = cell?.value ?? null;
388
418
  const present = isPresent( value );
389
419
  const normalized = present ? getNormalizedValue( value, extent ) : 0;
@@ -443,13 +473,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
443
473
  </div>
444
474
  { withTooltips && tooltipOpen && tooltipData && (
445
475
  <TooltipInPortal top={ tooltipTop } left={ tooltipLeft }>
446
- <div role="tooltip" tabIndex={ -1 }>
476
+ <div className={ CHART_SCOPE_CLASS } role="tooltip" tabIndex={ -1 }>
447
477
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
448
478
  </div>
449
479
  </TooltipInPortal>
450
480
  ) }
451
481
  </ChartLayout>
452
- </SingleChartContext.Provider>
482
+ </ChartInstanceContext.Provider>
453
483
  </HeatmapContext.Provider>
454
484
  );
455
485
  };