@automattic/charts 2.0.0 → 3.0.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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/TOKENS.md +107 -48
  3. package/dist/index.cjs +584 -209
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +97 -43
  6. package/dist/index.d.cts +46 -41
  7. package/dist/index.d.ts +46 -41
  8. package/dist/index.js +582 -210
  9. package/dist/index.js.map +1 -1
  10. package/package.json +2 -2
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +3 -5
  13. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  14. package/src/charts/bar-chart/bar-chart.tsx +24 -15
  15. package/src/charts/bar-chart/private/use-bar-chart-options.ts +197 -30
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +685 -1
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
  18. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  19. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  20. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  21. package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
  22. package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
  23. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  24. package/src/charts/leaderboard-chart/types.ts +0 -2
  25. package/src/charts/line-chart/line-chart.module.scss +10 -5
  26. package/src/charts/line-chart/line-chart.tsx +3 -5
  27. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  28. package/src/charts/pie-chart/pie-chart.tsx +4 -1
  29. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
  30. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  31. package/src/charts/private/test/time-axis.test.ts +247 -2
  32. package/src/charts/private/time-axis.ts +252 -63
  33. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  34. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  35. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  36. package/src/components/legend/private/base-legend.tsx +3 -1
  37. package/src/components/legend/test/legend.test.tsx +2 -1
  38. package/src/components/tooltip/accessible-tooltip.tsx +4 -2
  39. package/src/components/tooltip/base-tooltip.tsx +8 -1
  40. package/src/components/trend-indicator/test/trend-indicator.test.tsx +22 -1
  41. package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
  42. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  43. package/src/hooks/test/use-chart-margin.test.tsx +60 -0
  44. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  45. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +89 -0
  46. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  47. package/src/hooks/use-chart-margin.tsx +3 -4
  48. package/src/hooks/use-xychart-theme.ts +19 -11
  49. package/src/index.ts +9 -2
  50. package/src/providers/chart-context/global-charts-provider.tsx +35 -12
  51. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  52. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  53. package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
  54. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  55. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  56. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  57. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  58. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  59. package/src/providers/chart-context/themes.ts +18 -27
  60. package/src/providers/chart-context/types.ts +0 -3
  61. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  62. package/src/providers/chart-scope/index.ts +3 -0
  63. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  64. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  65. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  66. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  67. package/src/providers/index.ts +1 -0
  68. package/src/styles/chart-scope-class.ts +6 -0
  69. package/src/styles/chart-scope.scss +83 -0
  70. package/src/styles/test/chart-scope.test.ts +165 -0
  71. package/src/types.ts +6 -40
  72. package/src/utils/index.ts +1 -1
  73. package/src/utils/resolve-css-var.ts +69 -28
  74. package/src/utils/test/create-css-variable-resolver.test.ts +83 -0
  75. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  76. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  77. package/src/charts/private/grid-control/index.ts +0 -1
  78. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
@@ -184,4 +184,64 @@ describe( 'useChartMargin', () => {
184
184
  // This is larger than the 20px default bottom margin, so it should be used.
185
185
  expect( result.current.bottom ).toBe( 25 );
186
186
  } );
187
+
188
+ describe( 'horizontal y ticks', () => {
189
+ const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
190
+ ...optionsBase,
191
+ axis: {
192
+ ...optionsBase.axis,
193
+ y: { ...optionsBase.axis.y, orientation: Orientation.left, tickFormat },
194
+ },
195
+ } );
196
+
197
+ it( 'measures dated ticks by formatting the raw timestamps once', () => {
198
+ const formatHour = ( timestamp: string | number ) =>
199
+ new Date( timestamp ).toLocaleTimeString( undefined, { hour: 'numeric', hour12: true } );
200
+ const hourlyData = [
201
+ {
202
+ label: 'Series 1',
203
+ data: [
204
+ { date: new Date( 2024, 0, 1, 6 ), value: 10 },
205
+ { date: new Date( 2024, 0, 1, 7 ), value: 20 },
206
+ ],
207
+ },
208
+ ];
209
+
210
+ renderHook( () =>
211
+ useChartMargin( 300, horizontalOptions( formatHour ), hourlyData, baseTheme, true )
212
+ );
213
+
214
+ const [ ticks, measureFormatter ] = mockGetLongestTickWidth.mock.calls[ 0 ];
215
+ // Raw timestamps, so the formatter runs exactly once — formatting here
216
+ // and again while measuring would date-parse "6 AM" to "Invalid Date".
217
+ expect( ticks ).toEqual( [
218
+ new Date( 2024, 0, 1, 6 ).getTime(),
219
+ new Date( 2024, 0, 1, 7 ).getTime(),
220
+ ] );
221
+ expect( measureFormatter ).toBe( formatHour );
222
+ } );
223
+
224
+ it( "measures labelled ticks through the caller's formatter", () => {
225
+ const withSuffix = ( label: string | number ) => `${ label } (total)`;
226
+ const labelledData = [
227
+ {
228
+ label: 'Series 1',
229
+ data: [
230
+ { label: 'Mon', value: 10 },
231
+ { label: 'Tuesday', value: 20 },
232
+ ],
233
+ },
234
+ ];
235
+
236
+ renderHook( () =>
237
+ useChartMargin( 300, horizontalOptions( withSuffix ), labelledData, baseTheme, true )
238
+ );
239
+
240
+ const [ ticks, measureFormatter ] = mockGetLongestTickWidth.mock.calls[ 0 ];
241
+ // A formatter that lengthens labels has to reach the measurement, or the
242
+ // margin under-measures and the widest label clips at the SVG edge.
243
+ expect( ticks ).toEqual( [ 'Mon', 'Tuesday' ] );
244
+ expect( measureFormatter ).toBe( withSuffix );
245
+ } );
246
+ } );
187
247
  } );
@@ -0,0 +1,28 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { GlobalChartsProvider } from '../../providers/chart-context/global-charts-provider';
3
+ import { ChartScopeContext } from '../../providers/chart-scope';
4
+ import { useXYChartTheme } from '../use-xychart-theme';
5
+ import type { ReactNode } from 'react';
6
+
7
+ // A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches visx (SVG presentation attributes) through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-grid` override set inside the provider tree had no effect on the JS-resolved value — the two paths could disagree. After the fix, `providerTheme` carries the catalog pointer for an overridden role, so both paths resolve through the same CSS cascade and the nearer element wins.
8
+ describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
9
+ it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
10
+ const nestedOverride = document.createElement( 'div' );
11
+ nestedOverride.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( nestedOverride );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <GlobalChartsProvider theme={ { gridStyles: { stroke: 'rgb(255,0,0)' } } }>
16
+ <ChartScopeContext.Provider value={ nestedOverride }>
17
+ { children }
18
+ </ChartScopeContext.Provider>
19
+ </GlobalChartsProvider>
20
+ );
21
+
22
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
23
+
24
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
25
+
26
+ document.body.removeChild( nestedOverride );
27
+ } );
28
+ } );
@@ -0,0 +1,89 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
3
+ import { useXYChartTheme } from '../use-xychart-theme';
4
+ import type { SeriesData } from '../../types';
5
+ import type { ReactNode } from 'react';
6
+
7
+ // The theme resolves five catalog roles — background, grid, axis, tick and axis label. Each `getComputedStyle` call can force the browser to flush pending style, and this memo re-runs only when the scope element attaches or a series colour changes, so a dashboard mounting N charts pays the count below N times over.
8
+ const STABLE_DATA: SeriesData[] = [];
9
+
10
+ describe( 'useXYChartTheme style reads', () => {
11
+ let scope: HTMLElement;
12
+ let getComputedStyleSpy: jest.SpyInstance;
13
+
14
+ beforeEach( () => {
15
+ scope = document.createElement( 'div' );
16
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
17
+ document.body.appendChild( scope );
18
+ getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
19
+ } );
20
+
21
+ afterEach( () => {
22
+ getComputedStyleSpy.mockRestore();
23
+ scope.remove();
24
+ } );
25
+
26
+ const renderWithScope = ( data: SeriesData[] = STABLE_DATA ) =>
27
+ renderHook( ( { series }: { series: SeriesData[] } ) => useXYChartTheme( series ), {
28
+ initialProps: { series: data },
29
+ wrapper: ( { children }: { children: ReactNode } ) => (
30
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
31
+ ),
32
+ } );
33
+
34
+ it( 'reads every role from one computed-style snapshot, not one per role', () => {
35
+ scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
36
+ scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
37
+
38
+ const { result } = renderWithScope();
39
+
40
+ expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
41
+ expect( result.current.backgroundColor ).toBe( '#111111' );
42
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
43
+ expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe( '#222222' );
44
+ } );
45
+
46
+ it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
47
+ const { rerender } = renderWithScope();
48
+
49
+ getComputedStyleSpy.mockClear();
50
+ rerender( { series: STABLE_DATA } );
51
+
52
+ expect( getComputedStyleSpy ).not.toHaveBeenCalled();
53
+ } );
54
+
55
+ // A caller passing an inline array literal — `<LineChart data={ [ … ] } />` — hands the hook a new array identity on every render. The memo keys on the series strokes instead, so the theme is not rebuilt and no style is read. Keyed on `data` this cost one query per render, and before the roles shared a snapshot it cost five.
56
+ it( 'does not rebuild when the caller passes a fresh data array of the same strokes', () => {
57
+ const { rerender } = renderWithScope( [] );
58
+
59
+ getComputedStyleSpy.mockClear();
60
+ rerender( { series: [] } );
61
+
62
+ expect( getComputedStyleSpy ).not.toHaveBeenCalled();
63
+ } );
64
+
65
+ it( 'rebuilds when a series stroke actually changes', () => {
66
+ const withStroke = ( stroke: string ) =>
67
+ [ { label: 'A', options: { stroke }, data: [] } ] as unknown as SeriesData[];
68
+ const { result, rerender } = renderWithScope( withStroke( 'rgba(0, 0, 0, 0.5)' ) );
69
+
70
+ getComputedStyleSpy.mockClear();
71
+ rerender( { series: withStroke( 'rebeccapurple' ) } );
72
+
73
+ expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
74
+ expect( result.current.colors[ 0 ] ).toBe( 'rebeccapurple' );
75
+ } );
76
+
77
+ // The memo key serialises the strokes rather than joining them, so a colour containing a comma or a space survives the round-trip intact.
78
+ it( 'keeps a stroke that contains separators intact', () => {
79
+ const { result } = renderWithScope( [
80
+ { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
81
+ { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
82
+ ] as unknown as SeriesData[] );
83
+
84
+ expect( result.current.colors.slice( 0, 2 ) ).toEqual( [
85
+ 'rgba(0, 0, 0, 0.5)',
86
+ 'var(--brand, #fff)',
87
+ ] );
88
+ } );
89
+ } );
@@ -0,0 +1,25 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { ChartScopeContext } from '../../providers/chart-scope';
3
+ import { useXYChartTheme } from '../use-xychart-theme';
4
+ import type { ReactNode } from 'react';
5
+
6
+ describe( 'useXYChartTheme', () => {
7
+ it( 'resolves grid colour against the scope element, not the document root', () => {
8
+ document.documentElement.style.setProperty( '--a8c-charts-color-grid', '#ff0000' );
9
+
10
+ const scope = document.createElement( 'div' );
11
+ scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
12
+ document.body.appendChild( scope );
13
+
14
+ const wrapper = ( { children }: { children: ReactNode } ) => (
15
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
16
+ );
17
+
18
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
19
+
20
+ expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
21
+
22
+ document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
23
+ document.body.removeChild( scope );
24
+ } );
25
+ } );
@@ -75,10 +75,9 @@ export const useChartMargin = (
75
75
  const allDataPoints = data.flatMap( series => series.data as DataPointDate[] );
76
76
 
77
77
  if ( horizontal ) {
78
- // When horizontal, y ticks renders fixed tick labels.
79
- return allDataPoints.map(
80
- d => d.label || options.axis?.y?.tickFormat( d.date.getTime(), 0, [] )
81
- );
78
+ // When horizontal, y ticks render the category values; leave them raw so
79
+ // the axis tick formatter is applied exactly once, when measuring below.
80
+ return allDataPoints.map( d => d.label || d.date?.getTime() );
82
81
  }
83
82
 
84
83
  if ( options.axis?.y?.tickValues?.length ) {
@@ -1,22 +1,30 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
- import { resolveCssVariable } from '../utils';
4
+ import { useChartScopeElement } from '../providers/chart-scope';
5
+ import { createCssVariableResolver } from '../utils';
5
6
  import type { SeriesData } from '../types';
6
7
 
7
- // visx applies grid, axis, and tick-label colors as SVG presentation attributes,
8
- // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values (or their
9
- // fallbacks) before handing the theme to buildChartTheme.
10
- const resolveColor = ( value?: string ): string | undefined =>
11
- value ? resolveCssVariable( value ) ?? value : value;
12
-
13
8
  export const useXYChartTheme = ( data: SeriesData[] ) => {
14
9
  const theme = useGlobalChartsTheme();
10
+ const scopeElement = useChartScopeElement();
15
11
 
16
- return useMemo( () => {
17
- const seriesColors = ( data ?? [] )
12
+ // The only thing the theme takes from `data` is the series strokes, so key the memo on those rather than on the array's identity. A caller passing an inline literal — `<LineChart data={ [ … ] } />`, which the stories and several consumers do — otherwise rebuilds the whole theme on every render. Serialised rather than joined: a stroke can be `rgba(0, 0, 0, 0.5)` or `var(--brand, #fff)`, and any separator that reads naturally inside a colour cannot round-trip.
13
+ const seriesColorKey = JSON.stringify(
14
+ ( data ?? [] )
18
15
  .map( series => series.options?.stroke )
19
- .filter( ( color ): color is string => Boolean( color ) );
16
+ .filter( ( color ): color is string => Boolean( color ) )
17
+ );
18
+
19
+ return useMemo( () => {
20
+ // visx applies grid, axis, and tick-label colours as SVG presentation attributes, where CSS var() cannot resolve. Resolve the catalog token against the chart's own scope element — never :root — so this reads any override set inside the provider tree, the same one a CSS-painted element would inherit.
21
+ //
22
+ // One resolver per theme build, so the five roles below share a single getComputedStyle call rather than taking one each: this memo re-runs only when the scope element attaches or a series colour changes, which for a dashboard of charts is the difference between two style queries per chart and ten.
23
+ const resolve = createCssVariableResolver( scopeElement );
24
+ const resolveColor = ( value?: string ): string | undefined =>
25
+ value ? resolve( value ) ?? value : value;
26
+
27
+ const seriesColors: string[] = JSON.parse( seriesColorKey );
20
28
 
21
29
  return buildChartTheme( {
22
30
  ...theme,
@@ -39,5 +47,5 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
39
47
  fill: resolveColor( theme.svgLabelSmall.fill ),
40
48
  },
41
49
  } );
42
- }, [ theme, data ] );
50
+ }, [ theme, seriesColorKey, scopeElement ] );
43
51
  };
package/src/index.ts CHANGED
@@ -32,6 +32,8 @@ export {
32
32
  useGlobalChartsTheme,
33
33
  GlobalChartsContext,
34
34
  defaultTheme,
35
+ useChartScopeElement,
36
+ useChartRegistration,
35
37
  } from './providers';
36
38
 
37
39
  // Types - explicit exports (excludes internal types like DataPointPercentageCalculated)
@@ -60,7 +62,6 @@ export type {
60
62
  ChartLegendConfig,
61
63
  SeriesChartLegendConfig,
62
64
  BaseChartProps,
63
- GridProps,
64
65
  GoogleDataTableColumn,
65
66
  GoogleDataTableColumnRoleType,
66
67
  GoogleDataTableRow,
@@ -122,6 +123,12 @@ export type { ArcData } from './charts/pie-semi-circle-chart';
122
123
  export type { SparklineProps, GradientConfig, SparklineDataPoint } from './charts/sparkline';
123
124
 
124
125
  // Utilities
125
- export { parseAsLocalDate, formatMetricValue, formatPercentage, mergeThemes } from './utils';
126
+ export {
127
+ parseAsLocalDate,
128
+ formatMetricValue,
129
+ formatPercentage,
130
+ mergeThemes,
131
+ resolveCssVariable,
132
+ } from './utils';
126
133
  export * from './utils/color-utils';
127
134
  export type { MetricValueType } from './utils';
@@ -8,6 +8,11 @@ import {
8
8
  useLayoutEffect,
9
9
  useRef,
10
10
  } from 'react';
11
+ // Side-effect import: emits the `--a8c-charts-*` catalog, scoped to the `a8c-charts-scope` class applied to this component's wrapper below. Every chart either sits under a `GlobalChartsProvider` or auto-mounts its own, so importing it here guarantees the catalog always reaches the bundle.
12
+ //
13
+ // This must be a direct import of the stylesheet, not through a re-export barrel: a barrel `.ts` file doesn't match this package's `sideEffects` glob (only `*.css`/`*.scss` do), so tsdown/Rolldown treats an unused barrel import as side-effect-free and drops it — taking the nested stylesheet import with it. The file also can't be named `*.module.scss`: it declares zero CSS-module class names (only a `:where()`-wrapped selector), and `@tsdown/css` marks a `.module.*` file's generated JS proxy `moduleSideEffects: false` (tree-shakeable unless a class name is read); a plain stylesheet gets `moduleSideEffects: "no-treeshake"` instead, so it always ships. `tools/assert-charts-scope-emitted.ts` fails `pnpm run build` if this regresses — a passing test suite alone does not catch a dropped stylesheet.
14
+ import '../../styles/chart-scope.scss';
15
+ import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
11
16
  import {
12
17
  getItemShapeStyles,
13
18
  getSeriesBarStyles,
@@ -16,11 +21,15 @@ import {
16
21
  resolveCssVariable,
17
22
  normalizeColorToHex,
18
23
  } from '../../utils';
24
+ // Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
25
+ import { ChartScopeContext } from '../chart-scope/chart-scope-context';
19
26
  import { getChartColor, type ColorCache } from './private/get-chart-color';
27
+ import { themeOverrideVars } from './private/theme-override-vars';
28
+ import { withCatalogPointers } from './private/with-catalog-pointers';
20
29
  import { defaultTheme } from './themes';
21
30
  import type { GlobalChartsContextValue, ChartRegistration } from './types';
22
31
  import type { ChartTheme, CompleteChartTheme } from '../../types';
23
- import type { FC, ReactNode } from 'react';
32
+ import type { CSSProperties, FC, ReactNode } from 'react';
24
33
 
25
34
  export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
26
35
 
@@ -38,10 +47,26 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
38
47
 
39
48
  // Ref to the wrapper element for resolving scoped CSS variables
40
49
  const wrapperRef = useRef< HTMLDivElement >( null );
50
+ const [ scopeNode, setScopeNode ] = useState< HTMLElement | null >( null );
51
+
52
+ const setWrapperNode = useCallback( ( node: HTMLDivElement | null ) => {
53
+ wrapperRef.current = node;
54
+ setScopeNode( node );
55
+ }, [] );
56
+
57
+ // themeOverrideVars reads the raw `theme` prop, never `providerTheme` — feeding it the restored theme below would make an overridden role's pointer look like a self-reference and drop the var (see themeOverrideVars' own doc comment).
58
+ const { vars: overrideVars, roles: overriddenRoles } = useMemo(
59
+ () => themeOverrideVars( theme ),
60
+ [ theme ]
61
+ );
41
62
 
42
63
  const providerTheme: CompleteChartTheme = useMemo( () => {
43
- return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
44
- }, [ theme ] );
64
+ if ( ! theme ) {
65
+ return defaultTheme;
66
+ }
67
+
68
+ return withCatalogPointers( mergeThemes( defaultTheme, theme ), overriddenRoles );
69
+ }, [ theme, overriddenRoles ] );
45
70
 
46
71
  // Cache expensive color computations that only change when theme colors change
47
72
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
@@ -186,11 +211,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
186
211
  [ colorCache, groupToColorMap ]
187
212
  );
188
213
 
189
- const resolveThemeColor = useCallback< GlobalChartsContextValue[ 'resolveThemeColor' ] >(
190
- value => ( value ? normalizeColorToHex( value, wrapperRef.current, resolveCssVariable ) : '' ),
191
- []
192
- );
193
-
194
214
  const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >(
195
215
  ( { data, index, overrideColor, legendShape } ) => {
196
216
  const isSeriesData = data && typeof data === 'object' && 'data' in data && 'options' in data;
@@ -269,7 +289,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
269
289
  getChartData,
270
290
  theme: providerTheme,
271
291
  getElementStyles,
272
- resolveThemeColor,
273
292
  toggleSeriesVisibility,
274
293
  isSeriesVisible,
275
294
  getHiddenSeries,
@@ -282,7 +301,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
282
301
  getChartData,
283
302
  providerTheme,
284
303
  getElementStyles,
285
- resolveThemeColor,
286
304
  toggleSeriesVisibility,
287
305
  isSeriesVisible,
288
306
  getHiddenSeries,
@@ -292,8 +310,13 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
292
310
 
293
311
  return (
294
312
  <GlobalChartsContext.Provider value={ value }>
295
- <div ref={ wrapperRef } style={ { display: 'contents' } }>
296
- { children }
313
+ <div
314
+ ref={ setWrapperNode }
315
+ className={ CHART_SCOPE_CLASS }
316
+ data-testid="charts-scope"
317
+ style={ { display: 'contents', ...overrideVars } as CSSProperties }
318
+ >
319
+ <ChartScopeContext.Provider value={ scopeNode }>{ children }</ChartScopeContext.Provider>
297
320
  </div>
298
321
  </GlobalChartsContext.Provider>
299
322
  );
@@ -0,0 +1,87 @@
1
+ import type { ChartTheme } from '../../../types';
2
+
3
+ // The consumer theme fields that correspond to an emitted catalog role, and the role each one overrides. Each role must be read by exactly the elements its field controlled before the role existed: `svgLabelSmall.fill` maps to the narrow `--a8c-charts-color-label-axis` rather than `--a8c-charts-color-label`, because the broad role is also read by legend labels, heatmap cell values, funnel labels and the line-chart tooltip, which that field never moved.
4
+ const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
5
+ [
6
+ [ '--a8c-charts-color-background', theme => theme.backgroundColor ],
7
+ [ '--a8c-charts-color-grid', theme => theme.gridStyles?.stroke ],
8
+ [ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
9
+ [ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
10
+ [ '--a8c-charts-color-label-axis', theme => theme.svgLabelSmall?.fill ],
11
+ ];
12
+
13
+ /**
14
+ * The variable a `theme` prop override is published as, one layer outside the role itself.
15
+ *
16
+ * `chart-scope.scss` declares each of these roles as `var(<role>-theme, <catalog default>)`, so an override that is invalid at computed-value time — a fallback-less `var()` naming a token the host never set — only invalidates this variable, and the role still resolves its mapped `--wpds-*` token. Published as the role directly, such a value took the role and every bare `var(<role>)` read site down with it.
17
+ *
18
+ * @param role - The catalog role being overridden.
19
+ * @return The custom property carrying the consumer's value.
20
+ */
21
+ export const themeLayerVar = ( role: string ): string => `${ role }-theme`;
22
+
23
+ /** Every role whose catalog entry must read a theme layer. `src/styles/test/chart-scope.test.ts` checks the stylesheet against this. */
24
+ export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ role ] ) => role );
25
+
26
+ /**
27
+ * Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
28
+ *
29
+ * The dependency runs `<role>: var(<role>-theme, …)`, so a value naming `<role>` closes a cycle through the catalog entry. CSS marks every custom property in a cycle invalid at computed-value time — the role's own fallback is *not* used, so the token resolves to nothing and every chart loses that colour. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
30
+ *
31
+ * Three things this has to get right, none of which a plain substring test gets:
32
+ *
33
+ * `var()` permits whitespace after the opening paren, so testing for `var(<role>` misses `var( --a8c-charts-color-grid, … )`.
34
+ *
35
+ * Several roles are prefixes of others (`--a8c-charts-color-label` of `--a8c-charts-color-label-secondary`), and every role is a prefix of its own theme layer. A prefix match would read a legitimate cross-role pointer as a self-reference and silently drop the override, so the role must be followed by something that cannot continue an identifier — `-` included. A value naming `<role>-theme` is therefore not treated as a self-reference: that cycle is confined to the theme layer, which leaves the role free to fall back to the catalog default.
36
+ *
37
+ * `resolveCssVariable` also accepts a bare custom-property name with no `var()` wrapper (`--a8c-charts-color-grid`), so that is legal `theme` input too. It forms no cycle — custom properties accept arbitrary token streams — so it survives as a literal string and drops silently at the use site instead of falling back to the catalog default.
38
+ *
39
+ * @param value - The consumer's value for the field.
40
+ * @param role - The catalog role this value would override.
41
+ * @return True when the value reads `role`, so it must not be published.
42
+ */
43
+ const readsOwnRole = ( value: string, role: string ): boolean =>
44
+ value.trim() === role ||
45
+ new RegExp( `var\\(\\s*${ role.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) }(?![\\w-])` ).test(
46
+ value
47
+ );
48
+
49
+ export type ThemeOverrides = {
50
+ /** The custom properties to write on the provider wrapper. */
51
+ vars: Record< string, string >;
52
+ /** Every mapped role the consumer set a value for, whether or not it was published. */
53
+ roles: readonly string[];
54
+ };
55
+
56
+ /**
57
+ * Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colours read one source.
58
+ *
59
+ * A value that reads the role it would override is left unpublished — that is the default theme's own pointer surviving `mergeThemes`, and publishing it would invalidate the role. The role is still reported in `roles`: `withCatalogPointers` restores its theme field to the catalog pointer either way, so CSS and visx agree on the catalog default rather than visx painting a literal CSS never sees.
60
+ *
61
+ * @param theme - The consumer's `theme` prop, before merging with the default theme.
62
+ * @return The wrapper's custom properties, and the roles the consumer overrode.
63
+ */
64
+ export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrides => {
65
+ if ( ! theme ) {
66
+ return { vars: {}, roles: [] };
67
+ }
68
+
69
+ const vars: Record< string, string > = {};
70
+ const roles: string[] = [];
71
+
72
+ for ( const [ role, read ] of ROLE_FOR_FIELD ) {
73
+ const value = read( theme );
74
+
75
+ if ( typeof value !== 'string' || value === '' ) {
76
+ continue;
77
+ }
78
+
79
+ roles.push( role );
80
+
81
+ if ( ! readsOwnRole( value, role ) ) {
82
+ vars[ themeLayerVar( role ) ] = value;
83
+ }
84
+ }
85
+
86
+ return { vars, roles };
87
+ };
@@ -0,0 +1,50 @@
1
+ import { defaultTheme } from '../themes';
2
+ import type { CompleteChartTheme } from '../../../types';
3
+
4
+ // Restores each overridden role's mapped theme field to the catalog pointer `defaultTheme` already carries for it, so the value comes from `themes.ts` itself rather than a second, hand-copied literal.
5
+ const CATALOG_RESTORE_FOR_ROLE: Record<
6
+ string,
7
+ ( theme: CompleteChartTheme ) => Partial< CompleteChartTheme >
8
+ > = {
9
+ '--a8c-charts-color-background': () => ( { backgroundColor: defaultTheme.backgroundColor } ),
10
+ '--a8c-charts-color-grid': theme => ( {
11
+ gridStyles: { ...theme.gridStyles, stroke: defaultTheme.gridStyles.stroke },
12
+ } ),
13
+ '--a8c-charts-color-axis': theme => ( {
14
+ xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: defaultTheme.xAxisLineStyles.stroke },
15
+ } ),
16
+ '--a8c-charts-color-tick': theme => ( {
17
+ xTickLineStyles: { ...theme.xTickLineStyles, stroke: defaultTheme.xTickLineStyles.stroke },
18
+ } ),
19
+ '--a8c-charts-color-label-axis': theme => ( {
20
+ svgLabelSmall: { ...theme.svgLabelSmall, fill: defaultTheme.svgLabelSmall.fill },
21
+ } ),
22
+ };
23
+
24
+ /**
25
+ * Restores the mapped theme fields of `overriddenRoles` to their catalog pointer, so the theme-layer variable `themeOverrideVars` writes on the provider wrapper is the only carrier for an overridden role — CSS and the JS bridge (`useXYChartTheme`) then resolve it through the same cascade instead of visx reading a baked literal that can disagree with a closer CSS override.
26
+ *
27
+ * This covers roles `themeOverrideVars` deliberately left unpublished as well as the ones it published. A value that reads its own role is not publishable, but leaving visx the consumer's literal is what the whole mechanism exists to prevent: `theme={ { gridStyles: { stroke: 'var(--brand, var(--a8c-charts-color-grid, red))' } } }` would have CSS paint the catalog default while visx painted `--brand`.
28
+ *
29
+ * Every field outside the five mapped roles is left exactly as `merged` provided it: those aren't published as theme-layer vars, so rewriting them would erase a consumer's override with no replacement carrier.
30
+ *
31
+ * @param merged - The consumer theme merged onto `defaultTheme` (`mergeThemes` output).
32
+ * @param overriddenRoles - The catalog roles the consumer overrode, from `themeOverrideVars`.
33
+ * @return A new theme object; `merged` is not mutated.
34
+ */
35
+ export const withCatalogPointers = (
36
+ merged: CompleteChartTheme,
37
+ overriddenRoles: readonly string[]
38
+ ): CompleteChartTheme => {
39
+ let result = merged;
40
+
41
+ for ( const role of overriddenRoles ) {
42
+ const restore = CATALOG_RESTORE_FOR_ROLE[ role ];
43
+
44
+ if ( restore ) {
45
+ result = { ...result, ...restore( result ) };
46
+ }
47
+ }
48
+
49
+ return result;
50
+ };
@@ -2536,46 +2536,6 @@ describe( 'ChartContext', () => {
2536
2536
  expect( afterRerenderColor ).toBe( '#ff0000' );
2537
2537
  } );
2538
2538
  } );
2539
-
2540
- describe( 'resolveThemeColor', () => {
2541
- let contextValue: GlobalChartsContextValue;
2542
-
2543
- const TestComponent = () => {
2544
- contextValue = useGlobalChartsContext();
2545
- return <div>Test</div>;
2546
- };
2547
-
2548
- const mountProvider = () =>
2549
- render(
2550
- <GlobalChartsProvider>
2551
- <TestComponent />
2552
- </GlobalChartsProvider>
2553
- );
2554
-
2555
- it( 'resolves a CSS variable against the provider scope, not the document root', () => {
2556
- window.getComputedStyle = jest.fn( ( element: Element ) => ( {
2557
- getPropertyValue: ( prop: string ) =>
2558
- prop === '--surface' && element !== document.documentElement ? '#1e1e1e' : '#ffffff',
2559
- } ) ) as unknown as typeof window.getComputedStyle;
2560
-
2561
- mountProvider();
2562
-
2563
- expect( contextValue.resolveThemeColor( 'var(--surface, #fff)' ) ).toBe( '#1e1e1e' );
2564
- } );
2565
-
2566
- it( 'passes a resolved color through to hex', () => {
2567
- mountProvider();
2568
-
2569
- expect( contextValue.resolveThemeColor( '#abcdef' ) ).toBe( '#abcdef' );
2570
- expect( contextValue.resolveThemeColor( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
2571
- } );
2572
-
2573
- it( 'returns an empty string for an empty value', () => {
2574
- mountProvider();
2575
-
2576
- expect( contextValue.resolveThemeColor( '' ) ).toBe( '' );
2577
- } );
2578
- } );
2579
2539
  } );
2580
2540
 
2581
2541
  describe( 'defaultTheme', () => {
@@ -0,0 +1,19 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { CHART_SCOPE_CLASS } from '../../../styles/chart-scope-class';
3
+ import { GlobalChartsProvider } from '../global-charts-provider';
4
+
5
+ describe( 'GlobalChartsProvider wrapper', () => {
6
+ it( 'carries the a8c-charts-scope class and nothing that affects layout', () => {
7
+ render(
8
+ <GlobalChartsProvider>
9
+ <div>child</div>
10
+ </GlobalChartsProvider>
11
+ );
12
+
13
+ const wrapper = screen.getByTestId( 'charts-scope' );
14
+
15
+ expect( wrapper ).toHaveClass( CHART_SCOPE_CLASS );
16
+ // The catalog needs an element to sit on; `display: contents` is what keeps that element out of the consumer's layout.
17
+ expect( wrapper.style.cssText.replace( /\s/g, '' ) ).toBe( 'display:contents;' );
18
+ } );
19
+ } );
@@ -0,0 +1,35 @@
1
+ import { defaultTheme } from '../themes';
2
+
3
+ const flatten = ( value: unknown ): string[] => {
4
+ if ( typeof value === 'string' ) {
5
+ return [ value ];
6
+ }
7
+ if ( Array.isArray( value ) ) {
8
+ return value.flatMap( flatten );
9
+ }
10
+ if ( value && typeof value === 'object' ) {
11
+ return Object.values( value ).flatMap( flatten );
12
+ }
13
+ return [];
14
+ };
15
+
16
+ describe( 'defaultTheme', () => {
17
+ const strings = flatten( defaultTheme );
18
+
19
+ it( 'holds no --wpds-* mapping — that lives only in the catalog stylesheet', () => {
20
+ expect( strings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
21
+ } );
22
+
23
+ it( 'points every themed colour at an --a8c-charts-* catalog role', () => {
24
+ const varStrings = strings.filter( value => value.startsWith( 'var(' ) );
25
+
26
+ expect( varStrings.length ).toBeGreaterThan( 0 );
27
+ expect( varStrings.every( value => value.startsWith( 'var(--a8c-charts-' ) ) ).toBe( true );
28
+ } );
29
+
30
+ it( 'gives every catalog pointer a terminal literal for the SSR and jsdom paths', () => {
31
+ const varStrings = strings.filter( value => value.startsWith( 'var(--a8c-charts-' ) );
32
+
33
+ expect( varStrings.every( value => value.includes( ',' ) ) ).toBe( true );
34
+ } );
35
+ } );