@automattic/charts 3.1.1 → 3.2.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 (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/TOKENS.md +51 -12
  3. package/dist/index.cjs +292 -200
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +7 -1
  6. package/dist/index.d.cts +223 -127
  7. package/dist/index.d.ts +223 -127
  8. package/dist/index.js +291 -199
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/bar-chart/bar-chart.tsx +36 -10
  12. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  13. package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
  14. package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
  15. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  16. package/src/charts/line-chart/line-chart.tsx +16 -1
  17. package/src/charts/line-chart/test/line-chart.test.tsx +47 -0
  18. package/src/charts/sparkline/sparkline.tsx +9 -3
  19. package/src/charts/sparkline/types.ts +1 -1
  20. package/src/components/tooltip/accessible-tooltip.tsx +32 -2
  21. package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
  22. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
  23. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  24. package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
  25. package/src/hooks/use-xychart-theme.ts +14 -20
  26. package/src/providers/chart-context/global-charts-provider.tsx +44 -33
  27. package/src/providers/chart-context/private/series-palette.ts +45 -0
  28. package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
  29. package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
  30. package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
  31. package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
  32. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
  33. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
  34. package/src/providers/chart-context/themes.ts +8 -3
  35. package/src/styles/chart-scope.scss +52 -24
  36. package/src/styles/test/chart-scope.test.ts +22 -9
  37. package/src/types.ts +148 -31
  38. package/src/utils/get-styles.ts +7 -1
@@ -4,15 +4,15 @@ import { ChartScopeContext } from '../../providers/chart-scope';
4
4
  import { useXYChartTheme } from '../use-xychart-theme';
5
5
  import type { ReactNode } from 'react';
6
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.
7
+ // A `theme` prop override reaches CSS-painted elements through the inline `--a8c-charts-*` var GlobalChartsProvider writes on its wrapper, and reaches the values our own components read through `providerTheme`. Before the fix, `providerTheme` carried the override's literal directly, so an element-level `--a8c-charts-color-background` 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
8
  describe( 'useXYChartTheme resolves a theme prop override through the CSS cascade', () => {
9
9
  it( "an element override set inside the provider tree beats the provider's theme prop literal", () => {
10
10
  const nestedOverride = document.createElement( 'div' );
11
- nestedOverride.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
11
+ nestedOverride.style.setProperty( '--a8c-charts-color-background', '#00ff00' );
12
12
  document.body.appendChild( nestedOverride );
13
13
 
14
14
  const wrapper = ( { children }: { children: ReactNode } ) => (
15
- <GlobalChartsProvider theme={ { gridStyles: { stroke: 'rgb(255,0,0)' } } }>
15
+ <GlobalChartsProvider theme={ { backgroundColor: 'rgb(255,0,0)' } }>
16
16
  <ChartScopeContext.Provider value={ nestedOverride }>
17
17
  { children }
18
18
  </ChartScopeContext.Provider>
@@ -21,7 +21,7 @@ describe( 'useXYChartTheme resolves a theme prop override through the CSS cascad
21
21
 
22
22
  const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
23
23
 
24
- expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
24
+ expect( result.current.backgroundColor ).toBe( '#00ff00' );
25
25
 
26
26
  document.body.removeChild( nestedOverride );
27
27
  } );
@@ -4,7 +4,7 @@ import { useXYChartTheme } from '../use-xychart-theme';
4
4
  import type { SeriesData } from '../../types';
5
5
  import type { ReactNode } from 'react';
6
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.
7
+ // The theme resolves the background role and the five palette slots. 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 color changes, so a dashboard mounting N charts pays the count below N times over.
8
8
  const STABLE_DATA: SeriesData[] = [];
9
9
 
10
10
  describe( 'useXYChartTheme style reads', () => {
@@ -13,7 +13,6 @@ describe( 'useXYChartTheme style reads', () => {
13
13
 
14
14
  beforeEach( () => {
15
15
  scope = document.createElement( 'div' );
16
- scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
17
16
  document.body.appendChild( scope );
18
17
  getComputedStyleSpy = jest.spyOn( window, 'getComputedStyle' );
19
18
  } );
@@ -33,14 +32,13 @@ describe( 'useXYChartTheme style reads', () => {
33
32
 
34
33
  it( 'reads every role from one computed-style snapshot, not one per role', () => {
35
34
  scope.style.setProperty( '--a8c-charts-color-background', '#111111' );
36
- scope.style.setProperty( '--a8c-charts-color-axis', '#222222' );
35
+ scope.style.setProperty( '--a8c-charts-color-series-2', '#222222' );
37
36
 
38
37
  const { result } = renderWithScope();
39
38
 
40
39
  expect( getComputedStyleSpy ).toHaveBeenCalledTimes( 1 );
41
40
  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' );
41
+ expect( result.current.colors ).toContain( '#222222' );
44
42
  } );
45
43
 
46
44
  it( 'does not re-read styles when the hook re-renders with the same inputs', () => {
@@ -74,16 +72,25 @@ describe( 'useXYChartTheme style reads', () => {
74
72
  expect( result.current.colors[ 0 ] ).toBe( 'rebeccapurple' );
75
73
  } );
76
74
 
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.
75
+ // The memo key serializes the strokes rather than joining them, so a color containing a comma or a space survives the round-trip intact. `rgba(0, 0, 0, 0.5)` is the one that proves it: it holds both separators and comes back whole.
76
+ //
77
+ // The `var()` entry resolves to its fallback rather than passing through, which is the point of resolving the palette at all — visx paints these as SVG presentation attributes, where a `var()` resolves to nothing.
78
78
  it( 'keeps a stroke that contains separators intact', () => {
79
79
  const { result } = renderWithScope( [
80
80
  { label: 'A', options: { stroke: 'rgba(0, 0, 0, 0.5)' }, data: [] },
81
81
  { label: 'B', options: { stroke: 'var(--brand, #fff)' }, data: [] },
82
82
  ] as unknown as SeriesData[] );
83
83
 
84
- expect( result.current.colors.slice( 0, 2 ) ).toEqual( [
85
- 'rgba(0, 0, 0, 0.5)',
86
- 'var(--brand, #fff)',
87
- ] );
84
+ expect( result.current.colors.slice( 0, 2 ) ).toEqual( [ 'rgba(0, 0, 0, 0.5)', '#fff' ] );
85
+ } );
86
+
87
+ // Four of the five palette slots carry no catalog default, so in jsdom they resolve to nothing. Passing those to visx unresolved would make them its default stroke for any series without an explicit one, painting nothing at all.
88
+ it( 'drops palette entries that resolve to nothing', () => {
89
+ const { result } = renderWithScope( [] );
90
+
91
+ expect( result.current.colors ).not.toEqual(
92
+ expect.arrayContaining( [ expect.stringContaining( 'var(' ) ] )
93
+ );
94
+ expect( result.current.colors.length ).toBeGreaterThan( 0 );
88
95
  } );
89
96
  } );
@@ -4,11 +4,11 @@ import { useXYChartTheme } from '../use-xychart-theme';
4
4
  import type { ReactNode } from 'react';
5
5
 
6
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' );
7
+ it( 'resolves background color against the scope element, not the document root', () => {
8
+ document.documentElement.style.setProperty( '--a8c-charts-color-background', '#ff0000' );
9
9
 
10
10
  const scope = document.createElement( 'div' );
11
- scope.style.setProperty( '--a8c-charts-color-grid', '#00ff00' );
11
+ scope.style.setProperty( '--a8c-charts-color-background', '#00ff00' );
12
12
  document.body.appendChild( scope );
13
13
 
14
14
  const wrapper = ( { children }: { children: ReactNode } ) => (
@@ -17,9 +17,54 @@ describe( 'useXYChartTheme', () => {
17
17
 
18
18
  const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
19
19
 
20
- expect( result.current.gridStyles.stroke ).toBe( '#00ff00' );
20
+ expect( result.current.backgroundColor ).toBe( '#00ff00' );
21
+
22
+ document.documentElement.style.removeProperty( '--a8c-charts-color-background' );
23
+ document.body.removeChild( scope );
24
+ } );
25
+
26
+ // Resolving a paint-only color here would freeze it: visx writes whatever it is handed onto the element, so a literal stops following the cascade and an override set below the provider wrapper never reaches it. Handing visx the pointer instead lets it resolve at the element it paints. jsdom does not compute `var()`, so what the pointer resolves *to* is covered in Storybook; this pins that the chain survives the theme build intact.
27
+ it( 'hands visx the catalog pointer for paint-only colors rather than a resolved value', () => {
28
+ const scope = document.createElement( 'div' );
29
+ document.body.appendChild( scope );
30
+
31
+ const wrapper = ( { children }: { children: ReactNode } ) => (
32
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
33
+ );
34
+
35
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
36
+
37
+ expect( result.current.gridStyles.stroke ).toBe( 'var(--a8c-charts-color-grid, #dbdbdb)' );
38
+ expect( result.current.axisStyles.x.bottom.axisLine.stroke ).toBe(
39
+ 'var(--a8c-charts-color-axis, #dbdbdb)'
40
+ );
41
+ expect( result.current.axisStyles.x.bottom.tickLine.stroke ).toBe(
42
+ 'var(--a8c-charts-color-tick, #dbdbdb)'
43
+ );
44
+ expect( result.current.axisStyles.x.bottom.tickLabel.fill ).toBe(
45
+ 'var(--a8c-charts-color-label-axis, #1e1e1e)'
46
+ );
47
+
48
+ document.body.removeChild( scope );
49
+ } );
50
+
51
+ // The one label color that has to arrive resolved: visx paints it on a portal container appended to `document.body`, where the catalog is not declared, and concatenates it into `box-shadow: 0 1px 2px ${color}55`, which a `var()` chain would invalidate.
52
+ it( 'resolves the tooltip label color while the tick labels keep the pointer', () => {
53
+ const scope = document.createElement( 'div' );
54
+ scope.style.setProperty( '--a8c-charts-color-label-axis', '#0000ff' );
55
+ document.body.appendChild( scope );
56
+
57
+ const wrapper = ( { children }: { children: ReactNode } ) => (
58
+ <ChartScopeContext.Provider value={ scope }>{ children }</ChartScopeContext.Provider>
59
+ );
60
+
61
+ const { result } = renderHook( () => useXYChartTheme( [] ), { wrapper } );
62
+
63
+ expect( result.current.htmlLabel.color ).toBe( '#0000ff' );
64
+ expect( result.current.svgLabelSmall.fill ).toBe(
65
+ 'var(--a8c-charts-color-label-axis, #1e1e1e)'
66
+ );
21
67
 
22
- document.documentElement.style.removeProperty( '--a8c-charts-color-grid' );
23
68
  document.body.removeChild( scope );
24
69
  } );
25
70
  } );
@@ -9,7 +9,7 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
9
9
  const theme = useGlobalChartsTheme();
10
10
  const scopeElement = useChartScopeElement();
11
11
 
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.
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. Serialized 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 color cannot round-trip.
13
13
  const seriesColorKey = JSON.stringify(
14
14
  ( data ?? [] )
15
15
  .map( series => series.options?.stroke )
@@ -17,35 +17,29 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
17
17
  );
18
18
 
19
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.
20
+ // Only what is read as a string is resolved here — the palette and `backgroundColor` — plus `htmlLabel.color` below, which is painted outside the scope. Resolving against the chart's own scope element, never :root, is what reads an override set inside the provider tree.
21
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.
22
+ // One resolver per theme build, so all three share a single getComputedStyle call.
23
23
  const resolve = createCssVariableResolver( scopeElement );
24
24
  const resolveColor = ( value?: string ): string | undefined =>
25
25
  value ? resolve( value ) ?? value : value;
26
26
 
27
27
  const seriesColors: string[] = JSON.parse( seriesColorKey );
28
28
 
29
+ // The palette gets the same treatment as every other color here, and needs it more: `theme.colors` is the one field that is always a `var()` chain, and the four slots without a catalog default resolve to nothing at all. visx builds its `colorScale` from this array and uses it as the default stroke for a series rendered without an explicit one, so an unresolved entry paints nothing rather than degrading. Entries that resolve to nothing are dropped so the scale compacts the way the provider's own palette does.
30
+ const paletteColors = [ ...seriesColors, ...( theme.colors ?? [] ) ]
31
+ .map( color => resolveColor( color ) )
32
+ .filter( ( color ): color is string => Boolean( color ) && ! color.includes( 'var(' ) );
33
+
34
+ // The tooltip is painted in a portal outside the scope, and visx concatenates this color into a `box-shadow` where a chain cannot take a suffix; see TOKENS.md#the-svg-bridge. Passing it explicitly leaves `svgLabelSmall.fill`, which visx derives it from, a chain for the SVG tick labels.
35
+ const htmlLabelColor = resolveColor( theme.svgLabelSmall?.fill );
36
+
37
+ // The grid, axis, tick and label colors are spread through untouched, and that is the whole mechanism: visx writes each one onto the element it paints — as an inline style for the grid, a presentation attribute elsewhere — and a `var()` chain resolves there natively. So the role is read at the painted element rather than snapshot at the provider wrapper, which is what makes an override on a chart's own class work, keeps a theme change live without a re-render, and leaves nothing to resolve during SSR. Resolving them here would freeze the color instead.
29
38
  return buildChartTheme( {
30
39
  ...theme,
31
- colors: [ ...seriesColors, ...( theme.colors ?? [] ) ],
40
+ colors: paletteColors,
32
41
  backgroundColor: resolveColor( theme.backgroundColor ),
33
- gridStyles: theme.gridStyles && {
34
- ...theme.gridStyles,
35
- stroke: resolveColor( theme.gridStyles.stroke ),
36
- },
37
- xAxisLineStyles: theme.xAxisLineStyles && {
38
- ...theme.xAxisLineStyles,
39
- stroke: resolveColor( theme.xAxisLineStyles.stroke ),
40
- },
41
- xTickLineStyles: theme.xTickLineStyles && {
42
- ...theme.xTickLineStyles,
43
- stroke: resolveColor( theme.xTickLineStyles.stroke ),
44
- },
45
- svgLabelSmall: theme.svgLabelSmall && {
46
- ...theme.svgLabelSmall,
47
- fill: resolveColor( theme.svgLabelSmall.fill ),
48
- },
42
+ htmlLabel: htmlLabelColor ? { color: htmlLabelColor } : undefined,
49
43
  } );
50
44
  }, [ theme, seriesColorKey, scopeElement ] );
51
45
  };
@@ -24,6 +24,7 @@ import {
24
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
25
  import { ChartScopeContext } from '../chart-scope/chart-scope-context';
26
26
  import { getChartColor, type ColorCache } from './private/get-chart-color';
27
+ import { SERIES_PALETTE_POINTERS } from './private/series-palette';
27
28
  import { themeOverrideVars } from './private/theme-override-vars';
28
29
  import { withCatalogPointers } from './private/with-catalog-pointers';
29
30
  import { defaultTheme } from './themes';
@@ -91,45 +92,48 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
91
92
  // Resolves CSS variables from the wrapper element's scope to handle scoped variables
92
93
  // Note: Only re-runs when providerTheme changes, not when wrapper element changes.
93
94
  // This is intentional, as wrapperRef is expected to be stable for the lifetime of the provider.
95
+ // A remount is not a gap in that: effects always run on mount, so a new instance resolves
96
+ // against its own node. Only a node swap *within* one instance would go unseen, and the catalog
97
+ // is declared on the `.a8c-charts-scope` class rather than on a particular element, so the
98
+ // replacement carries the same computed values anyway.
94
99
  useLayoutEffect( () => {
95
100
  setIsColorPaletteResolved( false );
96
- const { colors } = providerTheme;
97
101
  const resolvedColors: string[] = [];
98
102
  const hues: number[] = [];
99
103
  const existingHslColors: Array< [ number, number, number ] > = [];
100
104
  let minHue = 360;
101
105
  let maxHue = 0;
102
106
 
103
- // Process all colors once and cache the results
104
- if ( Array.isArray( colors ) ) {
105
- for ( const color of colors ) {
106
- if ( color && typeof color === 'string' ) {
107
- // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
108
- // This uses normalizeColorToHex which resolves CSS variables and converts
109
- // rgb(), rgba(), hsl() formats to hex
110
- const normalizedColor = normalizeColorToHex(
111
- color,
112
- wrapperRef.current,
113
- resolveCssVariable
114
- );
115
-
116
- // Only process valid hex colors
117
- if ( normalizedColor.startsWith( '#' ) ) {
118
- resolvedColors.push( normalizedColor );
119
- const hslColor = d3Hsl( normalizedColor );
120
- // d3Hsl returns NaN values for invalid colors
121
- if ( ! isNaN( hslColor.h ) ) {
122
- const hslTuple: [ number, number, number ] = [
123
- hslColor.h,
124
- hslColor.s * 100,
125
- hslColor.l * 100,
126
- ];
127
- hues.push( hslTuple[ 0 ] );
128
- existingHslColors.push( hslTuple );
129
- minHue = Math.min( minHue, hslTuple[ 0 ] );
130
- maxHue = Math.max( maxHue, hslTuple[ 0 ] );
131
- }
132
- }
107
+ // Resolved from the theme rather than from `SERIES_PALETTE_POINTERS`, and it has to be. In a
108
+ // browser the two are equivalent — both name the same slots, and the wrapper's theme-layer
109
+ // vars answer either. But `withCatalogPointers` puts the consumer's own color in each
110
+ // pointer's terminal position, and that literal is the only carrier for the palette where
111
+ // `getComputedStyle` resolves nothing: SSR and jsdom. Walking the manifest instead makes
112
+ // every consumer palette collapse to the catalog seed there.
113
+ for ( const color of providerTheme.colors ?? SERIES_PALETTE_POINTERS ) {
114
+ // Normalize color to hex format, handling CSS variables, RGB, HSL, etc.
115
+ // This uses normalizeColorToHex which resolves CSS variables and converts
116
+ // rgb(), rgba(), hsl() formats to hex
117
+ const normalizedColor = normalizeColorToHex( color, wrapperRef.current, resolveCssVariable );
118
+
119
+ // Only process valid hex colors. An unset palette slot returns its own
120
+ // `var()` unchanged, so this is also what compacts the palette: slots the
121
+ // consumer never set drop out here and `getChartColor` generates past
122
+ // whatever survived.
123
+ if ( normalizedColor.startsWith( '#' ) ) {
124
+ resolvedColors.push( normalizedColor );
125
+ const hslColor = d3Hsl( normalizedColor );
126
+ // d3Hsl returns NaN values for invalid colors
127
+ if ( ! isNaN( hslColor.h ) ) {
128
+ const hslTuple: [ number, number, number ] = [
129
+ hslColor.h,
130
+ hslColor.s * 100,
131
+ hslColor.l * 100,
132
+ ];
133
+ hues.push( hslTuple[ 0 ] );
134
+ existingHslColors.push( hslTuple );
135
+ minHue = Math.min( minHue, hslTuple[ 0 ] );
136
+ maxHue = Math.max( maxHue, hslTuple[ 0 ] );
133
137
  }
134
138
  }
135
139
  }
@@ -153,11 +157,18 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
153
157
  () => new Map()
154
158
  );
155
159
 
156
- // Reset group color mappings when theme colors change
160
+ // Reset group color mappings when the resolved palette changes.
161
+ //
162
+ // Keyed on the resolved colors rather than on `providerTheme.colors`, which holds five
163
+ // catalog pointers and so does not move with a `theme.colors` change — a consumer's colors
164
+ // reach the palette through the theme-layer vars on the wrapper. Keying on content also stops
165
+ // a consumer passing an inline `theme` object from resetting the map on every render.
166
+ const paletteKey = colorCache.colors.join( ',' );
167
+
157
168
  useEffect( () => {
158
169
  // Create a completely new Map instance to trigger dependencies, e.g. useChartLegendItems
159
170
  setGroupToColorMap( new Map() );
160
- }, [ providerTheme.colors ] );
171
+ }, [ paletteKey ] );
161
172
 
162
173
  const registerChart = useCallback( ( id: string, data: ChartRegistration ) => {
163
174
  setCharts( prev => new Map( prev ).set( id, data ) );
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The series palette's slot manifest.
3
+ *
4
+ * Which slots exist is a fact about the catalog `chart-scope.scss` emits, not a theme value, so it
5
+ * is declared here rather than in `themes.ts`, which now derives `defaultTheme.colors` from it.
6
+ *
7
+ * That is as far as the separation goes, and the limit is worth knowing before the field is
8
+ * deleted. `GlobalChartsProvider` still resolves `providerTheme.colors`, not this manifest,
9
+ * because `withCatalogPointers` parks the consumer's own color in each pointer's terminal position
10
+ * and that literal is the palette's only carrier where `getComputedStyle` resolves nothing — SSR
11
+ * and jsdom. Swapping the provider onto this manifest passes in a browser and collapses every
12
+ * consumer palette to the catalog seed under jsdom; 24 tests say so. Removing `colors` therefore
13
+ * needs a different home for that literal first, not just a change of source here.
14
+ */
15
+
16
+ /** How many series-palette slots the catalog emits. `theme.colors` entries past this are ignored. */
17
+ export const SERIES_SLOT_COUNT = 5;
18
+
19
+ /**
20
+ * The catalog role holding one series-palette slot.
21
+ *
22
+ * @param slot - The one-based slot number.
23
+ * @return The role name.
24
+ */
25
+ export const seriesRole = ( slot: number ): string => `--a8c-charts-color-series-${ slot }`;
26
+
27
+ /*
28
+ * Only slot 1 carries a terminal literal, and it is reached only where `getComputedStyle` resolves
29
+ * nothing — SSR and jsdom. The rest resolve to nothing until a consumer sets them, and the provider
30
+ * drops what resolves to nothing, so the palette compacts rather than repeating one color.
31
+ *
32
+ * The literal matches slot 1's own last-resort fallback in `chart-scope.scss`. It is deliberately
33
+ * the bare hex rather than that slot's full `var()` chain: this string is resolved against the DOM,
34
+ * where the chain has already been walked, and is only read at all when no DOM answered.
35
+ */
36
+ const SERIES_SLOT_1_FALLBACK = '#3858e9';
37
+
38
+ /** The catalog pointer for every slot, in slot order. */
39
+ export const SERIES_PALETTE_POINTERS: readonly string[] = Array.from(
40
+ { length: SERIES_SLOT_COUNT },
41
+ ( _, index ) =>
42
+ index === 0
43
+ ? `var(${ seriesRole( 1 ) }, ${ SERIES_SLOT_1_FALLBACK })`
44
+ : `var(${ seriesRole( index + 1 ) })`
45
+ );
@@ -1,6 +1,15 @@
1
+ import { SERIES_SLOT_COUNT, seriesRole } from './series-palette';
1
2
  import type { ChartTheme } from '../../../types';
2
3
 
4
+ /*
5
+ * `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
6
+ * minimal `process` locally so this file type-checks as source under `jetpack:src`.
7
+ */
8
+ declare const process: { env: Record< string, string | undefined > };
9
+
3
10
  // 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.
11
+ //
12
+ // `theme.colors` is the one field mapped by position rather than by name: entry N publishes slot N+1, which is what makes it sugar over the five slots rather than a second palette with its own precedence.
4
13
  const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
5
14
  [
6
15
  [ '--a8c-charts-color-background', theme => theme.backgroundColor ],
@@ -8,8 +17,37 @@ const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => strin
8
17
  [ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
9
18
  [ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
10
19
  [ '--a8c-charts-color-label-axis', theme => theme.svgLabelSmall?.fill ],
20
+ [ '--a8c-charts-color-label-background', theme => theme.labelBackgroundColor ],
21
+ [ '--a8c-charts-color-label-on-fill', theme => theme.labelTextColor ],
22
+ ...Array.from(
23
+ { length: SERIES_SLOT_COUNT },
24
+ ( _, index ): [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] => [
25
+ seriesRole( index + 1 ),
26
+ theme => theme.colors?.[ index ],
27
+ ]
28
+ ),
11
29
  ];
12
30
 
31
+ let warnedAboutExtraColors = false;
32
+
33
+ /**
34
+ * Warns once when a consumer passes more colors than there are slots to publish them into.
35
+ *
36
+ * @param count - How many entries `theme.colors` holds.
37
+ */
38
+ function warnOnceAboutExtraColors( count: number ): void {
39
+ if ( warnedAboutExtraColors || process.env.NODE_ENV === 'production' ) {
40
+ return;
41
+ }
42
+
43
+ warnedAboutExtraColors = true;
44
+ // eslint-disable-next-line no-console
45
+ console.warn(
46
+ `[Charts] theme.colors holds ${ count } colors and the palette has ${ SERIES_SLOT_COUNT } slots, so entries past the ${ SERIES_SLOT_COUNT }th are ignored. ` +
47
+ 'Set a per-series color with `options.stroke` instead.'
48
+ );
49
+ }
50
+
13
51
  /**
14
52
  * The variable a `theme` prop override is published as, one layer outside the role itself.
15
53
  *
@@ -26,7 +64,7 @@ export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ ro
26
64
  /**
27
65
  * Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
28
66
  *
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`.
67
+ * 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 color. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
30
68
  *
31
69
  * Three things this has to get right, none of which a plain substring test gets:
32
70
  *
@@ -54,10 +92,12 @@ export type ThemeOverrides = {
54
92
  };
55
93
 
56
94
  /**
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.
95
+ * Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colors read one source.
58
96
  *
59
97
  * 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
98
  *
99
+ * `theme.colors` publishes through the same mechanism, one slot per entry, so a CSS declaration of `--a8c-charts-color-series-N` still beats it and a short array leaves the later slots unset rather than blank.
100
+ *
61
101
  * @param theme - The consumer's `theme` prop, before merging with the default theme.
62
102
  * @return The wrapper's custom properties, and the roles the consumer overrode.
63
103
  */
@@ -66,6 +106,10 @@ export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrid
66
106
  return { vars: {}, roles: [] };
67
107
  }
68
108
 
109
+ if ( Array.isArray( theme.colors ) && theme.colors.length > SERIES_SLOT_COUNT ) {
110
+ warnOnceAboutExtraColors( theme.colors.length );
111
+ }
112
+
69
113
  const vars: Record< string, string > = {};
70
114
  const roles: string[] = [];
71
115
 
@@ -1,11 +1,50 @@
1
1
  import { defaultTheme } from '../themes';
2
+ import { SERIES_PALETTE_POINTERS, SERIES_SLOT_COUNT, seriesRole } from './series-palette';
2
3
  import type { CompleteChartTheme } from '../../../types';
3
4
 
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
+ /**
6
+ * The pointer one palette slot is restored to.
7
+ *
8
+ * Unlike the other roles, this keeps the consumer's own color as the pointer's terminal literal instead of the catalog's. The literal is only ever reached where `getComputedStyle` resolves nothing — SSR and jsdom — and there the palette is the one field where falling back to the catalog default would be visible: every series would paint the same seeded blue. In a browser the slot resolves first, so a CSS declaration of it still outranks the consumer's `theme.colors`.
9
+ *
10
+ * @param index - The slot's zero-based index in `colors`.
11
+ * @param value - The merged theme's entry for it.
12
+ * @return The catalog pointer for that slot.
13
+ */
14
+ const seriesPointer = ( index: number, value: string | undefined ): string => {
15
+ const role = seriesRole( index + 1 );
16
+
17
+ // Idempotent: `withCatalogPointers` runs this once per overridden slot, so from the second call on the array already holds pointers.
18
+ if ( typeof value === 'string' && value.startsWith( `var(${ role }` ) ) {
19
+ return value;
20
+ }
21
+
22
+ // A slot the consumer left empty keeps the catalog's own pointer, so a CSS declaration of it still reaches the palette.
23
+ if ( typeof value !== 'string' || value === '' ) {
24
+ return SERIES_PALETTE_POINTERS[ index ];
25
+ }
26
+
27
+ return `var(${ role }, ${ value })`;
28
+ };
29
+
30
+ // Restores each overridden role's mapped theme field to the catalog pointer already written for it — `defaultTheme` for the ordinary roles, `series-palette.ts` for the palette slots — so the value comes from the one place that declares it rather than a second, hand-copied literal.
31
+ //
32
+ // Every series slot restores the whole `colors` array, not just its own entry. `mergeThemes` replaces arrays outright, so a consumer passing two colors would otherwise leave a two-entry palette and put slots 3 to 5 out of reach of a CSS declaration that sets them.
5
33
  const CATALOG_RESTORE_FOR_ROLE: Record<
6
34
  string,
7
35
  ( theme: CompleteChartTheme ) => Partial< CompleteChartTheme >
8
36
  > = {
37
+ // Each of the five entries rebuilds the whole array, so a consumer overriding every slot runs this five times. That is deliberate rather than merely tolerated: `seriesPointer` is idempotent, so every pass after the first is a no-op, and five allocations of a five-element array is not worth a special case in a map that is otherwise one entry per role.
38
+ ...Object.fromEntries(
39
+ Array.from( { length: SERIES_SLOT_COUNT }, ( _, index ) => [
40
+ seriesRole( index + 1 ),
41
+ ( theme: CompleteChartTheme ) => ( {
42
+ colors: SERIES_PALETTE_POINTERS.map( ( _pointer, slot ) =>
43
+ seriesPointer( slot, theme.colors?.[ slot ] )
44
+ ),
45
+ } ),
46
+ ] )
47
+ ),
9
48
  '--a8c-charts-color-background': () => ( { backgroundColor: defaultTheme.backgroundColor } ),
10
49
  '--a8c-charts-color-grid': theme => ( {
11
50
  gridStyles: { ...theme.gridStyles, stroke: defaultTheme.gridStyles.stroke },
@@ -19,6 +58,10 @@ const CATALOG_RESTORE_FOR_ROLE: Record<
19
58
  '--a8c-charts-color-label-axis': theme => ( {
20
59
  svgLabelSmall: { ...theme.svgLabelSmall, fill: defaultTheme.svgLabelSmall.fill },
21
60
  } ),
61
+ '--a8c-charts-color-label-background': () => ( {
62
+ labelBackgroundColor: defaultTheme.labelBackgroundColor,
63
+ } ),
64
+ '--a8c-charts-color-label-on-fill': () => ( { labelTextColor: defaultTheme.labelTextColor } ),
22
65
  };
23
66
 
24
67
  /**
@@ -26,7 +69,7 @@ const CATALOG_RESTORE_FOR_ROLE: Record<
26
69
  *
27
70
  * 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
71
  *
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.
72
+ * Every field outside the 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
73
  *
31
74
  * @param merged - The consumer theme merged onto `defaultTheme` (`mergeThemes` output).
32
75
  * @param overriddenRoles - The catalog roles the consumer overrode, from `themeOverrideVars`.
@@ -20,16 +20,30 @@ describe( 'defaultTheme', () => {
20
20
  expect( strings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
21
21
  } );
22
22
 
23
- it( 'points every themed colour at an --a8c-charts-* catalog role', () => {
23
+ it( 'points every themed color at an --a8c-charts-* catalog role', () => {
24
24
  const varStrings = strings.filter( value => value.startsWith( 'var(' ) );
25
25
 
26
26
  expect( varStrings.length ).toBeGreaterThan( 0 );
27
27
  expect( varStrings.every( value => value.startsWith( 'var(--a8c-charts-' ) ) ).toBe( true );
28
28
  } );
29
29
 
30
+ // The palette slots past the first are the exception, below: a terminal literal there would be a color nobody chose.
30
31
  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
+ const varStrings = strings
33
+ .filter( value => value.startsWith( 'var(--a8c-charts-' ) )
34
+ .filter( value => ! /^var\( *--a8c-charts-color-series-[2-9] *\)$/.test( value ) );
32
35
 
33
36
  expect( varStrings.every( value => value.includes( ',' ) ) ).toBe( true );
34
37
  } );
38
+
39
+ // Only slot 1 seeds a palette. The rest resolve to nothing until a consumer sets them, and the provider drops what resolves to nothing, so the palette compacts instead of repeating one color.
40
+ it( 'leaves the palette slots past the first with no default of their own', () => {
41
+ expect( defaultTheme.colors ).toEqual( [
42
+ 'var(--a8c-charts-color-series-1, #3858e9)',
43
+ 'var(--a8c-charts-color-series-2)',
44
+ 'var(--a8c-charts-color-series-3)',
45
+ 'var(--a8c-charts-color-series-4)',
46
+ 'var(--a8c-charts-color-series-5)',
47
+ ] );
48
+ } );
35
49
  } );