@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
@@ -140,54 +140,26 @@ describe( 'LineChart', () => {
140
140
  } );
141
141
  }
142
142
  );
143
- test.each( [
144
- [
145
- { color: 'white' },
146
- 'rgb(255, 255, 255)',
147
- 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
148
- { color: 'rgb(255, 255, 255)' },
149
- ],
150
- [ { backgroundColor: 'white' }, 'var(--a8c-charts-color-label)', 'rgb(255, 255, 255)', {} ],
151
- [ { background: 'white' }, 'var(--a8c-charts-color-label)', 'white', {} ],
152
- [
153
- { color: 'white', background: 'black' },
154
- 'rgb(255, 255, 255)',
155
- 'black',
156
- { color: 'rgb(255, 255, 255)' },
157
- ],
158
- ] )(
159
- 'applies directional tooltip color defaults: %j',
160
- async ( tooltipStyle, color, background, containerColorStyle ) => {
161
- const user = userEvent.setup();
162
- const defaults = document.createElement( 'style' );
163
- defaults.textContent = `.line-chart__tooltip {
164
- color: var(--a8c-charts-color-label);
165
- background: var(--a8c-charts-color-surface);
166
- }`;
167
- renderWithTheme( { tooltipStyle } );
168
- const chart = screen.getByTestId( 'line-chart' );
169
- chart.appendChild( defaults );
170
- chart.style.setProperty( '--a8c-charts-color-label', '#000' );
171
- chart.style.setProperty( '--a8c-charts-color-label-axis', '#aaa' );
172
- chart.style.setProperty( '--a8c-charts-color-surface', '#fff' );
173
- screen.getByRole( 'grid', { name: /line chart/i } ).focus();
174
- await user.keyboard( '{ArrowRight}' );
143
+ test( 'passes a color-only tooltipStyle through with no background added', async () => {
144
+ const user = userEvent.setup();
145
+ renderWithTheme( { tooltipStyle: { color: 'white' } } );
146
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
147
+ await user.keyboard( '{ArrowRight}' );
175
148
 
176
- const content = screen.getByTestId( 'line-chart-tooltip-content' );
177
- expect( content ).toHaveStyle( {
178
- color,
179
- [ 'backgroundColor' in tooltipStyle ? 'background-color' : 'background' ]:
180
- 'background' in tooltipStyle || 'backgroundColor' in tooltipStyle
181
- ? background
182
- : 'var(--a8c-charts-color-surface)',
183
- } );
184
- const container = screen.getByTestId( 'bounded-tooltip' );
185
- expect( container ).toHaveStyle( {
186
- ...containerColorStyle,
187
- [ 'background' in tooltipStyle ? 'background' : 'background-color' ]: background,
188
- } );
189
- }
190
- );
149
+ const box = screen.getByTestId( 'bounded-tooltip' );
150
+ expect( box ).toHaveStyle( { color: 'rgb(255, 255, 255)' } );
151
+ expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background' ) );
152
+ } );
153
+
154
+ test( 'paints a tooltipStyle background on the box, not on the default content', async () => {
155
+ const user = userEvent.setup();
156
+ renderWithTheme( { tooltipStyle: { background: 'black' } } );
157
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
158
+ await user.keyboard( '{ArrowRight}' );
159
+
160
+ expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'black' } );
161
+ expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).not.toHaveAttribute( 'style' );
162
+ } );
191
163
 
192
164
  test( 'preserves the existing tooltip colors for noncolor overrides', async () => {
193
165
  const user = userEvent.setup();
@@ -22,5 +22,14 @@
22
22
  &--on-light {
23
23
  fill: var(--a8c-charts-color-label, #1e1e1e);
24
24
  }
25
+
26
+ // Neither role reaches AA on this slice; see pickLabelTextColor.
27
+ &--black {
28
+ fill: #000;
29
+ }
30
+
31
+ &--white {
32
+ fill: #fff;
33
+ }
25
34
  }
26
35
  }
@@ -3,6 +3,7 @@ import { Pie } from '@visx/shape';
3
3
  import { useTooltip } from '@visx/tooltip';
4
4
  import { color as d3Color } from '@visx/vendor/d3-color';
5
5
  import clsx from 'clsx';
6
+ import isEqual from 'fast-deep-equal';
6
7
  import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
7
8
  import { Legend, useChartLegendItems } from '../../components/legend';
8
9
  import { BaseTooltip } from '../../components/tooltip';
@@ -21,20 +22,14 @@ import {
21
22
  GlobalChartsContext,
22
23
  } from '../../providers';
23
24
  import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
24
- import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
25
25
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
26
- import {
27
- attachSubComponents,
28
- createCssVariableResolver,
29
- isValidHexColor,
30
- normalizeColorToHex,
31
- resolveFontSize,
32
- } from '../../utils';
26
+ import { attachSubComponents, createCssVariableResolver, resolveFontSize } from '../../utils';
33
27
  import { getStringWidth } from '../../visx/text';
34
28
  import { Center } from '../private/center';
35
29
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
36
30
  import { ChartInstanceContext } from '../private/chart-instance-context';
37
31
  import { ChartLayout } from '../private/chart-layout';
32
+ import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-color';
38
33
  import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
39
34
  import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
40
35
  import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
@@ -47,7 +42,8 @@ import type {
47
42
  Optional,
48
43
  } from '../../types';
49
44
  import type { ChartComponentWithComposition } from '../private/chart-composition';
50
- import type { SVGProps, MouseEvent, ReactNode, FC } from 'react';
45
+ import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
46
+ import type { JSX, SVGProps, MouseEvent, ReactNode, FC } from 'react';
51
47
 
52
48
  /**
53
49
  * Parameters passed to the renderTooltip function for pie charts.
@@ -170,48 +166,12 @@ const validateData = ( data: DataPointPercentage[] ) => {
170
166
  return { isValid: true, message: '' };
171
167
  };
172
168
 
173
- /**
174
- * The label pointers, resolved at the pie chart's own root element so JS and CSS agree on what paints.
175
- */
176
- interface ResolvedLabelPointers {
177
- /** True while a label plate is set; the label text then always uses the inverse role. */
178
- hasPlate: boolean;
179
- labelHex: string;
180
- labelInverseHex: string;
181
- /** True when the inverse role is see-through, so only the `label` role can be seen on a fill. */
182
- isInverseSeeThrough: boolean;
183
- }
184
-
185
- /**
186
- * Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
187
- *
188
- * A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
189
- * Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
190
- * yet resolved, or labels off) keep the default inverse role.
191
- *
192
- * @param fill - The slice's resolved fill color.
193
- * @param pointers - The label pointers, resolved at the chart's own root element.
194
- * @return Whether the label needs dark text.
195
- */
196
- const labelNeedsDarkText = ( fill: string, pointers: ResolvedLabelPointers | null ): boolean => {
197
- if ( ! pointers || pointers.hasPlate ) {
198
- return false;
199
- }
200
-
201
- const fillHex = normalizeColorToHex( fill );
202
- if ( ! isValidHexColor( fillHex ) || ! isValidHexColor( pointers.labelHex ) ) {
203
- return false;
204
- }
205
- if ( pointers.isInverseSeeThrough ) {
206
- return true;
207
- }
208
- if ( ! isValidHexColor( pointers.labelInverseHex ) ) {
209
- return false;
210
- }
211
-
212
- return (
213
- contrastRatio( fillHex, pointers.labelHex ) > contrastRatio( fillHex, pointers.labelInverseHex )
214
- );
169
+ // `label-inverse` is the stylesheet's default, so it needs no modifier.
170
+ const LABEL_TEXT_MODIFIER: Record< LabelTextColor, string | undefined > = {
171
+ label: styles[ 'pie-chart__label-text--on-light' ],
172
+ 'label-inverse': undefined,
173
+ black: styles[ 'pie-chart__label-text--black' ],
174
+ white: styles[ 'pie-chart__label-text--white' ],
215
175
  };
216
176
 
217
177
  /**
@@ -258,7 +218,8 @@ const PieChartInternal = ( {
258
218
  // The element the chart's own `className` lands on, so an override set there reaches this
259
219
  // decision the same way it reaches CSS.
260
220
  const rootRef = useRef< HTMLDivElement >( null );
261
- const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
221
+ // Null until read, and while a label plate is set: text on the plate keeps the inverse role.
222
+ const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
262
223
 
263
224
  const onMouseLeave = useCallback( () => {
264
225
  if ( ! withTooltips ) {
@@ -279,28 +240,11 @@ const PieChartInternal = ( {
279
240
 
280
241
  const resolve = createCssVariableResolver( rootRef.current );
281
242
  const rawLabelBackground = resolve( CATALOG_POINTERS.labelBackground );
282
- // Hex drops alpha, so a see-through label role would win the comparison and paint nothing.
283
- const rawLabel = resolve( CATALOG_POINTERS.label );
284
- const isLabelOpaque = rawLabel ? d3Color( rawLabel )?.opacity === 1 : false;
285
- const rawLabelInverse = resolve( CATALOG_POINTERS.labelInverse );
286
- const labelInverseColor = rawLabelInverse ? d3Color( rawLabelInverse ) : null;
287
243
  // A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
288
244
  const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
289
- const next: ResolvedLabelPointers = {
290
- hasPlate: rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false,
291
- labelHex: isLabelOpaque ? normalizeColorToHex( CATALOG_POINTERS.label, null, resolve ) : '',
292
- labelInverseHex: normalizeColorToHex( CATALOG_POINTERS.labelInverse, null, resolve ),
293
- isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false,
294
- };
295
- setLabelPointers( previous =>
296
- previous &&
297
- previous.hasPlate === next.hasPlate &&
298
- previous.labelHex === next.labelHex &&
299
- previous.labelInverseHex === next.labelInverseHex &&
300
- previous.isInverseSeeThrough === next.isInverseSeeThrough
301
- ? previous
302
- : next
303
- );
245
+ const hasPlate = rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false;
246
+ const next = hasPlate ? null : resolveLabelRoles( resolve );
247
+ setLabelRoles( previous => ( isEqual( previous, next ) ? previous : next ) );
304
248
  }, [ showLabels, className, isColorPaletteResolved, isValid ] );
305
249
 
306
250
  // Calculate percentages from values (single source of truth)
@@ -538,10 +482,12 @@ const PieChartInternal = ( {
538
482
  pointerEvents="none"
539
483
  />
540
484
  <text
541
- className={ clsx( styles[ 'pie-chart__label-text' ], {
542
- [ styles[ 'pie-chart__label-text--on-light' ] ]:
543
- labelNeedsDarkText( fill, labelPointers ),
544
- } ) }
485
+ className={ clsx(
486
+ styles[ 'pie-chart__label-text' ],
487
+ LABEL_TEXT_MODIFIER[
488
+ pickLabelTextColor( fill, labelRoles, 'label-inverse' )
489
+ ]
490
+ ) }
545
491
  data-testid="pie-label"
546
492
  x={ centroidX }
547
493
  y={ centroidY }
@@ -187,17 +187,17 @@ describe( 'PieChart', () => {
187
187
 
188
188
  test( "compares against a label-inverse override set on the chart's own class", () => {
189
189
  injectedStyle = document.createElement( 'style' );
190
- // Matches the slice fill exactly, so the inverse role loses to the default dark label role.
191
- injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #1e3a8a; }';
190
+ // Black out-contrasts the dark label role on a light slice, where the default inverse would lose.
191
+ injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #000000; }';
192
192
  document.head.appendChild( injectedStyle );
193
193
 
194
194
  renderWithTheme( {
195
- data: [ { label: 'Dark', value: 100, color: '#1e3a8a' } ],
195
+ data: [ { label: 'Light', value: 100, color: '#f5d76e' } ],
196
196
  className: 'overridden-pie',
197
197
  } );
198
198
 
199
199
  const [ label ] = screen.getAllByTestId( 'pie-label' );
200
- expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
200
+ expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' );
201
201
  } );
202
202
 
203
203
  test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
@@ -239,7 +239,7 @@ describe( 'PieChart', () => {
239
239
  .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
240
240
  } );
241
241
 
242
- test( 'uses the label role on every slice when the inverse role is see-through', () => {
242
+ test( 'never picks a see-through inverse role, falling back to white where the label role fails', () => {
243
243
  injectedStyle = document.createElement( 'style' );
244
244
  injectedStyle.textContent =
245
245
  '.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
@@ -247,9 +247,22 @@ describe( 'PieChart', () => {
247
247
 
248
248
  renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
249
249
 
250
- screen
251
- .getAllByTestId( 'pie-label' )
252
- .forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
250
+ const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
251
+ expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
252
+ expect( dark ).toHaveClass( 'pie-chart__label-text--white' );
253
+ } );
254
+
255
+ test( 'falls back to black or white on a slice where neither label role reaches AA', () => {
256
+ renderWithTheme( {
257
+ data: [
258
+ { label: 'Mid-light', value: 50, color: '#516dec' },
259
+ { label: 'Mid-dark', value: 50, color: '#4663ea' },
260
+ ],
261
+ } );
262
+
263
+ const [ midLight, midDark ] = screen.getAllByTestId( 'pie-label' );
264
+ expect( midLight ).toHaveClass( 'pie-chart__label-text--black' );
265
+ expect( midDark ).toHaveClass( 'pie-chart__label-text--white' );
253
266
  } );
254
267
 
255
268
  test( 'keeps the inverse label color when the fill cannot be resolved', () => {
@@ -1,4 +1,4 @@
1
- import type { FC, PropsWithChildren } from 'react';
1
+ import type { JSX, FC, PropsWithChildren } from 'react';
2
2
 
3
3
  /**
4
4
  * Compound component for HTML children in charts.
@@ -1,4 +1,4 @@
1
- import type { FC, PropsWithChildren } from 'react';
1
+ import type { JSX, FC, PropsWithChildren } from 'react';
2
2
 
3
3
  /**
4
4
  * Compound component for SVG children in charts.
@@ -0,0 +1,116 @@
1
+ import { color as d3Color } from '@visx/vendor/d3-color';
2
+ import isEqual from 'fast-deep-equal';
3
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
4
+ import { MIN_LABEL_CONTRAST } from '../../providers/chart-context/private/palette-generator';
5
+ import {
6
+ blendRgb,
7
+ hexToRgb,
8
+ luminanceContrastRatio,
9
+ rgbLuminance,
10
+ } from '../../providers/chart-context/private/perceptual-color';
11
+ import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
12
+ import type { Rgb } from '../../providers/chart-context/private/perceptual-color';
13
+
14
+ // The browser paints the fill on 8-bit channels, which can cost a mid-tone ~0.03 of contrast.
15
+ const ROUNDING_HEADROOM = 0.05;
16
+
17
+ /** Which color a label drawn on a fill paints with: one of the two catalog roles, or the black/white floor. */
18
+ export type LabelTextColor = 'label' | 'label-inverse' | 'black' | 'white';
19
+
20
+ /** A label role as the chart's own element resolves it. `unreadable` keeps the raw value, which CSS may still paint. */
21
+ export type ResolvedRole =
22
+ { kind: 'color'; rgb: Rgb; alpha: number } | { kind: 'unreadable'; raw: string | null };
23
+
24
+ export interface LabelRoles {
25
+ label: ResolvedRole;
26
+ labelInverse: ResolvedRole;
27
+ }
28
+
29
+ const resolveRole = (
30
+ pointer: string,
31
+ resolve: ( value: string ) => string | null
32
+ ): ResolvedRole => {
33
+ const raw = resolve( pointer );
34
+ // d3 reads no CSS Color 4 syntax (`rgb(0 0 0)`, `oklch()`), though the browser paints it.
35
+ const parsed = raw ? d3Color( raw ) : null;
36
+ if ( ! parsed ) {
37
+ return { kind: 'unreadable', raw };
38
+ }
39
+ // d3 gives `transparent` NaN channels; with no alpha they never reach the composite anyway.
40
+ const { r, g, b } = parsed.opacity > 0 ? parsed.rgb() : { r: 0, g: 0, b: 0 };
41
+ return { kind: 'color', rgb: [ r, g, b ], alpha: parsed.opacity };
42
+ };
43
+
44
+ /**
45
+ * Reads both label roles at the element the label text inherits from, so JS and CSS agree on what paints.
46
+ *
47
+ * @param resolve - A CSS variable resolver bound to that element.
48
+ * @return The resolved label roles.
49
+ */
50
+ export const resolveLabelRoles = ( resolve: ( value: string ) => string | null ): LabelRoles => ( {
51
+ label: resolveRole( CATALOG_POINTERS.label, resolve ),
52
+ labelInverse: resolveRole( CATALOG_POINTERS.labelInverse, resolve ),
53
+ } );
54
+
55
+ // Text with alpha shows the fill through it, so it is measured as the blend the browser paints.
56
+ const roleContrast = ( role: ResolvedRole, fill: Rgb, fillLuminance: number ): number => {
57
+ if ( role.kind === 'unreadable' ) {
58
+ return 0;
59
+ }
60
+ return luminanceContrastRatio(
61
+ fillLuminance,
62
+ rgbLuminance( blendRgb( role.rgb, fill, role.alpha ) )
63
+ );
64
+ };
65
+
66
+ /**
67
+ * Picks the label color for text drawn on a fill: the role that contrasts more, or black/white when neither reaches AA.
68
+ *
69
+ * Both roles set to one color is a host's choice to keep that color, so it never falls back.
70
+ *
71
+ * @param fill - The fill the text sits on, as unrounded sRGB channels.
72
+ * @param roles - The label roles resolved at the chart's element, or null before they are read.
73
+ * @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
74
+ * @return The color the label should paint with.
75
+ */
76
+ export const pickLabelTextColorForFill = (
77
+ fill: Rgb,
78
+ roles: LabelRoles | null,
79
+ defaultRole: 'label' | 'label-inverse'
80
+ ): LabelTextColor => {
81
+ if ( ! roles || isEqual( roles.label, roles.labelInverse ) ) {
82
+ return defaultRole;
83
+ }
84
+
85
+ const fillLuminance = rgbLuminance( fill );
86
+ const labelContrast = roleContrast( roles.label, fill, fillLuminance );
87
+ const inverseContrast = roleContrast( roles.labelInverse, fill, fillLuminance );
88
+ if ( Math.max( labelContrast, inverseContrast ) >= MIN_LABEL_CONTRAST + ROUNDING_HEADROOM ) {
89
+ return labelContrast > inverseContrast ? 'label' : 'label-inverse';
90
+ }
91
+
92
+ // On any opaque fill one of these reaches at least 4.58:1.
93
+ return luminanceContrastRatio( fillLuminance, 0 ) > luminanceContrastRatio( fillLuminance, 1 )
94
+ ? 'black'
95
+ : 'white';
96
+ };
97
+
98
+ /**
99
+ * `pickLabelTextColorForFill` for a fill given as a color.
100
+ *
101
+ * @param fill - The fill, in any color syntax `normalizeColorToHex` reads.
102
+ * @param roles - The label roles resolved at the chart's element, or null before they are read.
103
+ * @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
104
+ * @return The color the label should paint with.
105
+ */
106
+ export const pickLabelTextColor = (
107
+ fill: string,
108
+ roles: LabelRoles | null,
109
+ defaultRole: 'label' | 'label-inverse'
110
+ ): LabelTextColor => {
111
+ const fillHex = normalizeColorToHex( fill );
112
+ if ( ! isValidHexColor( fillHex ) ) {
113
+ return defaultRole;
114
+ }
115
+ return pickLabelTextColorForFill( hexToRgb( fillHex ), roles, defaultRole );
116
+ };
@@ -9,6 +9,8 @@
9
9
  * - A white mask makes the area visible, while a black mask makes the area invisible.
10
10
  */
11
11
 
12
+ import type { JSX } from 'react';
13
+
12
14
  type Angle = `${ number }deg` | `${ number }rad` | `${ number }grad` | `${ number }turn` | 0 | '0';
13
15
 
14
16
  /**
@@ -1,6 +1,6 @@
1
1
  import { Center } from '../center';
2
2
  import styles from './svg-empty-state.module.scss';
3
- import type { FC, ReactNode } from 'react';
3
+ import type { JSX, FC, ReactNode } from 'react';
4
4
 
5
5
  interface SvgEmptyStateProps {
6
6
  /** X coordinate of the center point */
@@ -0,0 +1,191 @@
1
+ import { MIN_LABEL_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import {
3
+ blendRgb,
4
+ hexToRgb,
5
+ luminanceContrastRatio,
6
+ rgbLuminance,
7
+ } from '../../../providers/chart-context/private/perceptual-color';
8
+ import {
9
+ pickLabelTextColor,
10
+ pickLabelTextColorForFill,
11
+ resolveLabelRoles,
12
+ } from '../label-text-color';
13
+ import type { Rgb } from '../../../providers/chart-context/private/perceptual-color';
14
+ import type { LabelRoles, LabelTextColor, ResolvedRole } from '../label-text-color';
15
+
16
+ const color = ( hex: string, alpha = 1 ): ResolvedRole => ( {
17
+ kind: 'color',
18
+ rgb: hexToRgb( hex ),
19
+ alpha,
20
+ } );
21
+
22
+ const unreadable = ( raw: string | null ): ResolvedRole => ( { kind: 'unreadable', raw } );
23
+
24
+ const DEFAULT_ROLES: LabelRoles = { label: color( '#1e1e1e' ), labelInverse: color( '#f0f0f0' ) };
25
+
26
+ const WHITE = hexToRgb( '#ffffff' );
27
+ const MID_BLUE = hexToRgb( '#3858e9' );
28
+
29
+ describe( 'resolveLabelRoles', () => {
30
+ const resolverFor =
31
+ ( label: string | null, labelInverse: string | null ) =>
32
+ ( value: string ): string | null =>
33
+ value.includes( '--a8c-charts-color-label-inverse' ) ? labelInverse : label;
34
+
35
+ it( 'resolves opaque roles in any syntax d3 reads', () => {
36
+ expect( resolveLabelRoles( resolverFor( '#1E1E1E', 'rgb(240, 240, 240)' ) ) ).toEqual(
37
+ DEFAULT_ROLES
38
+ );
39
+ } );
40
+
41
+ it( "keeps a role's alpha", () => {
42
+ expect( resolveLabelRoles( resolverFor( 'rgba(30, 30, 30, 0.87)', '#f0f0f0' ) ).label ).toEqual(
43
+ color( '#1e1e1e', 0.87 )
44
+ );
45
+ } );
46
+
47
+ it( 'reads transparent as a role with no alpha', () => {
48
+ expect( resolveLabelRoles( resolverFor( 'transparent', '#f0f0f0' ) ).label ).toEqual(
49
+ color( '#000000', 0 )
50
+ );
51
+ } );
52
+
53
+ it( 'keeps the raw value of a role it cannot read', () => {
54
+ expect( resolveLabelRoles( resolverFor( null, 'oklch(98% 0 0)' ) ) ).toEqual( {
55
+ label: unreadable( null ),
56
+ labelInverse: unreadable( 'oklch(98% 0 0)' ),
57
+ } );
58
+ } );
59
+ } );
60
+
61
+ describe( 'pickLabelTextColorForFill', () => {
62
+ it( 'keeps the default role before the roles are read', () => {
63
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label' ) ).toBe( 'label' );
64
+ expect( pickLabelTextColorForFill( MID_BLUE, null, 'label-inverse' ) ).toBe( 'label-inverse' );
65
+ } );
66
+
67
+ it( 'picks the role that contrasts more when it reaches AA', () => {
68
+ expect( pickLabelTextColorForFill( WHITE, DEFAULT_ROLES, 'label-inverse' ) ).toBe( 'label' );
69
+ expect( pickLabelTextColorForFill( MID_BLUE, DEFAULT_ROLES, 'label' ) ).toBe( 'label-inverse' );
70
+ } );
71
+
72
+ it( 'falls back to black or white when neither role reaches AA', () => {
73
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), DEFAULT_ROLES, 'label' ) ).toBe(
74
+ 'black'
75
+ );
76
+ expect( pickLabelTextColorForFill( hexToRgb( '#4663ea' ), DEFAULT_ROLES, 'label' ) ).toBe(
77
+ 'white'
78
+ );
79
+ } );
80
+
81
+ it( 'measures a translucent role as it paints over the fill', () => {
82
+ // Opaque, this label would read 16:1 on white; at 30% it paints a pale gray that fails AA.
83
+ const faintLabel: LabelRoles = {
84
+ label: color( '#1e1e1e', 0.3 ),
85
+ labelInverse: color( '#f0f0f0' ),
86
+ };
87
+
88
+ expect( pickLabelTextColorForFill( WHITE, faintLabel, 'label-inverse' ) ).toBe( 'black' );
89
+ } );
90
+
91
+ it( 'never picks a fully transparent role', () => {
92
+ const clearLabel: LabelRoles = {
93
+ label: color( '#000000', 0 ),
94
+ labelInverse: color( '#f0f0f0' ),
95
+ };
96
+
97
+ expect( pickLabelTextColorForFill( WHITE, clearLabel, 'label' ) ).toBe( 'black' );
98
+ } );
99
+
100
+ it( 'treats two different translucent roles as two colors', () => {
101
+ const translucent: LabelRoles = {
102
+ label: color( '#000000', 0.87 ),
103
+ labelInverse: color( '#ffffff', 0.9 ),
104
+ };
105
+
106
+ expect( pickLabelTextColorForFill( MID_BLUE, translucent, 'label' ) ).toBe( 'label-inverse' );
107
+ } );
108
+
109
+ it( 'uses a readable role when the other cannot be read, else black or white', () => {
110
+ const unreadableInverse: LabelRoles = {
111
+ label: color( '#1e1e1e' ),
112
+ labelInverse: unreadable( 'rgb(255 255 255)' ),
113
+ };
114
+
115
+ expect( pickLabelTextColorForFill( WHITE, unreadableInverse, 'label-inverse' ) ).toBe(
116
+ 'label'
117
+ );
118
+ expect( pickLabelTextColorForFill( MID_BLUE, unreadableInverse, 'label' ) ).toBe( 'white' );
119
+ } );
120
+
121
+ it.each( [
122
+ [ 'readable', color( '#767676' ) ],
123
+ [ 'unreadable', unreadable( 'oklch(55% 0 0)' ) ],
124
+ ] )( 'never falls back when both roles are the same %s color', ( _name, role ) => {
125
+ const pinned: LabelRoles = { label: role, labelInverse: { ...role } };
126
+
127
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label' ) ).toBe( 'label' );
128
+ expect( pickLabelTextColorForFill( hexToRgb( '#516dec' ), pinned, 'label-inverse' ) ).toBe(
129
+ 'label-inverse'
130
+ );
131
+ } );
132
+
133
+ describe.each( [
134
+ [ 'white', '#ffffff' ],
135
+ [ 'dark', '#1e1e1e' ],
136
+ ] )( 'across a heatmap scale on a %s background', ( _name, background ) => {
137
+ const painted: Record< LabelTextColor, number > = {
138
+ label: rgbLuminance( hexToRgb( '#1e1e1e' ) ),
139
+ 'label-inverse': rgbLuminance( hexToRgb( '#f0f0f0' ) ),
140
+ black: 0,
141
+ white: 1,
142
+ };
143
+ // The Storybook custom accent, then the wp-admin scheme colors.
144
+ const primaries = [
145
+ '#4a19ab',
146
+ '#2271b1',
147
+ '#007cba',
148
+ '#3858e9',
149
+ '#437aa8',
150
+ '#916745',
151
+ '#646c3e',
152
+ '#cf4339',
153
+ '#567958',
154
+ '#ad631e',
155
+ ];
156
+
157
+ it.each( primaries )( 'reaches AA on every intensity of %s', primary => {
158
+ 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
+ );
164
+ const choice = pickLabelTextColorForFill( fill, DEFAULT_ROLES, 'label' );
165
+ // Checked on the 8-bit channels the browser paints as well as the exact mix.
166
+ const [ r, g, b ] = fill.map( Math.round );
167
+ const painted8Bit: Rgb = [ r, g, b ];
168
+
169
+ for ( const shown of [ fill, painted8Bit ] ) {
170
+ expect(
171
+ luminanceContrastRatio( rgbLuminance( shown ), painted[ choice ] )
172
+ ).toBeGreaterThanOrEqual( MIN_LABEL_CONTRAST );
173
+ }
174
+ }
175
+ } );
176
+ } );
177
+ } );
178
+
179
+ describe( 'pickLabelTextColor', () => {
180
+ it( 'reads the fill from any color syntax', () => {
181
+ expect( pickLabelTextColor( 'rgb(255, 255, 255)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
182
+ 'label'
183
+ );
184
+ } );
185
+
186
+ it( 'keeps the default role for a fill it cannot read', () => {
187
+ expect( pickLabelTextColor( 'var(--not-set)', DEFAULT_ROLES, 'label-inverse' ) ).toBe(
188
+ 'label-inverse'
189
+ );
190
+ } );
191
+ } );
@@ -3,6 +3,7 @@ import { useContext } from 'react';
3
3
  import { ChartScopeContext } from '../../../../providers/chart-scope';
4
4
  import { withResponsive } from '../index';
5
5
  import type { BaseChartProps } from '../../../../types';
6
+ import type { JSX } from 'react';
6
7
 
7
8
  // Mock the useParentSize hook
8
9
  jest.mock( '@visx/responsive', () => ( {