@automattic/charts 4.7.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 (61) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +6 -3
  3. package/dist/index.cjs +212 -95
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +10 -9
  6. package/dist/index.d.cts +60 -20
  7. package/dist/index.d.ts +60 -20
  8. package/dist/index.js +212 -97
  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.tsx +1 -1
  19. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +9 -0
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +6 -4
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +39 -29
  22. package/src/charts/heatmap-chart/private/heatmap-context.ts +2 -2
  23. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +2 -2
  24. package/src/charts/heatmap-chart/private/heatmap-scale.ts +74 -0
  25. package/src/charts/heatmap-chart/private/index.ts +2 -1
  26. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +24 -6
  27. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +99 -7
  28. package/src/charts/heatmap-chart/test/heatmap-scale.test.ts +78 -0
  29. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +18 -6
  30. package/src/charts/heatmap-chart/types.ts +4 -3
  31. package/src/charts/line-chart/line-chart.tsx +2 -2
  32. package/src/charts/pie-chart/pie-chart.tsx +4 -4
  33. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -4
  34. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +1 -1
  35. package/src/charts/private/test/label-text-color.test.ts +6 -8
  36. package/src/components/tooltip/base-tooltip.tsx +31 -32
  37. package/src/components/tooltip/index.ts +3 -1
  38. package/src/components/tooltip/private/bounded-tooltip.tsx +15 -15
  39. package/src/components/tooltip/private/label-value-content.tsx +8 -0
  40. package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +336 -0
  41. package/src/components/tooltip/test/tool-tip.test.tsx +8 -6
  42. package/src/components/tooltip/test/tooltip-box.test.tsx +53 -0
  43. package/src/components/tooltip/test/xy-chart-tooltip-overlay.test.tsx +445 -0
  44. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +282 -365
  45. package/src/components/tooltip/{base-tooltip.module.scss → tooltip-box.module.scss} +5 -8
  46. package/src/components/tooltip/tooltip-box.tsx +43 -0
  47. package/src/components/tooltip/types.ts +7 -1
  48. package/src/components/tooltip/xy-chart-tooltip.tsx +410 -300
  49. package/src/hooks/test/use-chart-margin.test.tsx +8 -4
  50. package/src/hooks/use-chart-margin.tsx +21 -18
  51. package/src/index.ts +8 -3
  52. package/src/providers/chart-context/global-charts-provider.tsx +5 -14
  53. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +1 -1
  54. package/src/providers/chart-context/private/catalog-pointers.ts +1 -0
  55. package/src/providers/chart-context/private/resolve-opaque-hex.ts +16 -0
  56. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +1 -1
  57. package/src/styles/chart-scope.scss +1 -0
  58. package/src/test-utils/zoned-parsing-suite.ts +0 -10
  59. package/src/utils/test/date-formatting.test.ts +0 -10
  60. package/src/components/tooltip/accessible-tooltip.tsx +0 -433
  61. package/src/components/tooltip/test/accessible-tooltip.test.tsx +0 -331
@@ -172,6 +172,26 @@ describe( 'HeatmapChart', () => {
172
172
  ).toHaveLength( 4 );
173
173
  } );
174
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
+
175
195
  test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => {
176
196
  renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
177
197
  const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
@@ -409,6 +429,78 @@ describe( 'HeatmapChart', () => {
409
429
  expect( grid.style.getPropertyValue( '--a8c-charts-color-heatmap-primary' ) ).toBe( '#abcdef' );
410
430
  } );
411
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
+
412
504
  test( 'falls back to the first palette slot when no primaryColor prop is set', () => {
413
505
  const scope = document.createElement( 'div' );
414
506
  scope.style.setProperty( '--a8c-charts-color-series-1', '#0a0b0c' );
@@ -469,9 +561,9 @@ const mockRects = () =>
469
561
  } );
470
562
 
471
563
  describe( 'HeatmapChart value text contrast', () => {
472
- // Extent 0..100, so each value is its own intensity in hundredths.
564
+ // The low end, a mid-tone, and the high end of the scale.
473
565
  const scale: HeatmapColumn[] = [
474
- { label: 'W1', data: [ { value: 0 }, { value: 85 }, { value: 100 } ] },
566
+ { label: 'W1', data: [ { value: 1 }, { value: 30 }, { value: 100 } ] },
475
567
  ];
476
568
 
477
569
  let injectedStyle: HTMLStyleElement | null = null;
@@ -497,14 +589,14 @@ describe( 'HeatmapChart value text contrast', () => {
497
589
  test( 'picks the label role that reaches AA on light and dark cells', () => {
498
590
  renderScale();
499
591
 
500
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
592
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
501
593
  expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
502
594
  } );
503
595
 
504
596
  test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
505
597
  renderScale();
506
598
 
507
- expect( screen.getByText( '85' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
599
+ expect( screen.getByText( '30' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
508
600
  } );
509
601
 
510
602
  test( 'reads the label roles once data arrives after an empty first render', () => {
@@ -542,7 +634,7 @@ describe( 'HeatmapChart value text contrast', () => {
542
634
 
543
635
  renderScale( 'translucent-heatmap' );
544
636
 
545
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
637
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
546
638
  expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
547
639
  } );
548
640
 
@@ -565,7 +657,7 @@ describe( 'HeatmapChart value text contrast', () => {
565
657
  );
566
658
 
567
659
  expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
568
- expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
660
+ expect( screen.getByText( '1' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
569
661
  } );
570
662
 
571
663
  test( "keeps one color when both label roles are set to it on the chart's own class", () => {
@@ -576,7 +668,7 @@ describe( 'HeatmapChart value text contrast', () => {
576
668
 
577
669
  renderScale( 'pinned-heatmap' );
578
670
 
579
- [ '0', '85', '100' ].forEach( value =>
671
+ [ '1', '30', '100' ].forEach( value =>
580
672
  expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
581
673
  exact: true,
582
674
  } )
@@ -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,8 +102,9 @@ 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;
@@ -15,7 +15,7 @@ import {
15
15
  useCallback,
16
16
  } from 'react';
17
17
  import { Legend, useChartLegendItems } from '../../components/legend';
18
- import { AccessibleTooltip, useKeyboardNavigation } from '../../components/tooltip';
18
+ import { XYChartTooltip, useKeyboardNavigation } from '../../components/tooltip';
19
19
  import {
20
20
  useXYChartTheme,
21
21
  useChartDataTransform,
@@ -784,7 +784,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
784
784
  </ZoomClip>
785
785
 
786
786
  { withTooltips && (
787
- <AccessibleTooltip
787
+ <XYChartTooltip
788
788
  detectBounds
789
789
  snapTooltipToDatumX
790
790
  tooltipPlacement={ tooltipPlacement }
@@ -6,8 +6,8 @@ import clsx from 'clsx';
6
6
  import isEqual from 'fast-deep-equal';
7
7
  import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
8
8
  import { Legend, useChartLegendItems } from '../../components/legend';
9
- import { BaseTooltip } from '../../components/tooltip';
10
9
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
10
+ import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
11
11
  import {
12
12
  useDataWithPercentages,
13
13
  useLegendVisibilityData,
@@ -57,13 +57,13 @@ export type PieChartRenderTooltipParams = {
57
57
 
58
58
  /**
59
59
  * Default tooltip renderer for pie charts.
60
- * Renders a BaseTooltip with the hovered segment's data.
60
+ * Renders the default `label: value` tooltip content for the hovered segment.
61
61
  *
62
62
  * @param {PieChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
63
63
  * @return {ReactNode} The rendered tooltip content
64
64
  */
65
65
  const renderDefaultPieTooltip = ( { tooltipData }: PieChartRenderTooltipParams ): ReactNode => {
66
- return <BaseTooltip data={ tooltipData } top={ 0 } left={ 0 } renderContainer={ false } />;
66
+ return <LabelValueContent data={ tooltipData } />;
67
67
  };
68
68
 
69
69
  export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
@@ -127,7 +127,7 @@ export interface PieChartProps extends BaseChartProps< DataPointPercentage[] > {
127
127
 
128
128
  /**
129
129
  * Custom render function for tooltip content.
130
- * When provided, replaces the default BaseTooltip with custom content.
130
+ * When provided, replaces the default `label: value` tooltip with custom content.
131
131
  */
132
132
  renderTooltip?: ( params: PieChartRenderTooltipParams ) => ReactNode;
133
133
  }
@@ -5,8 +5,8 @@ import { useTooltip } from '@visx/tooltip';
5
5
  import clsx from 'clsx';
6
6
  import { useCallback, useContext, useMemo, useRef } from 'react';
7
7
  import { Legend, useChartLegendItems } from '../../components/legend';
8
- import { BaseTooltip } from '../../components/tooltip';
9
8
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
9
+ import { LabelValueContent } from '../../components/tooltip/private/label-value-content';
10
10
  import {
11
11
  useDataWithPercentages,
12
12
  useLegendVisibilityData,
@@ -53,7 +53,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
53
53
 
54
54
  /**
55
55
  * Default tooltip renderer for semi-circle pie charts.
56
- * Renders a BaseTooltip with the hovered segment's data.
56
+ * Renders the default `label: value` tooltip content for the hovered segment.
57
57
  *
58
58
  * @param {PieSemiCircleChartRenderTooltipParams} params - The tooltip parameters containing the hovered data point
59
59
  * @return {ReactNode} The rendered tooltip content
@@ -61,7 +61,7 @@ export type PieSemiCircleChartRenderTooltipParams = {
61
61
  const renderDefaultPieSemiCircleTooltip = ( {
62
62
  tooltipData,
63
63
  }: PieSemiCircleChartRenderTooltipParams ): ReactNode => {
64
- return <BaseTooltip data={ tooltipData } top={ 0 } left={ 0 } renderContainer={ false } />;
64
+ return <LabelValueContent data={ tooltipData } />;
65
65
  };
66
66
 
67
67
  const PAD_ANGLE = 0.03; // Padding between segments
@@ -122,7 +122,7 @@ export interface PieSemiCircleChartProps extends BaseChartProps< DataPointPercen
122
122
 
123
123
  /**
124
124
  * Custom render function for tooltip content.
125
- * When provided, replaces the default BaseTooltip with custom content.
125
+ * When provided, replaces the default `label: value` tooltip with custom content.
126
126
  */
127
127
  renderTooltip?: ( params: PieSemiCircleChartRenderTooltipParams ) => ReactNode;
128
128
  }
@@ -96,7 +96,7 @@ describe( 'PieSemiCircleChart', () => {
96
96
 
97
97
  await user.hover( firstSegment );
98
98
 
99
- // Wait for tooltip to be visible - it should show in the BaseTooltip component
99
+ // Wait for tooltip to be visible - it should show the default label: value content
100
100
  const tooltip = await screen.findByRole( 'tooltip' );
101
101
  expect( tooltip ).toHaveTextContent( 'MacOS' );
102
102
 
@@ -5,6 +5,7 @@ import {
5
5
  luminanceContrastRatio,
6
6
  rgbLuminance,
7
7
  } from '../../../providers/chart-context/private/perceptual-color';
8
+ import { getHeatmapScale } from '../../heatmap-chart/private/heatmap-scale';
8
9
  import {
9
10
  pickLabelTextColor,
10
11
  pickLabelTextColorForFill,
@@ -131,9 +132,9 @@ describe( 'pickLabelTextColorForFill', () => {
131
132
  } );
132
133
 
133
134
  describe.each( [
134
- [ 'white', '#ffffff' ],
135
- [ 'dark', '#1e1e1e' ],
136
- ] )( 'across a heatmap scale on a %s background', ( _name, background ) => {
135
+ [ 'white', '#ffffff', '#f0f0f0' ],
136
+ [ 'dark', '#242424', '#272727' ],
137
+ ] )( 'across a heatmap scale on a %s background', ( _name, background, emptyCell ) => {
137
138
  const painted: Record< LabelTextColor, number > = {
138
139
  label: rgbLuminance( hexToRgb( '#1e1e1e' ) ),
139
140
  'label-inverse': rgbLuminance( hexToRgb( '#f0f0f0' ) ),
@@ -155,12 +156,9 @@ describe( 'pickLabelTextColorForFill', () => {
155
156
  ];
156
157
 
157
158
  it.each( primaries )( 'reaches AA on every intensity of %s', primary => {
159
+ const { low, high } = getHeatmapScale( primary, background, emptyCell );
158
160
  for ( let step = 0; step <= 200; step++ ) {
159
- const fill = blendRgb(
160
- hexToRgb( primary ),
161
- hexToRgb( background ),
162
- 0.15 + 0.85 * ( step / 200 )
163
- );
161
+ const fill = blendRgb( hexToRgb( high ), hexToRgb( low ), step / 200 );
164
162
  const choice = pickLabelTextColorForFill( fill, DEFAULT_ROLES, 'label' );
165
163
  // Checked on the 8-bit channels the browser paints as well as the exact mix.
166
164
  const [ r, g, b ] = fill.map( Math.round );
@@ -1,36 +1,32 @@
1
- import { formatNumber } from '@automattic/number-formatters';
2
- import clsx from 'clsx';
3
- import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
4
- import styles from './base-tooltip.module.scss';
5
- import { TooltipTheme } from './private/tooltip-theme';
1
+ import { LabelValueContent } from './private/label-value-content';
2
+ import { TooltipBox } from './tooltip-box';
3
+ import type { TooltipData } from './types';
6
4
  import type { CSSProperties, ComponentType, ReactNode } from 'react';
7
5
 
8
- type TooltipData = {
9
- label: string;
10
- value: number;
11
- valueDisplay?: string;
12
- };
13
-
14
6
  type TooltipComponentProps = {
15
7
  data: TooltipData;
16
8
  className?: string;
17
9
  };
18
10
 
19
11
  type TooltipCommonProps = {
20
- top: number;
21
- left: number;
12
+ /** @deprecated Position the box with `style`, or use `TooltipBox`. */
13
+ top?: number;
14
+ /** @deprecated Position the box with `style`, or use `TooltipBox`. */
15
+ left?: number;
22
16
  style?: CSSProperties;
23
17
  className?: string;
24
18
  /**
25
19
  * Whether to render the tooltip container div. When false, only renders the content.
26
- * Useful when the tooltip is rendered inside a portal or custom container.
20
+ * @deprecated Render the content directly; `TooltipBox` draws the box.
27
21
  * @default true
28
22
  */
29
23
  renderContainer?: boolean;
30
24
  };
31
25
 
32
26
  type DefaultDataTooltip = {
27
+ /** @deprecated Pass the content as children, or use `TooltipBox`. */
33
28
  data: TooltipData;
29
+ /** @deprecated Pass the content as children, or use `TooltipBox`. */
34
30
  component?: ComponentType< TooltipComponentProps >;
35
31
  children?: never;
36
32
  };
@@ -43,17 +39,25 @@ type CustomTooltip = {
43
39
 
44
40
  type BaseTooltipProps = TooltipCommonProps & ( DefaultDataTooltip | CustomTooltip );
45
41
 
46
- const DefaultTooltipContent = ( { data }: TooltipComponentProps ) => (
47
- <>
48
- { data?.label }: { data?.valueDisplay || formatNumber( data?.value ) }
49
- </>
50
- );
51
-
42
+ /**
43
+ * A tooltip box with children or `label: value` content. Prefer `TooltipBox`.
44
+ *
45
+ * @param props - Tooltip props.
46
+ * @param props.data - Data for the default content.
47
+ * @param props.top - Deprecated vertical position.
48
+ * @param props.left - Deprecated horizontal position.
49
+ * @param props.component - Deprecated content component.
50
+ * @param props.children - Tooltip content.
51
+ * @param props.className - Class name passed to the content component.
52
+ * @param props.style - Styles for the box.
53
+ * @param props.renderContainer - Deprecated; render the content alone when false.
54
+ * @return The tooltip.
55
+ */
52
56
  export const BaseTooltip = ( {
53
57
  data,
54
58
  top,
55
59
  left,
56
- component: Component = DefaultTooltipContent,
60
+ component: Component = LabelValueContent,
57
61
  children,
58
62
  className,
59
63
  style,
@@ -65,17 +69,12 @@ export const BaseTooltip = ( {
65
69
  return content;
66
70
  }
67
71
 
68
- return (
69
- <TooltipTheme>
70
- <div
71
- className={ clsx( CHART_SCOPE_CLASS, styles.tooltip ) }
72
- style={ { top, left, ...style } }
73
- role="tooltip"
74
- >
75
- { content }
76
- </div>
77
- </TooltipTheme>
78
- );
72
+ const placement =
73
+ top === undefined && left === undefined
74
+ ? undefined
75
+ : ( { position: 'absolute', top, left, transform: 'translate(-50%, -100%)' } as const );
76
+
77
+ return <TooltipBox style={ { ...placement, ...style } }>{ content }</TooltipBox>;
79
78
  };
80
79
 
81
80
  export type { BaseTooltipProps, TooltipData };
@@ -1,4 +1,6 @@
1
1
  export { BaseTooltip } from './base-tooltip';
2
- export { AccessibleTooltip, useKeyboardNavigation } from './accessible-tooltip';
2
+ export { AccessibleTooltip, XYChartTooltip, useKeyboardNavigation } from './xy-chart-tooltip';
3
+ export { TooltipBox } from './tooltip-box';
3
4
  export type { BaseTooltipProps, TooltipData } from './base-tooltip';
5
+ export type { TooltipBoxProps } from './tooltip-box';
4
6
  export type { TooltipProps } from './types';
@@ -1,19 +1,19 @@
1
- import { Tooltip } from '@visx/tooltip';
2
- import clsx from 'clsx';
3
1
  import { useLayoutEffect, useRef, useState } from 'react';
4
- import { TOOLTIP_SCOPE_CLASS } from '../../../styles/chart-scope-class';
5
- import styles from '../base-tooltip.module.scss';
6
- import { TooltipTheme } from './tooltip-theme';
2
+ import { TooltipBox } from '../tooltip-box';
7
3
  import type { TooltipPlacement } from '../../../visx/types';
8
- import type { TooltipProps } from '@visx/tooltip';
4
+ import type { TooltipBoxProps } from '../tooltip-box';
5
+ import type { ReactNode } from 'react';
9
6
 
10
7
  type Bounds = { left: number; top: number; right: number; bottom: number };
11
8
  type Size = { width: number; height: number };
12
9
 
13
- export type BoundedTooltipProps = Omit< TooltipProps, 'left' | 'top' | 'applyPositionStyle' > & {
10
+ export type BoundedTooltipProps = Omit< TooltipBoxProps, 'children' > & {
11
+ children?: ReactNode;
14
12
  /** Anchor, in the coordinates of the positioned wrapper the box renders into. */
15
13
  left?: number;
16
14
  top?: number;
15
+ offsetLeft?: number;
16
+ offsetTop?: number;
17
17
  placement?: TooltipPlacement;
18
18
  };
19
19
 
@@ -135,7 +135,7 @@ export const getBoundedPosition = ( {
135
135
  };
136
136
 
137
137
  /**
138
- * visx's `Tooltip`, positioned like its `TooltipWithBounds` but kept inside the
138
+ * A `TooltipBox`, positioned like visx's `TooltipWithBounds` but kept inside the
139
139
  * nearest clipping ancestor — or the viewport when there is none — rather than
140
140
  * inside its own parent. Rendered in-tree, a tooltip's parent is the chart
141
141
  * wrapper, which is often narrower than the box; measuring against the parent
@@ -144,7 +144,7 @@ export const getBoundedPosition = ( {
144
144
  * Re-measures on every render, so a box whose content changes width between
145
145
  * two hovers is placed for its current size.
146
146
  *
147
- * @param props - visx `Tooltip` props.
147
+ * @param props - `TooltipBox` props plus placement.
148
148
  * @param props.left - Anchor x, in wrapper coordinates.
149
149
  * @param props.top - Anchor y, in wrapper coordinates.
150
150
  * @param props.offsetLeft - Gap between the anchor and the box, horizontally.
@@ -213,11 +213,13 @@ export const BoundedTooltip = ( {
213
213
  const x = position?.x ?? left + offsetLeft;
214
214
  const y = position?.y ?? top + ( placement === 'below-axis' ? POINTER_HEIGHT : offsetTop );
215
215
 
216
- const box = (
217
- <Tooltip
216
+ return (
217
+ <TooltipBox
218
218
  ref={ nodeRef }
219
+ role="presentation"
219
220
  data-testid="bounded-tooltip"
220
- className={ clsx( ! unstyled && [ TOOLTIP_SCOPE_CLASS, styles.surface ], className ) }
221
+ unstyled={ unstyled }
222
+ className={ className }
221
223
  style={ {
222
224
  position: 'absolute',
223
225
  left: 0,
@@ -245,8 +247,6 @@ export const BoundedTooltip = ( {
245
247
  />
246
248
  ) }
247
249
  { children }
248
- </Tooltip>
250
+ </TooltipBox>
249
251
  );
250
-
251
- return unstyled ? box : <TooltipTheme>{ box }</TooltipTheme>;
252
252
  };
@@ -0,0 +1,8 @@
1
+ import { formatNumber } from '@automattic/number-formatters';
2
+ import type { TooltipData } from '../types';
3
+
4
+ export const LabelValueContent = ( { data }: { data: TooltipData } ) => (
5
+ <>
6
+ { data.label }: { data.valueDisplay || formatNumber( data.value ) }
7
+ </>
8
+ );