@automattic/charts 4.4.0 → 4.6.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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +2 -2
  3. package/dist/index.cjs +524 -203
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +4 -0
  6. package/dist/index.d.cts +22 -4
  7. package/dist/index.d.ts +22 -4
  8. package/dist/index.js +524 -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 +8 -0
  14. package/src/charts/geo-chart/index.ts +7 -1
  15. package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
  16. package/src/charts/geo-chart/types.ts +17 -0
  17. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
  18. package/src/charts/pie-chart/pie-chart.module.scss +4 -0
  19. package/src/charts/pie-chart/pie-chart.tsx +103 -7
  20. package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
  21. package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
  22. package/src/components/tooltip/accessible-tooltip.tsx +40 -4
  23. package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
  24. package/src/index.ts +7 -1
  25. package/src/providers/chart-context/global-charts-provider.tsx +57 -41
  26. package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
  27. package/src/providers/chart-context/private/palette-generator.ts +305 -0
  28. package/src/providers/chart-context/private/perceptual-color.ts +219 -0
  29. package/src/providers/chart-context/private/series-palette.ts +1 -1
  30. package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
  31. package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
  32. package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
  33. package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
  34. package/src/test-utils/zoned-parsing-suite.ts +1 -1
  35. package/src/utils/color-utils.ts +5 -3
  36. package/src/utils/test/date-formatting.test.ts +1 -1
  37. 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.6.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,8 @@ 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')
134
+ * @param props.domain - Country code whose viewpoint sets the disputed borders
133
135
  * @param props.onError - Optional callback for Google Charts errors
134
136
  * @param props.className - Additional CSS class name for the chart container
135
137
  * @param props.renderPlaceholder - Optional render function for the loading placeholder
@@ -142,6 +144,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
142
144
  height,
143
145
  region = 'world',
144
146
  resolution = 'countries',
147
+ displayMode = 'regions',
148
+ domain,
145
149
  onError,
146
150
  renderPlaceholder,
147
151
  } ) => {
@@ -262,6 +266,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
262
266
  () => ( {
263
267
  ...( region !== 'world' && { region } ),
264
268
  ...( resolution !== 'countries' && { resolution } ),
269
+ ...( displayMode !== 'regions' && { displayMode } ),
270
+ ...( domain && { domain } ),
265
271
  colorAxis: { colors: [ lightColorHex, fullColorHex ] },
266
272
  backgroundColor: backgroundColorHex,
267
273
  datalessRegionColor: defaultFillColorHex,
@@ -273,6 +279,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
273
279
  [
274
280
  region,
275
281
  resolution,
282
+ displayMode,
283
+ domain,
276
284
  lightColorHex,
277
285
  fullColorHex,
278
286
  backgroundColorHex,
@@ -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';
@@ -216,6 +216,22 @@ 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
+
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
+
219
235
  test( 'does not include resolution in options when set to countries (default)', () => {
220
236
  renderWithTheme();
221
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,16 @@ 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;
63
+ /**
64
+ * ISO 3166-1 alpha-2 code of the country whose viewpoint sets the disputed borders
65
+ * (e.g., 'IN' draws Kashmir as India shows it). Google's default borders apply when omitted.
66
+ */
67
+ domain?: string;
51
68
  /**
52
69
  * Callback fired when Google Charts emits a chart error.
53
70
  */
@@ -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 }