@automattic/charts 4.4.0 → 4.5.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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +521 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +10 -3
  7. package/dist/index.d.ts +10 -3
  8. package/dist/index.js +521 -203
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/bar-chart/bar-chart.tsx +22 -10
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
  13. package/src/charts/geo-chart/geo-chart.tsx +4 -0
  14. package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
  15. package/src/charts/geo-chart/types.ts +5 -0
  16. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  17. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  18. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  19. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  20. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  21. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  22. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  23. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  24. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  25. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  26. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  27. package/src/providers/chart-context/private/series-palette.ts +1 -1
  28. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  29. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  30. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  31. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  32. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  33. package/src/utils/color-utils.ts +5 -3
  34. package/src/utils/test/date-formatting.test.ts +1 -1
  35. package/src/providers/chart-context/private/get-chart-color.ts +0 -223
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "4.4.0",
3
+ "version": "4.5.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": {
@@ -63,7 +63,7 @@
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
65
65
  "dependencies": {
66
- "@automattic/number-formatters": "^1.2.9",
66
+ "@automattic/number-formatters": "^1.2.10",
67
67
  "@babel/runtime": "7.29.7",
68
68
  "@date-fns/tz": "1.5.0",
69
69
  "@react-spring/web": "10.1.1",
@@ -272,16 +272,27 @@ const BarChartInternal: FC< BarChartProps > = ( {
272
272
 
273
273
  const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
274
274
 
275
- const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
276
- selectedIndex,
277
- setSelectedIndex,
278
- isNavigating,
279
- setIsNavigating,
280
- chartRef,
281
- totalPoints,
282
- onActivate: activateSelectedBar,
283
- visibleSeriesKey,
284
- } );
275
+ const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown, onChartPointerMove } =
276
+ useKeyboardNavigation( {
277
+ selectedIndex,
278
+ setSelectedIndex,
279
+ isNavigating,
280
+ setIsNavigating,
281
+ chartRef,
282
+ totalPoints,
283
+ onActivate: activateSelectedBar,
284
+ visibleSeriesKey,
285
+ } );
286
+
287
+ const handlePointerMove = useCallback(
288
+ ( { key, index }: { key: string; index: number } ) => {
289
+ const seriesIndex = primaryKeys.indexOf( key );
290
+ if ( seriesIndex >= 0 ) {
291
+ onChartPointerMove( index * primaryKeys.length + seriesIndex );
292
+ }
293
+ },
294
+ [ primaryKeys, onChartPointerMove ]
295
+ );
285
296
 
286
297
  const comparisonEntries = useMemo( () => {
287
298
  const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
@@ -745,6 +756,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
745
756
  withTooltips={ withTooltips }
746
757
  onPointerDown={ onPointerDown }
747
758
  onPointerUp={ onPointerUp }
759
+ onPointerMove={ handlePointerMove }
748
760
  />
749
761
  ) }
750
762
 
@@ -45,6 +45,7 @@ export function nearestBandIndex< Datum >(
45
45
  * @param root0.withTooltips - Whether pointer events update the tooltip.
46
46
  * @param root0.onPointerDown - Receives the corrected pointer-down datum.
47
47
  * @param root0.onPointerUp - Receives the corrected pointer-up datum.
48
+ * @param root0.onPointerMove - Receives the corrected datum under a moving pointer.
48
49
  * @return No visual content.
49
50
  */
50
51
  export function BandTooltip( {
@@ -53,10 +54,12 @@ export function BandTooltip( {
53
54
  withTooltips,
54
55
  onPointerDown,
55
56
  onPointerUp,
56
- }: { keys: string[]; groupPadding: number } & Pick<
57
- BarChartProps,
58
- 'withTooltips' | 'onPointerDown' | 'onPointerUp'
59
- > ) {
57
+ onPointerMove,
58
+ }: {
59
+ keys: string[];
60
+ groupPadding: number;
61
+ onPointerMove?: ( selection: PointerSelection ) => void;
62
+ } & Pick< BarChartProps, 'withTooltips' | 'onPointerDown' | 'onPointerUp' > ) {
60
63
  const { xScale, yScale, dataRegistry, horizontal } = useContext( DataContext );
61
64
  const { showTooltip } = useContext( TooltipContext );
62
65
  const scale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
@@ -110,13 +113,6 @@ export function BandTooltip( {
110
113
  const handlePointer = useCallback< PointerHandler >(
111
114
  params => {
112
115
  const selections = getSelections( params );
113
- if ( withTooltips && params?.event.type !== 'pointerup' ) {
114
- selections.forEach( showTooltip );
115
- }
116
- const callback = params?.event.type === 'pointerdown' ? onPointerDown : onPointerUp;
117
- if ( params?.event.type === 'pointermove' || ! callback ) {
118
- return;
119
- }
120
116
  const nearest = selections.reduce(
121
117
  ( best, selection ) => {
122
118
  const distance = ( value: typeof selection ) =>
@@ -125,11 +121,21 @@ export function BandTooltip( {
125
121
  },
126
122
  undefined as ( typeof selections )[ number ] | undefined
127
123
  );
124
+ if ( params?.event.type === 'pointermove' && nearest ) {
125
+ onPointerMove?.( nearest );
126
+ }
127
+ if ( withTooltips && params?.event.type !== 'pointerup' ) {
128
+ selections.forEach( showTooltip );
129
+ }
130
+ const callback = params?.event.type === 'pointerdown' ? onPointerDown : onPointerUp;
131
+ if ( params?.event.type === 'pointermove' || ! callback ) {
132
+ return;
133
+ }
128
134
  if ( nearest ) {
129
135
  callback( nearest );
130
136
  }
131
137
  },
132
- [ getSelections, withTooltips, showTooltip, onPointerDown, onPointerUp ]
138
+ [ getSelections, withTooltips, showTooltip, onPointerDown, onPointerUp, onPointerMove ]
133
139
  );
134
140
  useEventEmitter( 'pointermove', withTooltips ? handlePointer : undefined );
135
141
  useEventEmitter( 'pointerdown', handlePointer );
@@ -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.domain - Country code whose viewpoint sets the disputed borders
133
134
  * @param props.onError - Optional callback for Google Charts errors
134
135
  * @param props.className - Additional CSS class name for the chart container
135
136
  * @param props.renderPlaceholder - Optional render function for the loading placeholder
@@ -142,6 +143,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
142
143
  height,
143
144
  region = 'world',
144
145
  resolution = 'countries',
146
+ domain,
145
147
  onError,
146
148
  renderPlaceholder,
147
149
  } ) => {
@@ -262,6 +264,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
262
264
  () => ( {
263
265
  ...( region !== 'world' && { region } ),
264
266
  ...( resolution !== 'countries' && { resolution } ),
267
+ ...( domain && { domain } ),
265
268
  colorAxis: { colors: [ lightColorHex, fullColorHex ] },
266
269
  backgroundColor: backgroundColorHex,
267
270
  datalessRegionColor: defaultFillColorHex,
@@ -273,6 +276,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
273
276
  [
274
277
  region,
275
278
  resolution,
279
+ domain,
276
280
  lightColorHex,
277
281
  fullColorHex,
278
282
  backgroundColorHex,
@@ -216,6 +216,14 @@ describe( 'GeoChart', () => {
216
216
  expect( options.region ).toBeUndefined();
217
217
  } );
218
218
 
219
+ test( 'passes the domain so disputed borders follow that country', () => {
220
+ renderWithTheme( { domain: 'IN' } );
221
+
222
+ const options = JSON.parse( screen.getByTestId( 'chart-options' ).textContent || '{}' );
223
+
224
+ expect( options.domain ).toBe( 'IN' );
225
+ } );
226
+
219
227
  test( 'does not include resolution in options when set to countries (default)', () => {
220
228
  renderWithTheme();
221
229
 
@@ -48,6 +48,11 @@ export interface GeoChartProps extends Pick<
48
48
  * @default 'countries'
49
49
  */
50
50
  resolution?: GeoResolution;
51
+ /**
52
+ * ISO 3166-1 alpha-2 code of the country whose viewpoint sets the disputed borders
53
+ * (e.g., 'IN' draws Kashmir as India shows it). Google's default borders apply when omitted.
54
+ */
55
+ domain?: string;
51
56
  /**
52
57
  * Callback fired when Google Charts emits a chart error.
53
58
  */
@@ -494,7 +494,7 @@ describe( 'HeatmapChart keyboard tooltip', () => {
494
494
  } );
495
495
 
496
496
  describe( 'HeatmapChart tooltip position', () => {
497
- // Restore only this spy: `restoreAllMocks` would also unspy jest-console, and
497
+ // Restore only this spy: `restoreAllMocks` would also unspy the console guard, and
498
498
  // every later `toHaveWarned` in the file would find no spy.
499
499
  let rects: jest.SpyInstance;
500
500
  afterEach( () => {
@@ -18,5 +18,9 @@
18
18
 
19
19
  &__label-text {
20
20
  fill: var(--a8c-charts-color-label-inverse, #f0f0f0);
21
+
22
+ &--on-light {
23
+ fill: var(--a8c-charts-color-label, #1e1e1e);
24
+ }
21
25
  }
22
26
  }
@@ -1,8 +1,9 @@
1
1
  import { Group } from '@visx/group';
2
2
  import { Pie } from '@visx/shape';
3
3
  import { useTooltip } from '@visx/tooltip';
4
+ import { color as d3Color } from '@visx/vendor/d3-color';
4
5
  import clsx from 'clsx';
5
- import { useCallback, useContext, useMemo, useRef } from 'react';
6
+ import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
6
7
  import { Legend, useChartLegendItems } from '../../components/legend';
7
8
  import { BaseTooltip } from '../../components/tooltip';
8
9
  import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
@@ -19,8 +20,16 @@ import {
19
20
  useGlobalChartsTheme,
20
21
  GlobalChartsContext,
21
22
  } from '../../providers';
23
+ import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
24
+ import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
22
25
  import { useStandaloneScopeClass } from '../../providers/chart-scope';
23
- import { attachSubComponents, resolveFontSize } from '../../utils';
26
+ import {
27
+ attachSubComponents,
28
+ createCssVariableResolver,
29
+ isValidHexColor,
30
+ normalizeColorToHex,
31
+ resolveFontSize,
32
+ } from '../../utils';
24
33
  import { getStringWidth } from '../../visx/text';
25
34
  import { Center } from '../private/center';
26
35
  import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
@@ -161,6 +170,50 @@ const validateData = ( data: DataPointPercentage[] ) => {
161
170
  return { isValid: true, message: '' };
162
171
  };
163
172
 
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
+ );
215
+ };
216
+
164
217
  /**
165
218
  * Renders a pie or donut chart using the provided data.
166
219
  *
@@ -202,6 +255,11 @@ const PieChartInternal = ( {
202
255
  // The tooltip renders inside this element, so pointer coordinates are taken relative to it.
203
256
  const containerRef = useRef< HTMLDivElement >( null );
204
257
 
258
+ // The element the chart's own `className` lands on, so an override set there reaches this
259
+ // decision the same way it reaches CSS.
260
+ const rootRef = useRef< HTMLDivElement >( null );
261
+ const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
262
+
205
263
  const onMouseLeave = useCallback( () => {
206
264
  if ( ! withTooltips ) {
207
265
  return;
@@ -209,7 +267,41 @@ const PieChartInternal = ( {
209
267
  hideTooltip();
210
268
  }, [ withTooltips, hideTooltip ] );
211
269
 
212
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
270
+ const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
271
+ const { isValid, message } = validateData( data );
272
+
273
+ // Skipped when labels are off, or before the chart's own root element mounts (the invalid-data
274
+ // branch renders a plain div, so `rootRef` is not yet attached).
275
+ useLayoutEffect( () => {
276
+ if ( ! showLabels || ! rootRef.current ) {
277
+ return;
278
+ }
279
+
280
+ const resolve = createCssVariableResolver( rootRef.current );
281
+ 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
+ // A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
288
+ 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
+ );
304
+ }, [ showLabels, className, isColorPaletteResolved, isValid ] );
213
305
 
214
306
  // Calculate percentages from values (single source of truth)
215
307
  const dataWithPercentages = useDataWithPercentages( data );
@@ -230,8 +322,6 @@ const PieChartInternal = ( {
230
322
  // Create legend items using legendData (has recalculated percentages for visible items)
231
323
  const legendItems = useChartLegendItems( legendData, legendOptions );
232
324
 
233
- const { isValid, message } = validateData( data );
234
-
235
325
  // Process children to extract compound components
236
326
  const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
237
327
  children,
@@ -309,6 +399,7 @@ const PieChartInternal = ( {
309
399
  legendElement={ legendElement }
310
400
  legendChildren={ legendChildren }
311
401
  gap={ gap }
402
+ rootRef={ rootRef }
312
403
  className={ clsx(
313
404
  'pie-chart',
314
405
  styles[ 'pie-chart' ],
@@ -405,11 +496,12 @@ const PieChartInternal = ( {
405
496
  } );
406
497
  };
407
498
 
499
+ const fill = accessors.fill( arc.data );
408
500
  const pathProps: SVGProps< SVGPathElement > & {
409
501
  'data-testid'?: string;
410
502
  } = {
411
503
  d: pie.path( arc ) || '',
412
- fill: accessors.fill( arc.data ),
504
+ fill,
413
505
  'data-testid': 'pie-segment',
414
506
  };
415
507
 
@@ -446,7 +538,11 @@ const PieChartInternal = ( {
446
538
  pointerEvents="none"
447
539
  />
448
540
  <text
449
- className={ styles[ 'pie-chart__label-text' ] }
541
+ className={ clsx( styles[ 'pie-chart__label-text' ], {
542
+ [ styles[ 'pie-chart__label-text--on-light' ] ]:
543
+ labelNeedsDarkText( fill, labelPointers ),
544
+ } ) }
545
+ data-testid="pie-label"
450
546
  x={ centroidX }
451
547
  y={ centroidY }
452
548
  dy=".33em"
@@ -149,6 +149,155 @@ describe( 'PieChart', () => {
149
149
  } );
150
150
  } );
151
151
 
152
+ describe( 'Label Text Contrast', () => {
153
+ const contrastData = [
154
+ { label: 'Light', value: 60, color: '#f5d76e' },
155
+ { label: 'Dark', value: 40, color: '#1e3a8a' },
156
+ ];
157
+
158
+ let injectedStyle: HTMLStyleElement | null = null;
159
+
160
+ afterEach( () => {
161
+ if ( injectedStyle ) {
162
+ document.head.removeChild( injectedStyle );
163
+ injectedStyle = null;
164
+ }
165
+ } );
166
+
167
+ test( 'uses dark label text on a light slice', () => {
168
+ renderWithTheme( { data: contrastData } );
169
+
170
+ const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
171
+ expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
172
+ expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
173
+ } );
174
+
175
+ test( "keeps the inverse label color when a label plate is set on the chart's own class", () => {
176
+ injectedStyle = document.createElement( 'style' );
177
+ injectedStyle.textContent =
178
+ '.plated-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
179
+ document.head.appendChild( injectedStyle );
180
+
181
+ renderWithTheme( { data: contrastData, className: 'plated-pie' } );
182
+
183
+ screen
184
+ .getAllByTestId( 'pie-label' )
185
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
186
+ } );
187
+
188
+ test( "compares against a label-inverse override set on the chart's own class", () => {
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; }';
192
+ document.head.appendChild( injectedStyle );
193
+
194
+ renderWithTheme( {
195
+ data: [ { label: 'Dark', value: 100, color: '#1e3a8a' } ],
196
+ className: 'overridden-pie',
197
+ } );
198
+
199
+ const [ label ] = screen.getAllByTestId( 'pie-label' );
200
+ expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
201
+ } );
202
+
203
+ test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
204
+ injectedStyle = document.createElement( 'style' );
205
+ injectedStyle.textContent =
206
+ '.modern-plate-pie { --a8c-charts-color-label-background: rgb(0 0 0 / 50%); }';
207
+ document.head.appendChild( injectedStyle );
208
+
209
+ renderWithTheme( { data: contrastData, className: 'modern-plate-pie' } );
210
+
211
+ screen
212
+ .getAllByTestId( 'pie-label' )
213
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
214
+ } );
215
+
216
+ test( 'keeps the inverse label color when the label role is see-through', () => {
217
+ injectedStyle = document.createElement( 'style' );
218
+ injectedStyle.textContent =
219
+ '.clear-label-pie { --a8c-charts-color-label: rgba(0, 0, 0, 0); }';
220
+ document.head.appendChild( injectedStyle );
221
+
222
+ renderWithTheme( { data: contrastData, className: 'clear-label-pie' } );
223
+
224
+ screen
225
+ .getAllByTestId( 'pie-label' )
226
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
227
+ } );
228
+
229
+ test( 'keeps one label color on every slice when both label roles are the same', () => {
230
+ injectedStyle = document.createElement( 'style' );
231
+ injectedStyle.textContent =
232
+ '.single-label-pie { --a8c-charts-color-label: #ffffff; --a8c-charts-color-label-inverse: #ffffff; }';
233
+ document.head.appendChild( injectedStyle );
234
+
235
+ renderWithTheme( { data: contrastData, className: 'single-label-pie' } );
236
+
237
+ screen
238
+ .getAllByTestId( 'pie-label' )
239
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
240
+ } );
241
+
242
+ test( 'uses the label role on every slice when the inverse role is see-through', () => {
243
+ injectedStyle = document.createElement( 'style' );
244
+ injectedStyle.textContent =
245
+ '.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
246
+ document.head.appendChild( injectedStyle );
247
+
248
+ renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
249
+
250
+ screen
251
+ .getAllByTestId( 'pie-label' )
252
+ .forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
253
+ } );
254
+
255
+ test( 'keeps the inverse label color when the fill cannot be resolved', () => {
256
+ renderWithTheme( {
257
+ data: [ { label: 'Unresolved', value: 100, color: 'var(--not-set)' } ],
258
+ } );
259
+
260
+ screen
261
+ .getAllByTestId( 'pie-label' )
262
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
263
+ } );
264
+
265
+ describe( 'after an invalid-to-valid data transition', () => {
266
+ test( "keeps the inverse label color when a plate is set on the chart's own class", () => {
267
+ injectedStyle = document.createElement( 'style' );
268
+ injectedStyle.textContent =
269
+ '.plated-late-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
270
+ document.head.appendChild( injectedStyle );
271
+
272
+ const { rerender } = renderWithTheme( { data: [], className: 'plated-late-pie' } );
273
+
274
+ rerender(
275
+ <GlobalChartsProvider>
276
+ <PieChart { ...defaultProps } data={ contrastData } className="plated-late-pie" />
277
+ </GlobalChartsProvider>
278
+ );
279
+
280
+ screen
281
+ .getAllByTestId( 'pie-label' )
282
+ .forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
283
+ } );
284
+
285
+ test( 'resolves the label pointers once the chart mounts its own root element', () => {
286
+ const { rerender } = renderWithTheme( { data: [] } );
287
+
288
+ rerender(
289
+ <GlobalChartsProvider>
290
+ <PieChart { ...defaultProps } data={ contrastData } />
291
+ </GlobalChartsProvider>
292
+ );
293
+
294
+ const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
295
+ expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
296
+ expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
297
+ } );
298
+ } );
299
+ } );
300
+
152
301
  describe( 'Legend Value Display', () => {
153
302
  // Values that give clean percentages: 60/100=60%, 23/100=23%, 17/100=17%
154
303
  const testData = [
@@ -5,7 +5,7 @@ import { renderLegendSlot } from '../chart-composition';
5
5
  import styles from './chart-layout.module.scss';
6
6
  import type { GapSize, LegendPosition } from '../../../types';
7
7
  import type { LegendChild } from '../chart-composition/use-chart-children';
8
- import type { CSSProperties, ReactNode } from 'react';
8
+ import type { CSSProperties, ReactNode, Ref } from 'react';
9
9
 
10
10
  /**
11
11
  * Measurements provided to the render prop when ChartLayout handles resize listening.
@@ -36,6 +36,8 @@ export interface ChartLayoutProps {
36
36
  gap?: GapSize;
37
37
  /** Additional class names */
38
38
  className?: string;
39
+ /** Ref to the root element, the one `className` lands on */
40
+ rootRef?: Ref< HTMLDivElement >;
39
41
  /** Inline styles (width, height, etc.) */
40
42
  style?: CSSProperties;
41
43
  /** Test ID for the container */
@@ -53,6 +55,7 @@ export const ChartLayout = ( {
53
55
  onContentHeightChange,
54
56
  gap,
55
57
  className,
58
+ rootRef,
56
59
  style,
57
60
  'data-testid': dataTestId,
58
61
  'data-chart-id': dataChartId,
@@ -78,6 +81,7 @@ export const ChartLayout = ( {
78
81
 
79
82
  return (
80
83
  <Stack
84
+ ref={ rootRef }
81
85
  direction="column"
82
86
  gap={ gap }
83
87
  className={ className }
@@ -341,10 +341,41 @@ export const useKeyboardNavigation = ( {
341
341
  [ isNavigating, selectedIndex, setSelectedIndex ]
342
342
  );
343
343
 
344
+ // The point the pointer took over a keyboard selection at, where the next arrow key continues from.
345
+ const pointerIndex = useRef< number | undefined >( undefined );
346
+
347
+ // The pointer is the latest input, so it ends a keyboard selection instead of competing with it.
348
+ const onChartPointerMove = useCallback(
349
+ ( index: number ) => {
350
+ if ( selectedIndex === undefined ) {
351
+ if ( pointerIndex.current !== undefined ) {
352
+ pointerIndex.current = index;
353
+ }
354
+ return;
355
+ }
356
+ const root = getChartRoot();
357
+ const activeElement = root?.ownerDocument.activeElement;
358
+ // Focus elsewhere on the page stays there; a hover must not pull it back into the chart.
359
+ if ( activeElement && root.contains( activeElement ) ) {
360
+ pointerIndex.current = index;
361
+ focusWithoutScrollIfNeeded( root );
362
+ }
363
+ setSelectedIndex( undefined );
364
+ setIsNavigating( false );
365
+ },
366
+ [ selectedIndex, setSelectedIndex, setIsNavigating, getChartRoot, focusWithoutScrollIfNeeded ]
367
+ );
368
+
344
369
  // On each blur of chart, keyboard navigation should restart from first tooltip
345
- const onChartBlur = useCallback( () => {
346
- setIsNavigating( false );
347
- }, [ setIsNavigating ] );
370
+ const onChartBlur = useCallback(
371
+ ( event: React.FocusEvent< HTMLDivElement > ) => {
372
+ setIsNavigating( false );
373
+ if ( ! event.currentTarget.contains( event.relatedTarget ) ) {
374
+ pointerIndex.current = undefined;
375
+ }
376
+ },
377
+ [ setIsNavigating ]
378
+ );
348
379
 
349
380
  const onChartKeyDown = useCallback(
350
381
  ( event: React.KeyboardEvent< HTMLDivElement > ) => {
@@ -356,12 +387,16 @@ export const useKeyboardNavigation = ( {
356
387
  focusWithoutScrollIfNeeded( getChartRoot() );
357
388
  setSelectedIndex( undefined );
358
389
  setIsNavigating( false );
390
+ pointerIndex.current = undefined;
359
391
  return;
360
392
  }
361
393
 
362
394
  if ( totalPoints === 0 ) return;
363
395
 
364
- const currentSelectedIndex = selectedIndex === undefined ? -1 : selectedIndex;
396
+ const currentSelectedIndex = selectedIndex ?? pointerIndex.current ?? -1;
397
+ if ( event.key === 'ArrowRight' || event.key === 'ArrowLeft' ) {
398
+ pointerIndex.current = undefined;
399
+ }
365
400
 
366
401
  // WAI-ARIA grid: arrows stop at the first and last cell.
367
402
  if ( event.key === 'ArrowRight' ) {
@@ -393,5 +428,6 @@ export const useKeyboardNavigation = ( {
393
428
  onChartFocus,
394
429
  onChartBlur,
395
430
  onChartKeyDown,
431
+ onChartPointerMove,
396
432
  };
397
433
  };