@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.
- package/CHANGELOG.md +29 -0
- package/TOKENS.md +109 -50
- package/dist/index.cjs +529 -233
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +118 -55
- package/dist/index.d.cts +90 -48
- package/dist/index.d.ts +90 -48
- package/dist/index.js +527 -235
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.module.scss +8 -3
- package/src/charts/area-chart/area-chart.tsx +25 -25
- package/src/charts/area-chart/private/overlays.tsx +3 -3
- package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
- package/src/charts/area-chart/types.ts +4 -3
- package/src/charts/bar-chart/bar-chart.module.scss +14 -6
- package/src/charts/bar-chart/bar-chart.tsx +21 -15
- package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
- package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
- package/src/charts/geo-chart/geo-chart.module.scss +8 -0
- package/src/charts/geo-chart/geo-chart.tsx +12 -4
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
- package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
- package/src/charts/heatmap-chart/types.ts +8 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
- package/src/charts/leaderboard-chart/types.ts +0 -2
- package/src/charts/line-chart/line-chart.module.scss +12 -7
- package/src/charts/line-chart/line-chart.tsx +32 -28
- package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
- package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
- package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
- package/src/charts/line-chart/types.ts +4 -3
- package/src/charts/pie-chart/pie-chart.tsx +12 -14
- package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
- package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
- package/src/charts/private/chart-instance-context/index.ts +3 -0
- package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
- package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
- package/src/charts/private/svg-empty-state/index.ts +1 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
- package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
- package/src/components/legend/legend.tsx +4 -4
- package/src/components/legend/private/base-legend.module.scss +1 -1
- package/src/components/legend/private/base-legend.tsx +70 -19
- package/src/components/legend/test/legend.test.tsx +100 -4
- package/src/components/tooltip/accessible-tooltip.tsx +17 -4
- package/src/components/tooltip/base-tooltip.tsx +8 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
- package/src/components/trend-indicator/trend-indicator.tsx +3 -0
- package/src/hooks/index.ts +1 -1
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
- package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
- package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
- package/src/hooks/use-xychart-theme.ts +6 -4
- package/src/index.ts +12 -3
- package/src/providers/chart-context/global-charts-provider.tsx +127 -30
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
- package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
- package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
- package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
- package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
- package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
- package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
- package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
- package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
- package/src/providers/chart-context/themes.ts +18 -27
- package/src/providers/chart-context/types.ts +7 -4
- package/src/providers/chart-scope/chart-scope-context.ts +8 -0
- package/src/providers/chart-scope/index.ts +3 -0
- package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
- package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
- package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
- package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
- package/src/providers/index.ts +1 -0
- package/src/styles/chart-scope-class.ts +6 -0
- package/src/styles/chart-scope.scss +83 -0
- package/src/styles/test/chart-scope.test.ts +165 -0
- package/src/styles/test/wpds-fallbacks.test.ts +131 -0
- package/src/types.ts +12 -34
- package/src/charts/private/grid-control/grid-control.module.scss +0 -8
- package/src/charts/private/grid-control/grid-control.tsx +0 -26
- package/src/charts/private/grid-control/index.ts +0 -1
- package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
- 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={
|
|
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
|
-
{
|
|
342
|
-
|
|
343
|
-
renderMainMetric(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
{
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
401
|
-
className={
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
</
|
|
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 {
|
|
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,
|
|
215
|
+
normalizeColorToHex( backgroundColor, scopeElement, resolveCssVariable ) ||
|
|
216
|
+
DEFAULT_BACKGROUND_COLOR;
|
|
210
217
|
const defaultFillColorHex =
|
|
211
|
-
normalizeColorToHex( featureFillColor,
|
|
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
|
|
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(--
|
|
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
|
|
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(--
|
|
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
|
|
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(--
|
|
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
|
|
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
|
|
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(--
|
|
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,
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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 ( !
|
|
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 &&
|
|
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,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
482
|
+
</ChartInstanceContext.Provider>
|
|
453
483
|
</HeatmapContext.Provider>
|
|
454
484
|
);
|
|
455
485
|
};
|