@automattic/charts 4.6.0 → 4.8.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 (91) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/TOKENS.md +15 -10
  3. package/dist/index.cjs +427 -211
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +40 -26
  6. package/dist/index.d.cts +69 -29
  7. package/dist/index.d.ts +69 -29
  8. package/dist/index.js +427 -213
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +1 -1
  13. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +1 -1
  14. package/src/charts/bar-chart/bar-chart.module.scss +7 -0
  15. package/src/charts/bar-chart/bar-chart.tsx +20 -14
  16. package/src/charts/bar-chart/private/comparison-bars.tsx +1 -0
  17. package/src/charts/bar-chart/test/bar-chart.test.tsx +25 -0
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +5 -3
  20. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +20 -1
  21. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  22. package/src/charts/heatmap-chart/heatmap-chart.module.scss +16 -7
  23. package/src/charts/heatmap-chart/heatmap-chart.tsx +80 -53
  24. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  25. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  26. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  27. package/src/charts/heatmap-chart/private/index.ts +2 -1
  28. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  29. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +221 -25
  30. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  31. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  32. package/src/charts/heatmap-chart/types.ts +6 -9
  33. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  34. package/src/charts/line-chart/line-chart.module.scss +0 -5
  35. package/src/charts/line-chart/line-chart.tsx +10 -42
  36. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  37. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  38. package/src/charts/pie-chart/pie-chart.tsx +26 -80
  39. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  40. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  41. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  42. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  43. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  44. package/src/charts/private/label-text-color.ts +116 -0
  45. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  46. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  47. package/src/charts/private/test/label-text-color.test.ts +189 -0
  48. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  49. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  50. package/src/components/tooltip/base-tooltip.tsx +31 -30
  51. package/src/components/tooltip/index.ts +3 -1
  52. package/src/components/tooltip/private/bounded-tooltip.tsx +32 -13
  53. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  54. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  55. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  56. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  57. package/src/components/tooltip/test/tool-tip.test.tsx +10 -3
  58. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  59. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  60. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  61. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +286 -356
  62. package/src/components/tooltip/tooltip-box.module.scss +21 -0
  63. package/src/components/tooltip/tooltip-box.tsx +43 -0
  64. package/src/components/tooltip/types.ts +7 -1
  65. package/src/components/tooltip/xy-chart-tooltip.tsx +411 -301
  66. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  67. package/src/hooks/index.ts +1 -0
  68. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  69. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  70. package/src/hooks/use-chart-margin.tsx +21 -18
  71. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  72. package/src/hooks/use-xychart-theme.ts +4 -12
  73. package/src/index.ts +8 -3
  74. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  75. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +3 -5
  76. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  77. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  78. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  79. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  81. package/src/styles/chart-scope-class.ts +5 -0
  82. package/src/styles/chart-scope.scss +10 -6
  83. package/src/styles/test/chart-scope.test.ts +3 -3
  84. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  85. package/src/utils/color-utils.ts +2 -1
  86. package/src/utils/test/date-formatting.test.ts +0 -10
  87. package/src/visx/types.ts +1 -1
  88. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
  89. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  90. package/src/components/tooltip/base-tooltip.module.scss +0 -20
  91. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -0,0 +1,74 @@
1
+ import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
3
+ import { mixHexColors, relativeLuminance } from '../../../utils/color-utils';
4
+
5
+ /** Contrast the highest step aims for against the background, so the steps stay apart. */
6
+ export const HEATMAP_HIGH_CONTRAST = 9;
7
+
8
+ const STEPS = 200;
9
+
10
+ export type HeatmapScale = {
11
+ low: string;
12
+ high: string;
13
+ };
14
+
15
+ // Measured on the rounded hex the browser paints, so an endpoint never lands under its threshold.
16
+ const firstMeeting = ( from: string, to: string, meets: ( color: string ) => boolean ) => {
17
+ for ( let step = 0; step <= STEPS; step++ ) {
18
+ const color = mixHexColors( from, to, step / STEPS );
19
+ if ( meets( color ) ) {
20
+ return color;
21
+ }
22
+ }
23
+ return null;
24
+ };
25
+
26
+ /**
27
+ * The two ends of the heatmap fill scale: 3:1 against the background and the empty cell at the
28
+ * low end, toward 9:1 against the background at the high end.
29
+ *
30
+ * @param primary - The resolved primary color, as six-digit hex.
31
+ * @param background - The resolved chart background, as six-digit hex.
32
+ * @param emptyCell - The resolved empty-cell color, as six-digit hex, when it is opaque.
33
+ * @return The scale ends, as six-digit hex.
34
+ */
35
+ export const getHeatmapScale = (
36
+ primary: string,
37
+ background: string,
38
+ emptyCell?: string | null
39
+ ): HeatmapScale => {
40
+ const primaryStandsOut = contrastRatio( primary, background ) >= MIN_BACKGROUND_CONTRAST;
41
+ // The scale deepens to black or white on the primary's own side of the background, so it never
42
+ // crosses it. A primary too close to the background takes whichever contrasts more.
43
+ const towardWhite = primaryStandsOut
44
+ ? relativeLuminance( primary ) > relativeLuminance( background )
45
+ : contrastRatio( '#ffffff', background ) > contrastRatio( '#000000', background );
46
+ const extreme = towardWhite ? '#ffffff' : '#000000';
47
+
48
+ const lowAgainst = ( references: string[] ) => {
49
+ const meets = ( color: string ) =>
50
+ references.every( reference => contrastRatio( color, reference ) >= MIN_BACKGROUND_CONTRAST );
51
+ return (
52
+ ( primaryStandsOut ? firstMeeting( background, primary, meets ) : null ) ??
53
+ firstMeeting( primary, extreme, meets )
54
+ );
55
+ };
56
+ // Only `high` can miss: black or white tops out near 4.58:1 on a mid-tone background.
57
+ const high =
58
+ firstMeeting(
59
+ primary,
60
+ extreme,
61
+ color => contrastRatio( color, background ) >= HEATMAP_HIGH_CONTRAST
62
+ ) ?? extreme;
63
+
64
+ // The lowest step sits beside empty cells, so it stands apart from them too, unless an
65
+ // empty-cell color far from the background would push it past the high end.
66
+ const againstEmptyCell = emptyCell ? lowAgainst( [ background, emptyCell ] ) : null;
67
+ const low =
68
+ againstEmptyCell &&
69
+ contrastRatio( againstEmptyCell, background ) <= contrastRatio( high, background )
70
+ ? againstEmptyCell
71
+ : lowAgainst( [ background ] );
72
+
73
+ return { low: low ?? extreme, high };
74
+ };
@@ -1,4 +1,5 @@
1
- export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
1
+ export { getValueExtent, getNormalizedValue, isEmptyValue, isPresent } from './use-heatmap-colors';
2
+ export { getHeatmapScale } from './heatmap-scale';
2
3
  export { HeatmapLegend } from './heatmap-legend';
3
4
  export { HeatmapContext } from './heatmap-context';
4
5
  export type { HeatmapLegendProps } from './heatmap-legend';
@@ -4,14 +4,16 @@ export const isPresent = ( value: number | null | undefined ): value is number =
4
4
  value !== null && value !== undefined && ! isNaN( value );
5
5
 
6
6
  /**
7
- * Get the min and max values from heatmap data, ignoring null/NaN. Summary
8
- * columns stay out: a roll-up on the scale would flatten every real cell.
7
+ * Get the min and max values from heatmap data, ignoring null/NaN and the zeros
8
+ * `isEmptyValue` paints as empty. Summary columns stay out: a roll-up on the
9
+ * scale would flatten every real cell.
9
10
  * @param data - The heatmap columns
10
11
  * @return Tuple of [min, max] values
11
12
  */
12
13
  export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
13
14
  let min = Infinity;
14
15
  let max = -Infinity;
16
+ let hasZero = false;
15
17
  for ( const column of data ) {
16
18
  if ( column.summary ) {
17
19
  continue;
@@ -20,6 +22,10 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
20
22
  if ( ! isPresent( cell.value ) ) {
21
23
  continue;
22
24
  }
25
+ if ( cell.value === 0 ) {
26
+ hasZero = true;
27
+ continue;
28
+ }
23
29
  if ( cell.value < min ) {
24
30
  min = cell.value;
25
31
  }
@@ -28,17 +34,29 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
28
34
  }
29
35
  }
30
36
  }
37
+ if ( hasZero && min < 0 ) {
38
+ max = Math.max( max, 0 );
39
+ }
31
40
  if ( min === Infinity ) {
32
41
  return [ 0, 0 ];
33
42
  }
34
43
  return [ min, max ];
35
44
  };
36
45
 
46
+ /**
47
+ * Whether a value paints as an empty cell: a zero in data with no negatives
48
+ * counts nothing, so it must not look like the lowest step of activity.
49
+ *
50
+ * @param value - The cell value
51
+ * @param extent - The extent from `getValueExtent`
52
+ * @return True when the cell takes the empty-cell color
53
+ */
54
+ export const isEmptyValue = ( value: number, extent: [ number, number ] ): boolean =>
55
+ value === 0 && extent[ 0 ] >= 0;
56
+
37
57
  /**
38
58
  * Normalize a value to 0–1 within the extent. A flat extent (min === max)
39
- * maps to 1 — every cell is equally the "highest" — except an all-zero
40
- * extent, which maps to 0 so a no-activity grid renders at the scale's
41
- * bottom instead of full intensity.
59
+ * maps to 1, since every cell is equally the "highest".
42
60
  *
43
61
  * @param value - The value to normalize
44
62
  * @param extent - Tuple of [min, max] values for the normalization range
@@ -47,7 +65,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
47
65
  export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
48
66
  const [ min, max ] = extent;
49
67
  if ( min === max ) {
50
- return max === 0 ? 0 : 1;
68
+ return 1;
51
69
  }
52
70
  return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
53
71
  };
@@ -85,34 +85,22 @@ describe( 'HeatmapChart', () => {
85
85
  await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
86
86
  } );
87
87
 
88
- test( 'draws the light tooltip box by default', async () => {
89
- renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
90
- await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
91
- await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
92
- const box = screen.getByTestId( 'bounded-tooltip' );
93
- expect( box ).not.toHaveClass( 'surface' );
94
- expect( box ).toHaveStyle( { backgroundColor: 'rgb(255, 255, 255)' } );
95
- expect( box ).toHaveStyle( { zIndex: 3 } );
96
- } );
97
-
98
- test( 'draws the dark variant on the package tooltip surface, not the visx box', async () => {
99
- renderChart( {
100
- withTooltips: true,
101
- tooltipVariant: 'dark',
102
- rowLabels: [ 'Mon', 'Tue', 'Wed' ],
103
- } );
104
- await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
105
- await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
106
- const box = screen.getByTestId( 'bounded-tooltip' );
107
- expect( box ).toHaveClass( 'surface' );
108
- expect( box ).not.toHaveStyle( { backgroundColor: 'rgb(255, 255, 255)' } );
109
- expect( box ).not.toHaveStyle( { color: 'rgb(102, 102, 102)' } );
110
- } );
88
+ test.each( [ undefined, 'light', 'dark' ] as const )(
89
+ 'draws the tooltip on the package surface for tooltipVariant %s',
90
+ async tooltipVariant => {
91
+ renderChart( { withTooltips: true, tooltipVariant, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
92
+ await userEvent.setup().hover( screen.getAllByTestId( 'heatmap-cell' )[ 0 ] );
93
+ await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
94
+ const box = screen.getByTestId( 'bounded-tooltip' );
95
+ expect( box ).toHaveClass( 'surface' );
96
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background' ) );
97
+ expect( box ).toHaveStyle( { zIndex: 3 } );
98
+ }
99
+ );
111
100
 
112
- test( 'merges tooltipStyle over the variant box styles', async () => {
101
+ test( 'merges tooltipStyle over the box styles', async () => {
113
102
  renderChart( {
114
103
  withTooltips: true,
115
- tooltipVariant: 'dark',
116
104
  tooltipStyle: { padding: '2px 4px' },
117
105
  rowLabels: [ 'Mon', 'Tue', 'Wed' ],
118
106
  } );
@@ -184,6 +172,26 @@ describe( 'HeatmapChart', () => {
184
172
  ).toHaveLength( 4 );
185
173
  } );
186
174
 
175
+ test( 'paints the legend on the same scale as the cells', () => {
176
+ render(
177
+ <GlobalChartsProvider>
178
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9">
179
+ <HeatmapChart.Legend />
180
+ </HeatmapChart>
181
+ </GlobalChartsProvider>
182
+ );
183
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
184
+
185
+ for ( const swatch of screen.getAllByTestId( 'heatmap-legend-swatch' ) ) {
186
+ for ( const end of [ 'primary', 'low', 'high' ] ) {
187
+ const property = `--a8c-charts-color-heatmap-${ end }`;
188
+ expect( swatch.style.getPropertyValue( property ) ).toBe(
189
+ grid.style.getPropertyValue( property )
190
+ );
191
+ }
192
+ }
193
+ } );
194
+
187
195
  test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
188
196
  renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
189
197
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -421,6 +429,78 @@ describe( 'HeatmapChart', () => {
421
429
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
422
430
  } );
423
431
 
432
+ test( 'leaves the fill to the stylesheet when the primary color cannot resolve to hex', () => {
433
+ render(
434
+ <GlobalChartsProvider>
435
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="oklch(0.6 0.2 260)">
436
+ <HeatmapChart.Legend />
437
+ </HeatmapChart>
438
+ </GlobalChartsProvider>
439
+ );
440
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
441
+ const swatches = screen.getAllByTestId( 'heatmap-legend-swatch' );
442
+
443
+ for ( const element of [ grid, ...swatches ] ) {
444
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe(
445
+ 'oklch(0.6 0.2 260)'
446
+ );
447
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '' );
448
+ expect( element.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '' );
449
+ }
450
+ expect( screen.getByText( '4' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
451
+ } );
452
+
453
+ test( 'builds the scale for white on a see-through background, as the palette does', () => {
454
+ const scope = document.createElement( 'div' );
455
+ scope.style.setProperty( '--a8c-charts-color-background', 'transparent' );
456
+ document.body.appendChild( scope );
457
+
458
+ render(
459
+ <GlobalChartsProvider>
460
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } primaryColor="#3858e9" />
461
+ </GlobalChartsProvider>,
462
+ { container: scope }
463
+ );
464
+ const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
465
+
466
+ expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-low' ) ).toBe( '#6c83ef' );
467
+ expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-high' ) ).toBe( '#283ea5' );
468
+
469
+ document.body.removeChild( scope );
470
+ } );
471
+
472
+ test( 'reads the computed style once per re-render for the fill scale', () => {
473
+ const { rerender } = renderChart();
474
+ const spy = jest.spyOn( window, 'getComputedStyle' );
475
+
476
+ rerender(
477
+ <GlobalChartsProvider>
478
+ <HeatmapChart width={ 500 } height={ 300 } data={ data } />
479
+ </GlobalChartsProvider>
480
+ );
481
+
482
+ expect( spy ).toHaveBeenCalledTimes( 1 );
483
+ spy.mockRestore();
484
+ } );
485
+
486
+ test( 'paints a zero as an empty cell, still announcing its value', () => {
487
+ renderChart();
488
+ const zero = screen.getByRole( 'gridcell', { name: 'W2: 0' } );
489
+
490
+ expect( zero ).not.toHaveClass( 'heatmap-chart__cell--filled' );
491
+ expect( zero.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '' );
492
+ expect( screen.getByRole( 'gridcell', { name: 'W1: 1' } ) ).toHaveClass(
493
+ 'heatmap-chart__cell--filled'
494
+ );
495
+ } );
496
+
497
+ test( 'puts the lowest non-zero value on the lowest step', () => {
498
+ renderChart();
499
+ const lowest = screen.getByRole( 'gridcell', { name: 'W1: 1' } );
500
+
501
+ expect( lowest.style.getPropertyValue( '--a8c-charts-heatmap-cell-intensity' ) ).toBe( '0' );
502
+ } );
503
+
424
504
  test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
425
505
  const scope = document.createElement( 'div' );
426
506
  scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
@@ -480,6 +560,122 @@ const mockRects = () =>
480
560
  } as DOMRect;
481
561
  } );
482
562
 
563
+ describe( 'HeatmapChart value text contrast', () => {
564
+ // The low end, a mid-tone, and the high end of the scale.
565
+ const scale: HeatmapColumn[] = [
566
+ { label: 'W1', data: [ { value: 1 }, { value: 30 }, { value: 100 } ] },
567
+ ];
568
+
569
+ let injectedStyle: HTMLStyleElement | null = null;
570
+
571
+ afterEach( () => {
572
+ injectedStyle?.remove();
573
+ injectedStyle = null;
574
+ } );
575
+
576
+ const renderScale = ( className?: string ) =>
577
+ render(
578
+ <GlobalChartsProvider>
579
+ <HeatmapChart
580
+ width={ 500 }
581
+ height={ 300 }
582
+ data={ scale }
583
+ primaryColor="#3858e9"
584
+ className={ className }
585
+ />
586
+ </GlobalChartsProvider>
587
+ );
588
+
589
+ test( 'picks the label role that reaches AA on light and dark cells', () => {
590
+ renderScale();
591
+
592
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
593
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
594
+ } );
595
+
596
+ test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
597
+ renderScale();
598
+
599
+ expect( screen.getByText( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
600
+ } );
601
+
602
+ test( 'reads the label roles once data arrives after an empty first render', () => {
603
+ const { rerender } = render(
604
+ <GlobalChartsProvider>
605
+ <HeatmapChart width={ 500 } height={ 300 } data={ [] } primaryColor="#3858e9" />
606
+ </GlobalChartsProvider>
607
+ );
608
+
609
+ rerender(
610
+ <GlobalChartsProvider>
611
+ <HeatmapChart width={ 500 } height={ 300 } data={ scale } primaryColor="#3858e9" />
612
+ </GlobalChartsProvider>
613
+ );
614
+
615
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
616
+ } );
617
+
618
+ test( 'falls back to black or white when a label role uses syntax it cannot read', () => {
619
+ injectedStyle = document.createElement( 'style' );
620
+ injectedStyle.textContent =
621
+ '.modern-heatmap { --a8c-charts-color-label-inverse: rgb(255 255 255); }';
622
+ document.head.appendChild( injectedStyle );
623
+
624
+ renderScale( 'modern-heatmap' );
625
+
626
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--white' );
627
+ } );
628
+
629
+ test( 'measures a translucent label role as it paints over each cell', () => {
630
+ injectedStyle = document.createElement( 'style' );
631
+ injectedStyle.textContent =
632
+ '.translucent-heatmap { --a8c-charts-color-label: rgba(30, 30, 30, 0.3); }';
633
+ document.head.appendChild( injectedStyle );
634
+
635
+ renderScale( 'translucent-heatmap' );
636
+
637
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
638
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
639
+ } );
640
+
641
+ test( 'leaves a summary value, which sits on no fill, on the default role', () => {
642
+ injectedStyle = document.createElement( 'style' );
643
+ injectedStyle.textContent =
644
+ '.swapped-heatmap { --a8c-charts-color-label: #f0f0f0; --a8c-charts-color-label-inverse: #1e1e1e; }';
645
+ document.head.appendChild( injectedStyle );
646
+
647
+ render(
648
+ <GlobalChartsProvider>
649
+ <HeatmapChart
650
+ width={ 500 }
651
+ height={ 300 }
652
+ className="swapped-heatmap"
653
+ primaryColor="#3858e9"
654
+ data={ [ ...scale, { label: 'Total', summary: true, data: [ { value: 7 } ] } ] }
655
+ />
656
+ </GlobalChartsProvider>
657
+ );
658
+
659
+ expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
660
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
661
+ } );
662
+
663
+ test( "keeps one color when both label roles are set to it on the chart's own class", () => {
664
+ injectedStyle = document.createElement( 'style' );
665
+ injectedStyle.textContent =
666
+ '.pinned-heatmap { --a8c-charts-color-label: #767676; --a8c-charts-color-label-inverse: #767676; }';
667
+ document.head.appendChild( injectedStyle );
668
+
669
+ renderScale( 'pinned-heatmap' );
670
+
671
+ [ '1', '30', '100' ].forEach( value =>
672
+ expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
673
+ exact: true,
674
+ } )
675
+ );
676
+ } );
677
+ } );
678
+
483
679
  describe( 'HeatmapChart keyboard tooltip', () => {
484
680
  test( 'opens on the selected cell without row labels', async () => {
485
681
  renderChart( { withTooltips: true } );
@@ -0,0 +1,78 @@
1
+ import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
3
+ import { mixHexColors } from '../../../utils/color-utils';
4
+ import { getHeatmapScale, HEATMAP_HIGH_CONTRAST } from '../private/heatmap-scale';
5
+
6
+ // The wp-admin scheme colors, then the pre-7.0 ones, which include a sunrise below 3:1 on white.
7
+ const PRIMARIES = [
8
+ '#007cba',
9
+ '#3858e9',
10
+ '#437aa8',
11
+ '#916745',
12
+ '#646c3e',
13
+ '#cf4339',
14
+ '#567958',
15
+ '#ad631e',
16
+ '#0085ba',
17
+ '#dd823b',
18
+ ];
19
+
20
+ describe.each( [
21
+ [ 'light', '#ffffff', '#f0f0f0' ],
22
+ [ 'dark', '#242424', '#272727' ],
23
+ ] )( 'getHeatmapScale on a %s background', ( _mode, background, emptyCell ) => {
24
+ it.each( PRIMARIES )(
25
+ 'keeps the lowest step of %s at 3:1 against the background and empty cell, no further',
26
+ primary => {
27
+ const { low } = getHeatmapScale( primary, background, emptyCell );
28
+ const contrasts = [ contrastRatio( low, background ), contrastRatio( low, emptyCell ) ];
29
+
30
+ expect( Math.min( ...contrasts ) ).toBeGreaterThanOrEqual( MIN_BACKGROUND_CONTRAST );
31
+ expect( Math.min( ...contrasts ) ).toBeLessThan( MIN_BACKGROUND_CONTRAST + 0.1 );
32
+ }
33
+ );
34
+
35
+ it.each( PRIMARIES )( 'runs the scale of %s out to 9:1', primary => {
36
+ const { high } = getHeatmapScale( primary, background );
37
+
38
+ expect( contrastRatio( high, background ) ).toBeGreaterThanOrEqual( HEATMAP_HIGH_CONTRAST );
39
+ } );
40
+ } );
41
+
42
+ describe( 'getHeatmapScale', () => {
43
+ it( 'keeps a primary already past 9:1 as the high end', () => {
44
+ expect( getHeatmapScale( '#1d2327', '#ffffff' ).high ).toBe( '#1d2327' );
45
+ } );
46
+
47
+ it.each( [
48
+ [ '#ffffff', '#808080' ],
49
+ [ '#ffd000', '#767676' ],
50
+ [ '#3858e9', '#808080' ],
51
+ [ '#1e1e1e', '#999999' ],
52
+ ] )( 'keeps every step of %s at 3:1 on a mid-gray %s', ( primary, background ) => {
53
+ const { low, high } = getHeatmapScale( primary, background );
54
+
55
+ for ( let step = 0; step <= 20; step++ ) {
56
+ expect(
57
+ contrastRatio( mixHexColors( low, high, step / 20 ), background )
58
+ ).toBeGreaterThanOrEqual( MIN_BACKGROUND_CONTRAST );
59
+ }
60
+ } );
61
+
62
+ it.each( [
63
+ [ 'no step clears it', '#ffffff', '#555555' ],
64
+ [ 'clearing it would pass the high end', '#ffffff', '#767676' ],
65
+ [ 'clearing it would pass the high end', '#1e1e1e', '#6b6b6b' ],
66
+ ] )(
67
+ 'measures against the background alone when %s (%s, %s)',
68
+ ( _case, background, emptyCell ) => {
69
+ expect( getHeatmapScale( '#3858e9', background, emptyCell ) ).toEqual(
70
+ getHeatmapScale( '#3858e9', background )
71
+ );
72
+ }
73
+ );
74
+
75
+ it( 'ends on black or white when the background leaves no room for 9:1', () => {
76
+ expect( getHeatmapScale( '#3858e9', '#808080' ).high ).toBe( '#000000' );
77
+ } );
78
+ } );
@@ -1,4 +1,4 @@
1
- import { getValueExtent, getNormalizedValue } from '../private/use-heatmap-colors';
1
+ import { getValueExtent, getNormalizedValue, isEmptyValue } from '../private/use-heatmap-colors';
2
2
  import type { HeatmapColumn } from '../types';
3
3
 
4
4
  const data: HeatmapColumn[] = [
@@ -7,8 +7,12 @@ const data: HeatmapColumn[] = [
7
7
  ];
8
8
 
9
9
  describe( 'getValueExtent', () => {
10
- test( 'returns [min, max] ignoring null/NaN', () => {
11
- expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] );
10
+ test( 'returns [min, max] ignoring null/NaN and empty zeros', () => {
11
+ expect( getValueExtent( data ) ).toEqual( [ 5, 20 ] );
12
+ } );
13
+
14
+ test( 'keeps a zero in the extent when the data has negatives', () => {
15
+ expect( getValueExtent( [ { data: [ { value: -4 }, { value: 0 } ] } ] ) ).toEqual( [ -4, 0 ] );
12
16
  } );
13
17
 
14
18
  test( 'leaves summary columns out of the extent', () => {
@@ -16,11 +20,12 @@ describe( 'getValueExtent', () => {
16
20
  ...data,
17
21
  { label: 'Total', summary: true, data: [ { value: 500 }, { value: 900 }, { value: null } ] },
18
22
  ];
19
- expect( getValueExtent( withTotals ) ).toEqual( [ 0, 20 ] );
23
+ expect( getValueExtent( withTotals ) ).toEqual( [ 5, 20 ] );
20
24
  } );
21
25
 
22
26
  test( 'returns [0, 0] for all-empty data', () => {
23
27
  expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] );
28
+ expect( getValueExtent( [ { data: [ { value: 0 }, { value: 0 } ] } ] ) ).toEqual( [ 0, 0 ] );
24
29
  } );
25
30
  } );
26
31
 
@@ -39,8 +44,15 @@ describe( 'getNormalizedValue', () => {
39
44
  test( 'returns 1 when min === max', () => {
40
45
  expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 );
41
46
  } );
47
+ } );
48
+
49
+ describe( 'isEmptyValue', () => {
50
+ test( 'paints a zero as empty in data with no negatives', () => {
51
+ expect( isEmptyValue( 0, [ 0, 20 ] ) ).toBe( true );
52
+ expect( isEmptyValue( 5, [ 0, 20 ] ) ).toBe( false );
53
+ } );
42
54
 
43
- test( 'returns 0 for an all-zero extent so a no-activity grid stays at the scale bottom', () => {
44
- expect( getNormalizedValue( 0, [ 0, 0 ] ) ).toBe( 0 );
55
+ test( 'keeps a zero on the scale when the data has negatives', () => {
56
+ expect( isEmptyValue( 0, [ -4, 6 ] ) ).toBe( false );
45
57
  } );
46
58
  } );
@@ -1,7 +1,7 @@
1
1
  import type { BaseChartProps } from '../../types';
2
2
  import type { CSSProperties, ReactNode } from 'react';
3
3
 
4
- /** A single heatmap cell. `value: null` marks an empty cell. */
4
+ /** A single heatmap cell. `value: null` marks an empty cell, as does a zero when no value is negative. */
5
5
  export type HeatmapCell = {
6
6
  /** Per-cell label used in the tooltip / accessible name. */
7
7
  label?: string;
@@ -102,18 +102,15 @@ export interface HeatmapChartProps extends Omit<
102
102
  /** Floor a cell's height (px) in non-compact mode; see `minCellWidth`. */
103
103
  minCellHeight?: number;
104
104
  /**
105
- * Color the cell scale interpolates toward at the highest value. Defaults to the
106
- * first series palette slot, `--a8c-charts-color-series-1`.
105
+ * Color the cell scale is built from, deepened at the highest value toward 9:1
106
+ * against the background. Defaults to the first series palette slot,
107
+ * `--a8c-charts-color-series-1`.
107
108
  */
108
109
  primaryColor?: string;
109
110
  renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
110
- /**
111
- * The tooltip box: `light` is the plain white box, `dark` the package's
112
- * tooltip surface, themed through `--a8c-charts-color-tooltip-surface`.
113
- * Default `light`.
114
- */
111
+ /** @deprecated Every tooltip draws on the package tooltip surface; this prop has no effect. */
115
112
  tooltipVariant?: 'light' | 'dark';
116
- /** Inline styles merged onto the tooltip box, over the variant's own. */
113
+ /** Inline styles merged onto the tooltip box, over the surface's own. */
117
114
  tooltipStyle?: CSSProperties;
118
115
  children?: ReactNode;
119
116
  }
@@ -0,0 +1,23 @@
1
+ /* istanbul ignore file */ // Compile-only: tsgo checks these assignments; nothing imports or runs them.
2
+ import type { LineChartProps } from './types';
3
+
4
+ type Crosshairs = NonNullable< LineChartProps[ 'withTooltipCrosshairs' ] >;
5
+
6
+ export const paintCrosshairs: Crosshairs = {
7
+ verticalStyle: { stroke: 'purple', strokeWidth: 2 },
8
+ };
9
+
10
+ export const coordinateCrosshairs: Crosshairs = {
11
+ // @ts-expect-error Crosshair coordinates are computed from the active datum.
12
+ verticalStyle: { x1: 12 },
13
+ };
14
+
15
+ export const transformedCrosshairs: Crosshairs = {
16
+ // @ts-expect-error Crosshair transforms are not paint attributes.
17
+ verticalStyle: { transform: 'translate(100 0)' },
18
+ };
19
+
20
+ export const transformedStyles: Crosshairs = {
21
+ // @ts-expect-error Inline crosshair styles accept only paint properties.
22
+ horizontalStyle: { style: { transform: 'translateX(100px)' } },
23
+ };
@@ -17,7 +17,6 @@
17
17
  overflow: visible;
18
18
  }
19
19
 
20
- &__tooltip,
21
20
  &__annotation-label-popover {
22
21
  background: var(--a8c-charts-color-surface);
23
22
  color: var(--a8c-charts-color-label);
@@ -29,10 +28,6 @@
29
28
  padding-bottom: var(--wpds-dimension-padding-md);
30
29
  }
31
30
 
32
- &__tooltip-row {
33
- padding: var(--wpds-dimension-padding-xs) 0;
34
- }
35
-
36
31
  &__tooltip-label {
37
32
  font-weight: var(--wpds-typography-font-weight-emphasis);
38
33
  padding-right: var(--wpds-dimension-padding-lg);