@automattic/charts 4.6.0 → 4.7.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 (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/TOKENS.md +12 -10
  3. package/dist/index.cjs +244 -145
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -20
  6. package/dist/index.d.cts +10 -10
  7. package/dist/index.d.ts +10 -10
  8. package/dist/index.js +245 -146
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
  12. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +4 -2
  13. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -1
  14. package/src/charts/conversion-funnel-chart/types.ts +3 -0
  15. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
  16. package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
  18. package/src/charts/heatmap-chart/types.ts +2 -6
  19. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  20. package/src/charts/line-chart/line-chart.module.scss +0 -5
  21. package/src/charts/line-chart/line-chart.tsx +8 -40
  22. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  23. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  24. package/src/charts/pie-chart/pie-chart.tsx +22 -76
  25. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  26. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  27. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  28. package/src/charts/private/label-text-color.ts +116 -0
  29. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  30. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  31. package/src/charts/private/test/label-text-color.test.ts +191 -0
  32. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  33. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  34. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  35. package/src/components/tooltip/base-tooltip.module.scss +12 -8
  36. package/src/components/tooltip/base-tooltip.tsx +11 -9
  37. package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
  38. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  39. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  40. package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
  41. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  42. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
  43. package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
  44. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  45. package/src/hooks/index.ts +1 -0
  46. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  47. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  48. package/src/hooks/use-xychart-theme.ts +4 -12
  49. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
  50. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  51. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  52. package/src/styles/chart-scope-class.ts +5 -0
  53. package/src/styles/chart-scope.scss +9 -6
  54. package/src/styles/test/chart-scope.test.ts +3 -3
  55. package/src/utils/color-utils.ts +2 -1
  56. package/src/visx/types.ts +1 -1
  57. package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -114,16 +114,6 @@
114
114
  }
115
115
  }
116
116
 
117
- .tooltip-wrapper {
118
- background: var(--a8c-charts-color-surface);
119
-
120
- // Override .visx-tooltip inline styles.
121
- border-radius: var(--wpds-border-radius-md) !important;
122
- padding: var(--wpds-dimension-padding-md) !important;
123
- box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005) !important;
124
-
125
- }
126
-
127
117
  .tooltip-title {
128
118
  color: var(--a8c-charts-color-label);
129
119
  font-size: var(--wpds-typography-font-size-sm);
@@ -36,6 +36,7 @@ import type { FunnelStep, ConversionFunnelChartProps } from './types';
36
36
  * @param props.renderStepRate - Custom render function for step rates
37
37
  * @param props.renderMainMetric - Custom render function for the entire main metric section
38
38
  * @param props.renderTooltip - Custom render function for tooltip content
39
+ * @param props.tooltipStyle - Inline styles merged over the tooltip box defaults
39
40
  * @return JSX element representing the conversion funnel chart
40
41
  */
41
42
  const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
@@ -52,6 +53,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
52
53
  renderStepRate,
53
54
  renderMainMetric,
54
55
  renderTooltip,
56
+ tooltipStyle,
55
57
  } ) => {
56
58
  const chartId = useChartId( providedChartId );
57
59
  const { getElementStyles, isColorPaletteResolved } = useGlobalChartsContext();
@@ -412,7 +414,6 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
412
414
  index: steps.findIndex( s => s.id === ( tooltipData as FunnelStep ).id ),
413
415
  top: tooltipTop,
414
416
  left: tooltipLeft,
415
- className: styles[ 'tooltip-wrapper' ],
416
417
  } )
417
418
  : renderDefaultTooltip( tooltipData as FunnelStep );
418
419
 
@@ -423,7 +424,8 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( {
423
424
  <BoundedTooltip
424
425
  top={ tooltipTop }
425
426
  left={ tooltipLeft }
426
- className={ clsx( standaloneScopeClass, styles[ 'tooltip-wrapper' ] ) }
427
+ className={ standaloneScopeClass }
428
+ style={ tooltipStyle }
427
429
  >
428
430
  { tooltipContent }
429
431
  </BoundedTooltip>
@@ -427,10 +427,20 @@ describe( 'ConversionFunnelChart', () => {
427
427
  index: 1,
428
428
  top: expect.any( Number ),
429
429
  left: expect.any( Number ),
430
- className: 'tooltip-wrapper',
431
430
  } );
432
431
  } );
433
432
 
433
+ it( 'merges tooltipStyle onto the tooltip box', async () => {
434
+ const user = userEvent.setup();
435
+ renderWithoutTheme(
436
+ <ConversionFunnelChart { ...defaultProps } tooltipStyle={ { background: 'purple' } } />
437
+ );
438
+
439
+ await user.click( screen.getByRole( 'button', { name: /cart/i } ) );
440
+
441
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'purple' } );
442
+ } );
443
+
434
444
  it( 'disables tooltip when renderTooltip returns null', async () => {
435
445
  const user = userEvent.setup();
436
446
  const customRenderTooltip = jest.fn( () => null );
@@ -50,6 +50,7 @@ export interface TooltipRenderProps {
50
50
  index: number;
51
51
  top: number;
52
52
  left: number;
53
+ /** @deprecated The tooltip box draws the package surface; this is no longer set. */
53
54
  className?: string;
54
55
  }
55
56
 
@@ -80,4 +81,6 @@ export interface ConversionFunnelChartProps extends Pick<
80
81
  renderMainMetric?: ( props: MainMetricRenderProps ) => React.ReactNode;
81
82
  /** Custom render function for tooltip content */
82
83
  renderTooltip?: ( props: TooltipRenderProps ) => React.ReactNode;
84
+ /** Inline styles merged over the tooltip box defaults. */
85
+ tooltipStyle?: React.CSSProperties;
83
86
  }
@@ -183,12 +183,19 @@ $focus-ring-width: var(--wpds-border-width-focus);
183
183
  font-size: var(--wpds-typography-font-size-md);
184
184
  }
185
185
 
186
- // Light text on fills dark enough to out-contrast dark text (decided in JS from
187
- // the blended fill luminance).
188
- .heatmap-chart__cell--strong .heatmap-chart__cell-value {
186
+ // Chosen per cell in JS from the blended fill; see pickLabelTextColorForFill.
187
+ .heatmap-chart__cell-value--inverse {
189
188
  color: var(--a8c-charts-color-label-inverse);
190
189
  }
191
190
 
191
+ .heatmap-chart__cell-value--black {
192
+ color: #000;
193
+ }
194
+
195
+ .heatmap-chart__cell-value--white {
196
+ color: #fff;
197
+ }
198
+
192
199
  .heatmap-chart__legend-label {
193
200
  color: var(--a8c-charts-color-label);
194
201
  }
@@ -1,10 +1,11 @@
1
1
  import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
2
- import { defaultStyles as visxTooltipStyles, useTooltip } from '@visx/tooltip';
2
+ import { useTooltip } from '@visx/tooltip';
3
3
  import { __ } from '@wordpress/i18n';
4
4
  import clsx from 'clsx';
5
+ import isEqual from 'fast-deep-equal';
5
6
  import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
6
- import tooltipStyles from '../../components/tooltip/base-tooltip.module.scss';
7
7
  import { BoundedTooltip, TOOLTIP_Z_INDEX } from '../../components/tooltip/private/bounded-tooltip';
8
+ import { useIsomorphicLayoutEffect } from '../../hooks';
8
9
  import {
9
10
  GlobalChartsProvider,
10
11
  useChartId,
@@ -13,19 +14,16 @@ import {
13
14
  GlobalChartsContext,
14
15
  } from '../../providers';
15
16
  import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
17
+ import { blendRgb, hexToRgb } from '../../providers/chart-context/private/perceptual-color';
16
18
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
17
19
  import { attachSubComponents } from '../../utils';
18
- import {
19
- isValidHexColor,
20
- mixHexColors,
21
- normalizeColorToHex,
22
- prefersLightText,
23
- } from '../../utils/color-utils';
24
- import { resolveCssVariable } from '../../utils/resolve-css-var';
20
+ import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
21
+ import { createCssVariableResolver, resolveCssVariable } from '../../utils/resolve-css-var';
25
22
  import { Center } from '../private/center';
26
23
  import { useChartChildren } from '../private/chart-composition';
27
24
  import { ChartInstanceContext } from '../private/chart-instance-context';
28
25
  import { ChartLayout } from '../private/chart-layout';
26
+ import { pickLabelTextColorForFill, resolveLabelRoles } from '../private/label-text-color';
29
27
  import { withResponsive } from '../private/with-responsive';
30
28
  import styles from './heatmap-chart.module.scss';
31
29
  import {
@@ -46,6 +44,7 @@ import {
46
44
  import type { HeatmapContextValue } from './private';
47
45
  import type { CellBlock, CellPosition } from './private/keyboard-navigation';
48
46
  import type { HeatmapChartProps, HeatmapTooltipData } from './types';
47
+ import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
49
48
  import type { ResponsiveConfig } from '../private/with-responsive';
50
49
  import type { CSSProperties, FC } from 'react';
51
50
 
@@ -53,15 +52,19 @@ import type { CSSProperties, FC } from 'react';
53
52
  // the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
54
53
  const CELL_MIX_FLOOR = 0.15;
55
54
 
55
+ // `label` is the stylesheet's default, so it needs no modifier.
56
+ const CELL_VALUE_MODIFIER: Record< LabelTextColor, string | undefined > = {
57
+ label: undefined,
58
+ 'label-inverse': styles[ 'heatmap-chart__cell-value--inverse' ],
59
+ black: styles[ 'heatmap-chart__cell-value--black' ],
60
+ white: styles[ 'heatmap-chart__cell-value--white' ],
61
+ };
62
+
56
63
  // One instance, not a `[]` default in the signature: `buildTooltipData` keys on
57
64
  // it, and a fresh array per render re-ran the keyboard tooltip effect endlessly.
58
65
  const NO_ROW_LABELS: string[] = [];
59
66
 
60
- // The dark variant is painted by the `.surface` class, so its style carries no box.
61
- const TOOLTIP_BOX_STYLES: Record< 'light' | 'dark', CSSProperties > = {
62
- light: { ...visxTooltipStyles, zIndex: TOOLTIP_Z_INDEX },
63
- dark: { zIndex: TOOLTIP_Z_INDEX },
64
- };
67
+ const TOOLTIP_BOX_STYLE: CSSProperties = { zIndex: TOOLTIP_Z_INDEX };
65
68
 
66
69
  // The cell's own label wins; otherwise the group, column and row labels name it.
67
70
  const cellName = ( info: HeatmapTooltipData ) =>
@@ -88,14 +91,13 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
88
91
  gap = 'md',
89
92
  withTooltips = false,
90
93
  renderTooltip,
91
- tooltipVariant = 'light',
92
94
  tooltipStyle,
93
95
  children,
94
96
  } ) => {
95
97
  const chartId = useChartId( providedChartId );
96
98
  const tooltipBoxStyle = tooltipStyle
97
- ? { ...TOOLTIP_BOX_STYLES[ tooltipVariant ], ...tooltipStyle }
98
- : TOOLTIP_BOX_STYLES[ tooltipVariant ];
99
+ ? { ...TOOLTIP_BOX_STYLE, ...tooltipStyle }
100
+ : TOOLTIP_BOX_STYLE;
99
101
  const { getElementStyles, theme } = useGlobalChartsContext();
100
102
  const scopeElement = useChartScopeElement();
101
103
  const { heatmapChart: heatmapChartSettings } = theme;
@@ -106,6 +108,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
106
108
  useTooltip< HeatmapTooltipData >();
107
109
  const standaloneScopeClass = useStandaloneScopeClass();
108
110
  const containerRef = useRef< HTMLDivElement >( null );
111
+ const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
109
112
  // The chart root positions the tooltip, so pointer and cell coordinates are
110
113
  // measured against it — found by its id rather than by walking up, so
111
114
  // whatever ChartLayout wraps the grid in cannot shift the origin.
@@ -131,18 +134,34 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
131
134
  );
132
135
 
133
136
  // Choose text color from the blended fill, not the raw value.
134
- // If either color cannot resolve to hex, keep dark text.
137
+ // If either color cannot resolve to hex, keep the stylesheet's default role.
135
138
  const primaryHex = normalizeColorToHex( primaryColorHex );
136
- const cellHasLightText = ( intensity: number ): boolean =>
137
- isValidHexColor( primaryHex ) &&
138
- isValidHexColor( chartBackgroundHex ) &&
139
- prefersLightText(
140
- mixHexColors(
141
- primaryHex,
142
- chartBackgroundHex,
143
- 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity )
144
- )
145
- );
139
+ const cellMix =
140
+ isValidHexColor( primaryHex ) && isValidHexColor( chartBackgroundHex )
141
+ ? { primary: hexToRgb( primaryHex ), background: hexToRgb( chartBackgroundHex ) }
142
+ : null;
143
+ const cellTextColor = ( intensity: number ): LabelTextColor =>
144
+ cellMix
145
+ ? pickLabelTextColorForFill(
146
+ blendRgb(
147
+ cellMix.primary,
148
+ cellMix.background,
149
+ CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity
150
+ ),
151
+ labelRoles,
152
+ 'label'
153
+ )
154
+ : 'label';
155
+
156
+ // Read inside the chart's own class, where the cell text inherits them. Keyed on `data` too,
157
+ // because the grid is not mounted while there is nothing to draw.
158
+ useIsomorphicLayoutEffect( () => {
159
+ if ( ! containerRef.current ) {
160
+ return;
161
+ }
162
+ const next = resolveLabelRoles( createCssVariableResolver( containerRef.current ) );
163
+ setLabelRoles( previous => ( isEqual( previous, next ) ? previous : next ) );
164
+ }, [ data, className, primaryColorHex, chartBackgroundHex ] );
146
165
 
147
166
  const extent = useMemo( () => getValueExtent( data ), [ data ] );
148
167
  const heatmapContext = useMemo< HeatmapContextValue >(
@@ -547,8 +566,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
547
566
  data-row={ rowIndex }
548
567
  className={ clsx( styles[ 'heatmap-chart__cell' ], {
549
568
  [ styles[ 'heatmap-chart__cell--filled' ] ]: filled,
550
- [ styles[ 'heatmap-chart__cell--strong' ] ]:
551
- filled && cellHasLightText( normalized ),
552
569
  [ styles[ 'heatmap-chart__cell--summary' ] ]: column.summary,
553
570
  ...summaryGaps( columnIndex ),
554
571
  [ styles[ 'heatmap-chart__cell--selected' ] ]:
@@ -566,7 +583,12 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
566
583
  onMouseLeave={ handleCellMouseLeave }
567
584
  >
568
585
  { ( drawValues || column.summary ) && present && (
569
- <span className={ styles[ 'heatmap-chart__cell-value' ] }>
586
+ <span
587
+ className={ clsx(
588
+ styles[ 'heatmap-chart__cell-value' ],
589
+ filled && CELL_VALUE_MODIFIER[ cellTextColor( normalized ) ]
590
+ ) }
591
+ >
570
592
  { /* Compact display; tooltip and aria-label keep full precision. */ }
571
593
  { formatNumberCompact( value ) }
572
594
  </span>
@@ -596,12 +618,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
596
618
  ) }
597
619
  </div>
598
620
  { withTooltips && tooltipOpen && tooltipData && (
599
- <BoundedTooltip
600
- top={ tooltipTop }
601
- left={ tooltipLeft }
602
- className={ tooltipVariant === 'dark' ? tooltipStyles.surface : undefined }
603
- style={ tooltipBoxStyle }
604
- >
621
+ <BoundedTooltip top={ tooltipTop } left={ tooltipLeft } style={ tooltipBoxStyle }>
605
622
  <div className={ standaloneScopeClass } role="tooltip" tabIndex={ -1 }>
606
623
  { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
607
624
  </div>
@@ -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
  } );
@@ -480,6 +468,122 @@ const mockRects = () =>
480
468
  } as DOMRect;
481
469
  } );
482
470
 
471
+ describe( 'HeatmapChart value text contrast', () => {
472
+ // Extent 0..100, so each value is its own intensity in hundredths.
473
+ const scale: HeatmapColumn[] = [
474
+ { label: 'W1', data: [ { value: 0 }, { value: 85 }, { value: 100 } ] },
475
+ ];
476
+
477
+ let injectedStyle: HTMLStyleElement | null = null;
478
+
479
+ afterEach( () => {
480
+ injectedStyle?.remove();
481
+ injectedStyle = null;
482
+ } );
483
+
484
+ const renderScale = ( className?: string ) =>
485
+ render(
486
+ <GlobalChartsProvider>
487
+ <HeatmapChart
488
+ width={ 500 }
489
+ height={ 300 }
490
+ data={ scale }
491
+ primaryColor="#3858e9"
492
+ className={ className }
493
+ />
494
+ </GlobalChartsProvider>
495
+ );
496
+
497
+ test( 'picks the label role that reaches AA on light and dark cells', () => {
498
+ renderScale();
499
+
500
+ expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
501
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
502
+ } );
503
+
504
+ test( 'falls back to black on a mid-tone cell where neither role reaches AA', () => {
505
+ renderScale();
506
+
507
+ expect( screen.getByText( '85' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
508
+ } );
509
+
510
+ test( 'reads the label roles once data arrives after an empty first render', () => {
511
+ const { rerender } = render(
512
+ <GlobalChartsProvider>
513
+ <HeatmapChart width={ 500 } height={ 300 } data={ [] } primaryColor="#3858e9" />
514
+ </GlobalChartsProvider>
515
+ );
516
+
517
+ rerender(
518
+ <GlobalChartsProvider>
519
+ <HeatmapChart width={ 500 } height={ 300 } data={ scale } primaryColor="#3858e9" />
520
+ </GlobalChartsProvider>
521
+ );
522
+
523
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
524
+ } );
525
+
526
+ test( 'falls back to black or white when a label role uses syntax it cannot read', () => {
527
+ injectedStyle = document.createElement( 'style' );
528
+ injectedStyle.textContent =
529
+ '.modern-heatmap { --a8c-charts-color-label-inverse: rgb(255 255 255); }';
530
+ document.head.appendChild( injectedStyle );
531
+
532
+ renderScale( 'modern-heatmap' );
533
+
534
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--white' );
535
+ } );
536
+
537
+ test( 'measures a translucent label role as it paints over each cell', () => {
538
+ injectedStyle = document.createElement( 'style' );
539
+ injectedStyle.textContent =
540
+ '.translucent-heatmap { --a8c-charts-color-label: rgba(30, 30, 30, 0.3); }';
541
+ document.head.appendChild( injectedStyle );
542
+
543
+ renderScale( 'translucent-heatmap' );
544
+
545
+ expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--black' );
546
+ expect( screen.getByText( '100' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
547
+ } );
548
+
549
+ test( 'leaves a summary value, which sits on no fill, on the default role', () => {
550
+ injectedStyle = document.createElement( 'style' );
551
+ injectedStyle.textContent =
552
+ '.swapped-heatmap { --a8c-charts-color-label: #f0f0f0; --a8c-charts-color-label-inverse: #1e1e1e; }';
553
+ document.head.appendChild( injectedStyle );
554
+
555
+ render(
556
+ <GlobalChartsProvider>
557
+ <HeatmapChart
558
+ width={ 500 }
559
+ height={ 300 }
560
+ className="swapped-heatmap"
561
+ primaryColor="#3858e9"
562
+ data={ [ ...scale, { label: 'Total', summary: true, data: [ { value: 7 } ] } ] }
563
+ />
564
+ </GlobalChartsProvider>
565
+ );
566
+
567
+ expect( screen.getByText( '7' ) ).toHaveClass( 'heatmap-chart__cell-value', { exact: true } );
568
+ expect( screen.getByText( '0' ) ).toHaveClass( 'heatmap-chart__cell-value--inverse' );
569
+ } );
570
+
571
+ test( "keeps one color when both label roles are set to it on the chart's own class", () => {
572
+ injectedStyle = document.createElement( 'style' );
573
+ injectedStyle.textContent =
574
+ '.pinned-heatmap { --a8c-charts-color-label: #767676; --a8c-charts-color-label-inverse: #767676; }';
575
+ document.head.appendChild( injectedStyle );
576
+
577
+ renderScale( 'pinned-heatmap' );
578
+
579
+ [ '0', '85', '100' ].forEach( value =>
580
+ expect( screen.getByText( value ) ).toHaveClass( 'heatmap-chart__cell-value', {
581
+ exact: true,
582
+ } )
583
+ );
584
+ } );
585
+ } );
586
+
483
587
  describe( 'HeatmapChart keyboard tooltip', () => {
484
588
  test( 'opens on the selected cell without row labels', async () => {
485
589
  renderChart( { withTooltips: true } );
@@ -107,13 +107,9 @@ export interface HeatmapChartProps extends Omit<
107
107
  */
108
108
  primaryColor?: string;
109
109
  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
- */
110
+ /** @deprecated Every tooltip draws on the package tooltip surface; this prop has no effect. */
115
111
  tooltipVariant?: 'light' | 'dark';
116
- /** Inline styles merged onto the tooltip box, over the variant's own. */
112
+ /** Inline styles merged onto the tooltip box, over the surface's own. */
117
113
  tooltipStyle?: CSSProperties;
118
114
  children?: ReactNode;
119
115
  }
@@ -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);
@@ -66,7 +66,7 @@ import type { RenderTooltipParams } from '../../visx/types';
66
66
  import type { ResponsiveConfig } from '../private/with-responsive';
67
67
  import type { TickFormatter } from '@visx/axis';
68
68
  import type { GlyphProps } from '@visx/xychart';
69
- import type { CSSProperties, FC, Ref } from 'react';
69
+ import type { FC, Ref } from 'react';
70
70
 
71
71
  const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
72
72
  return <DefaultGlyph { ...props } key={ props.key } />;
@@ -116,13 +116,11 @@ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution
116
116
  * one row per visible series (label + formatted value), sorted descending by
117
117
  * value. Reused by AreaChart, which has the same multi-series shape.
118
118
  *
119
- * @param params - visx tooltip data and the chart's optional `bucketInfo`.
120
- * @param contentStyle - Explicit tooltip content color overrides.
119
+ * @param params - visx tooltip data and the chart's optional `bucketInfo`.
121
120
  * @return Tooltip JSX, or `null` when no datum is hovered.
122
121
  */
123
122
  export const renderDefaultTooltip = (
124
- params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo },
125
- contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
123
+ params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
126
124
  ) => {
127
125
  const { tooltipData, bucketInfo } = params;
128
126
  const nearestDatum = tooltipData?.nearestDatum?.datum;
@@ -141,11 +139,7 @@ export const renderDefaultTooltip = (
141
139
  } );
142
140
 
143
141
  return (
144
- <div
145
- className={ styles[ 'line-chart__tooltip' ] }
146
- data-testid="line-chart-tooltip-content"
147
- style={ contentStyle }
148
- >
142
+ <div data-testid="line-chart-tooltip-content">
149
143
  <div className={ styles[ 'line-chart__tooltip-date' ] }>
150
144
  <TooltipDate
151
145
  date={ nearestDatum.date }
@@ -153,13 +147,7 @@ export const renderDefaultTooltip = (
153
147
  />
154
148
  </div>
155
149
  { tooltipPoints.map( point => (
156
- <Stack
157
- key={ point.key }
158
- direction="row"
159
- align="center"
160
- justify="space-between"
161
- className={ styles[ 'line-chart__tooltip-row' ] }
162
- >
150
+ <Stack key={ point.key } direction="row" align="center" justify="space-between">
163
151
  <span className={ styles[ 'line-chart__tooltip-label' ] }>{ point.key }:</span>
164
152
  <span className={ styles[ 'line-chart__tooltip-value' ] }>
165
153
  { formatReading( point.value ) }
@@ -577,32 +565,12 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
577
565
  yAccessor: ( d: DataPointDate ) => d?.value,
578
566
  };
579
567
 
580
- const resolvedTooltipStyle = useMemo( () => {
581
- if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
582
- if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
583
- return tooltipStyle;
584
- }
585
- return {
586
- backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
587
- ...tooltipStyle,
588
- };
589
- }, [ renderTooltip, tooltipStyle ] );
590
-
591
568
  // Augments every renderTooltip call with the chart's bucket classification,
592
569
  // default or custom, so a heading keyed on it can't disagree with the axis.
593
570
  const tooltipRenderer = useMemo(
594
571
  () => ( params: RenderTooltipParams< DataPointDate > ) =>
595
- renderTooltip === renderDefaultTooltip
596
- ? renderDefaultTooltip(
597
- { ...params, bucketInfo },
598
- {
599
- color: tooltipStyle?.color,
600
- background: tooltipStyle?.background,
601
- backgroundColor: tooltipStyle?.backgroundColor,
602
- }
603
- )
604
- : renderTooltip( { ...params, bucketInfo } ),
605
- [ renderTooltip, bucketInfo, tooltipStyle ]
572
+ renderTooltip( { ...params, bucketInfo } ),
573
+ [ renderTooltip, bucketInfo ]
606
574
  );
607
575
 
608
576
  if ( error ) {
@@ -820,7 +788,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
820
788
  detectBounds
821
789
  snapTooltipToDatumX
822
790
  tooltipPlacement={ tooltipPlacement }
823
- style={ resolvedTooltipStyle }
791
+ style={ tooltipStyle }
824
792
  snapTooltipToDatumY
825
793
  showSeriesGlyphs
826
794
  renderTooltip={ tooltipRenderer }