@automattic/charts 4.5.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 (62) hide show
  1. package/CHANGELOG.md +24 -2
  2. package/TOKENS.md +12 -10
  3. package/dist/index.cjs +248 -146
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -20
  6. package/dist/index.d.cts +22 -11
  7. package/dist/index.d.ts +22 -11
  8. package/dist/index.js +249 -147
  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/geo-chart/geo-chart.tsx +4 -0
  16. package/src/charts/geo-chart/index.ts +7 -1
  17. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  18. package/src/charts/geo-chart/types.ts +12 -0
  19. package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
  20. package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
  21. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
  22. package/src/charts/heatmap-chart/types.ts +2 -6
  23. package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
  24. package/src/charts/line-chart/line-chart.module.scss +0 -5
  25. package/src/charts/line-chart/line-chart.tsx +8 -40
  26. package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
  27. package/src/charts/pie-chart/pie-chart.module.scss +9 -0
  28. package/src/charts/pie-chart/pie-chart.tsx +22 -76
  29. package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
  30. package/src/charts/private/chart-composition/chart-html.tsx +1 -1
  31. package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
  32. package/src/charts/private/label-text-color.ts +116 -0
  33. package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
  34. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
  35. package/src/charts/private/test/label-text-color.test.ts +191 -0
  36. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
  37. package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
  38. package/src/components/tooltip/accessible-tooltip.tsx +1 -1
  39. package/src/components/tooltip/base-tooltip.module.scss +12 -8
  40. package/src/components/tooltip/base-tooltip.tsx +11 -9
  41. package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
  42. package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
  43. package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
  44. package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
  45. package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
  46. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
  47. package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
  48. package/src/components/trend-indicator/trend-indicator.tsx +1 -0
  49. package/src/hooks/index.ts +1 -0
  50. package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
  51. package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
  52. package/src/hooks/use-xychart-theme.ts +4 -12
  53. package/src/index.ts +7 -1
  54. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
  55. package/src/providers/chart-context/private/perceptual-color.ts +45 -4
  56. package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
  57. package/src/styles/chart-scope-class.ts +5 -0
  58. package/src/styles/chart-scope.scss +9 -6
  59. package/src/styles/test/chart-scope.test.ts +3 -3
  60. package/src/utils/color-utils.ts +2 -1
  61. package/src/visx/types.ts +1 -1
  62. 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.5.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
  }
@@ -130,6 +130,7 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
130
130
  * @param props.height - Height of the chart in pixels
131
131
  * @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
132
132
  * @param props.resolution - Resolution level ('countries', 'provinces', or 'metros')
133
+ * @param props.displayMode - Plot rows as shaded areas ('regions') or markers ('markers')
133
134
  * @param props.domain - Country code whose viewpoint sets the disputed borders
134
135
  * @param props.onError - Optional callback for Google Charts errors
135
136
  * @param props.className - Additional CSS class name for the chart container
@@ -143,6 +144,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
143
144
  height,
144
145
  region = 'world',
145
146
  resolution = 'countries',
147
+ displayMode = 'regions',
146
148
  domain,
147
149
  onError,
148
150
  renderPlaceholder,
@@ -264,6 +266,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
264
266
  () => ( {
265
267
  ...( region !== 'world' && { region } ),
266
268
  ...( resolution !== 'countries' && { resolution } ),
269
+ ...( displayMode !== 'regions' && { displayMode } ),
267
270
  ...( domain && { domain } ),
268
271
  colorAxis: { colors: [ lightColorHex, fullColorHex ] },
269
272
  backgroundColor: backgroundColorHex,
@@ -276,6 +279,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
276
279
  [
277
280
  region,
278
281
  resolution,
282
+ displayMode,
279
283
  domain,
280
284
  lightColorHex,
281
285
  fullColorHex,
@@ -1,2 +1,8 @@
1
1
  export { default as GeoChart, GeoChartUnresponsive } from './geo-chart';
2
- export type { GeoChartProps, GeoRegion, GeoResolution, GeoChartError } from './types';
2
+ export type {
3
+ GeoChartProps,
4
+ GeoRegion,
5
+ GeoResolution,
6
+ GeoDisplayMode,
7
+ GeoChartError,
8
+ } from './types';
@@ -224,6 +224,14 @@ describe( 'GeoChart', () => {
224
224
  expect( options.domain ).toBe( 'IN' );
225
225
  } );
226
226
 
227
+ test( 'passes the markers display mode so rows draw as points, not shaded areas', () => {
228
+ renderWithTheme( { displayMode: 'markers' } );
229
+
230
+ const options = JSON.parse( screen.getByTestId( 'chart-options' ).textContent || '{}' );
231
+
232
+ expect( options.displayMode ).toBe( 'markers' );
233
+ } );
234
+
227
235
  test( 'does not include resolution in options when set to countries (default)', () => {
228
236
  renderWithTheme();
229
237
 
@@ -15,6 +15,13 @@ export type GeoRegion = 'world' | ( string & {} );
15
15
  */
16
16
  export type GeoResolution = 'countries' | 'provinces' | 'metros';
17
17
 
18
+ /**
19
+ * How the map plots its rows.
20
+ * - 'regions': Shades each area the rows name
21
+ * - 'markers': Draws a sized, colored circle per row; a row can name a place or give `[ latitude, longitude ]` as its first two columns
22
+ */
23
+ export type GeoDisplayMode = 'regions' | 'markers';
24
+
18
25
  export interface GeoChartError {
19
26
  id?: string;
20
27
  message?: string;
@@ -48,6 +55,11 @@ export interface GeoChartProps extends Pick<
48
55
  * @default 'countries'
49
56
  */
50
57
  resolution?: GeoResolution;
58
+ /**
59
+ * How the map plots its rows: shaded areas, or one marker per row.
60
+ * @default 'regions'
61
+ */
62
+ displayMode?: GeoDisplayMode;
51
63
  /**
52
64
  * ISO 3166-1 alpha-2 code of the country whose viewpoint sets the disputed borders
53
65
  * (e.g., 'IN' draws Kashmir as India shows it). Google's default borders apply when omitted.
@@ -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);