@automattic/charts 3.1.0 → 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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/TOKENS.md +51 -12
  3. package/dist/index.cjs +307 -213
  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 +306 -212
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +45 -0
  13. package/src/charts/bar-chart/bar-chart.tsx +36 -10
  14. package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
  15. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +101 -0
  17. package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
  18. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  19. package/src/charts/line-chart/line-chart.tsx +18 -3
  20. package/src/charts/line-chart/test/line-chart.test.tsx +98 -0
  21. package/src/charts/sparkline/sparkline.tsx +9 -3
  22. package/src/charts/sparkline/types.ts +1 -1
  23. package/src/components/tooltip/accessible-tooltip.tsx +32 -2
  24. package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
  25. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
  26. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  27. package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
  28. package/src/hooks/use-xychart-theme.ts +14 -20
  29. package/src/providers/chart-context/global-charts-provider.tsx +44 -33
  30. package/src/providers/chart-context/private/series-palette.ts +45 -0
  31. package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
  32. package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
  33. package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
  34. package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
  35. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
  36. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
  37. package/src/providers/chart-context/themes.ts +8 -3
  38. package/src/styles/chart-scope.scss +52 -24
  39. package/src/styles/test/chart-scope.test.ts +22 -9
  40. package/src/types.ts +148 -31
  41. package/src/utils/get-styles.ts +7 -1
@@ -500,6 +500,57 @@ describe( 'LineChart', () => {
500
500
  expect( ticks.length ).toBeGreaterThan( 1 );
501
501
  } );
502
502
 
503
+ test( 'keeps the derived month formatter when tickFormat is passed as undefined.', () => {
504
+ renderWithTheme( {
505
+ width: 800,
506
+ options: { axis: { x: { tickFormat: undefined } } },
507
+ data: [
508
+ {
509
+ label: 'Series A',
510
+ data: [
511
+ { date: new Date( '2024-01-01' ), value: 10 },
512
+ { date: new Date( '2024-04-01' ), value: 20 },
513
+ { date: new Date( '2024-07-01' ), value: 30 },
514
+ { date: new Date( '2024-10-01' ), value: 40 },
515
+ { date: new Date( '2025-03-01' ), value: 50 },
516
+ ],
517
+ },
518
+ ],
519
+ } );
520
+
521
+ // January is absent: formatMonthOrYearTick renders it as the year instead.
522
+ const ticks = screen.getAllByText( /^(Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/ );
523
+ expect( ticks.length ).toBeGreaterThan( 1 );
524
+ expect(
525
+ screen.queryByText( /^(February|March|April|June|July|August|September|October)$/ )
526
+ ).not.toBeInTheDocument();
527
+ } );
528
+
529
+ test( 'honors an explicit tickFormat over the derived formatter.', () => {
530
+ renderWithTheme( {
531
+ width: 800,
532
+ options: {
533
+ axis: {
534
+ x: {
535
+ tickFormat: ( date: Date | number ) =>
536
+ `tick-${ new Date( Number( date ) ).getUTCMonth() }`,
537
+ },
538
+ },
539
+ },
540
+ data: [
541
+ {
542
+ label: 'Series A',
543
+ data: [
544
+ { date: new Date( '2024-01-01' ), value: 10 },
545
+ { date: new Date( '2024-07-01' ), value: 30 },
546
+ ],
547
+ },
548
+ ],
549
+ } );
550
+
551
+ expect( screen.getAllByText( /^tick-\d+$/ ).length ).toBeGreaterThan( 0 );
552
+ } );
553
+
503
554
  test( 'renders ticks in year format.', () => {
504
555
  renderWithTheme( {
505
556
  width: 800,
@@ -1102,6 +1153,53 @@ describe( 'LineChart', () => {
1102
1153
  } );
1103
1154
  } );
1104
1155
 
1156
+ describe( 'Activation', () => {
1157
+ const SERIES_A = {
1158
+ label: 'Series A',
1159
+ data: [
1160
+ { date: new Date( '2024-01-01' ), value: 10 },
1161
+ { date: new Date( '2024-01-02' ), value: 20 },
1162
+ ],
1163
+ options: {},
1164
+ };
1165
+ const SERIES_B = {
1166
+ label: 'Series B',
1167
+ data: [
1168
+ { date: new Date( '2024-01-01' ), value: 15 },
1169
+ { date: new Date( '2024-01-02' ), value: 25 },
1170
+ ],
1171
+ options: {},
1172
+ };
1173
+
1174
+ // Navigation steps through x positions; the first series names the point.
1175
+ test( 'Enter hands the selected point to onDatumActivate', async () => {
1176
+ const user = userEvent.setup();
1177
+ const onDatumActivate = jest.fn();
1178
+ renderWithTheme( { data: [ SERIES_A, SERIES_B ], onDatumActivate } );
1179
+
1180
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1181
+ await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
1182
+
1183
+ expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
1184
+ expect( onDatumActivate ).toHaveBeenCalledWith( {
1185
+ datum: SERIES_A.data[ 1 ],
1186
+ index: 1,
1187
+ key: 'Series A',
1188
+ } );
1189
+ } );
1190
+
1191
+ test( 'Enter with no point selected activates nothing', async () => {
1192
+ const user = userEvent.setup();
1193
+ const onDatumActivate = jest.fn();
1194
+ renderWithTheme( { data: [ SERIES_A ], onDatumActivate } );
1195
+
1196
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
1197
+ await user.keyboard( '{Enter}' );
1198
+
1199
+ expect( onDatumActivate ).not.toHaveBeenCalled();
1200
+ } );
1201
+ } );
1202
+
1105
1203
  describe( 'Arrow Key Navigation', () => {
1106
1204
  test( 'right arrow key navigates to next data point', async () => {
1107
1205
  const user = userEvent.setup();
@@ -1,6 +1,6 @@
1
1
  import clsx from 'clsx';
2
- import { useMemo, forwardRef } from 'react';
3
- import { useGlobalChartsTheme } from '../../providers';
2
+ import { useContext, useMemo, forwardRef } from 'react';
3
+ import { GlobalChartsContext, useGlobalChartsTheme } from '../../providers';
4
4
  import { LineChartUnresponsive } from '../line-chart';
5
5
  import { withResponsive } from '../private/with-responsive';
6
6
  import styles from './sparkline.module.scss';
@@ -60,6 +60,9 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
60
60
  ref
61
61
  ) => {
62
62
  const theme = useGlobalChartsTheme();
63
+ // Read through the context rather than `useGlobalChartsContext()`, which throws: Sparkline
64
+ // renders standalone too, and only the LineChart it delegates to supplies a provider.
65
+ const chartsContext = useContext( GlobalChartsContext );
63
66
 
64
67
  // Get theme defaults for sparkline
65
68
  const themeStrokeWidth = theme.sparkline?.strokeWidth ?? 1.5;
@@ -127,7 +130,10 @@ const SparklineComponent = forwardRef< HTMLDivElement, SparklineProps >(
127
130
  if ( data.length === 1 ) {
128
131
  const cx = width / 2;
129
132
  const cy = height / 2;
130
- const resolvedColor = color || '#000000';
133
+ // The multi-point path lets LineChart resolve the palette; a lone circle has to ask for
134
+ // the same slot itself, or omitting `color` paints black instead of the series color.
135
+ const resolvedColor =
136
+ color || chartsContext?.getElementStyles( { index: 0 } ).color || '#000000';
131
137
 
132
138
  return (
133
139
  <div
@@ -47,7 +47,7 @@ export interface SparklineProps {
47
47
 
48
48
  /**
49
49
  * Color for the line stroke (hex or CSS color)
50
- * @default Theme color (first color in theme.colors array)
50
+ * @default The first series palette color (`--a8c-charts-color-series-1`)
51
51
  */
52
52
  color?: string;
53
53
 
@@ -1,7 +1,10 @@
1
1
  import { Tooltip, TooltipContext } from '@visx/xychart';
2
2
  import clsx from 'clsx';
3
3
  import { useContext, useEffect, useCallback, useMemo, useRef } from 'react';
4
+ import { useGlobalChartsTheme } from '../../providers';
5
+ import { useChartScopeElement } from '../../providers/chart-scope';
4
6
  import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
7
+ import { resolveCssVariable } from '../../utils';
5
8
  import type { SeriesData, DataPointDate } from '../../types';
6
9
  import type { RenderTooltipParams, XyChartTooltipProps } from '../../visx/types';
7
10
  import type { ReactNode } from 'react';
@@ -45,9 +48,21 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
45
48
  keyboardFocusedClassName,
46
49
  series = [],
47
50
  mode = 'group',
51
+ verticalCrosshairStyle,
52
+ horizontalCrosshairStyle,
48
53
  ...props
49
54
  } ) => {
50
55
  const tooltipContext = useContext( TooltipContext );
56
+ const scopeElement = useChartScopeElement();
57
+ const gridStroke = useGlobalChartsTheme().gridStyles?.stroke;
58
+
59
+ // The crosshair is painted in a portal outside the scope, so it needs a resolved color; see TOKENS.md#the-svg-bridge.
60
+ const crosshairStroke = useMemo( () => {
61
+ const stroke = gridStroke ? resolveCssVariable( gridStroke, scopeElement ) : null;
62
+
63
+ // Passing `stroke: undefined` would erase the crosshair: it overrides visx's own value, and SVG's initial `stroke` is `none`.
64
+ return stroke ? { stroke } : undefined;
65
+ }, [ gridStroke, scopeElement ] );
51
66
 
52
67
  const tooltipData = useMemo( () => {
53
68
  if ( mode !== 'individual' ) return [];
@@ -158,7 +173,14 @@ export const AccessibleTooltip: React.FC< AccessibleTooltipProps > = ( {
158
173
  };
159
174
  }, [ renderTooltip, selectedIndex, tooltipRef, keyboardFocusedClassName ] );
160
175
 
161
- return <Tooltip { ...props } renderTooltip={ focusableRenderTooltip } />;
176
+ return (
177
+ <Tooltip
178
+ { ...props }
179
+ verticalCrosshairStyle={ { ...crosshairStroke, ...verticalCrosshairStyle } }
180
+ horizontalCrosshairStyle={ { ...crosshairStroke, ...horizontalCrosshairStyle } }
181
+ renderTooltip={ focusableRenderTooltip }
182
+ />
183
+ );
162
184
  };
163
185
 
164
186
  // Keyboard navigation hook for charts
@@ -172,6 +194,11 @@ interface UseKeyboardNavigationProps {
172
194
  * Total number of navigation points (length of tooltip data array)
173
195
  */
174
196
  totalPoints: number;
197
+ /**
198
+ * Called with the selected index on Enter or Space, so a chart can treat the
199
+ * keyboard selection the way it treats a click.
200
+ */
201
+ onActivate?: ( index: number ) => void;
175
202
  }
176
203
 
177
204
  export const useKeyboardNavigation = ( {
@@ -181,6 +208,7 @@ export const useKeyboardNavigation = ( {
181
208
  setIsNavigating,
182
209
  chartRef,
183
210
  totalPoints,
211
+ onActivate,
184
212
  }: UseKeyboardNavigationProps ) => {
185
213
  // Focus the tooltip as soon as it is rendered
186
214
  const tooltipRef = useCallback(
@@ -237,9 +265,11 @@ export const useKeyboardNavigation = ( {
237
265
  setSelectedIndex( undefined );
238
266
  setIsNavigating( false );
239
267
  chartRef.current?.focus();
268
+ } else if ( ( event.key === 'Enter' || event.key === ' ' ) && selectedIndex !== undefined ) {
269
+ onActivate?.( selectedIndex );
240
270
  }
241
271
  },
242
- [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef ]
272
+ [ totalPoints, selectedIndex, setSelectedIndex, setIsNavigating, chartRef, onActivate ]
243
273
  );
244
274
 
245
275
  return {
@@ -0,0 +1,68 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { LineChartUnresponsive } from '../../../charts/line-chart/line-chart';
4
+ import { GlobalChartsProvider } from '../../../providers';
5
+ import { ChartScopeContext } from '../../../providers/chart-scope';
6
+ import type { ReactNode } from 'react';
7
+
8
+ // A real chart is the harness rather than the subject: the crosshairs render only once visx has a data context and an open tooltip. The unresponsive export is what lets the test own the scope element — `withResponsive` otherwise provides its own wrapper as the scope.
9
+ const renderChart = ( scope?: HTMLElement ) => {
10
+ const chart = (
11
+ <LineChartUnresponsive
12
+ width={ 500 }
13
+ height={ 300 }
14
+ data={ [
15
+ {
16
+ label: 'Series A',
17
+ data: [
18
+ { date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
19
+ { date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
20
+ ],
21
+ options: {},
22
+ },
23
+ ] }
24
+ withTooltips
25
+ withTooltipCrosshairs={ { showVertical: true } }
26
+ withGradientFill={ false }
27
+ />
28
+ );
29
+
30
+ const scoped: ReactNode = scope ? (
31
+ <ChartScopeContext.Provider value={ scope }>{ chart }</ChartScopeContext.Provider>
32
+ ) : (
33
+ chart
34
+ );
35
+
36
+ return render( <GlobalChartsProvider>{ scoped }</GlobalChartsProvider> );
37
+ };
38
+
39
+ const openTooltip = async () => {
40
+ const user = userEvent.setup();
41
+
42
+ screen.getByRole( 'grid', { name: /line chart/i } ).focus();
43
+ await user.keyboard( '{ArrowRight}' );
44
+
45
+ // eslint-disable-next-line testing-library/no-node-access -- visx owns the crosshair and hardcodes its class name, so there is no attribute to reach it by.
46
+ return document.querySelector( '.visx-crosshair-vertical line' );
47
+ };
48
+
49
+ describe( 'AccessibleTooltip', () => {
50
+ // The crosshair is painted in a portal outside the scope element; see TOKENS.md#the-svg-bridge.
51
+ it( 'reads the grid role from the scope element', async () => {
52
+ const scope = document.createElement( 'div' );
53
+ scope.style.setProperty( '--a8c-charts-color-grid', 'rgb(1, 2, 3)' );
54
+ document.body.appendChild( scope );
55
+
56
+ renderChart( scope );
57
+
58
+ await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', 'rgb(1, 2, 3)' );
59
+
60
+ document.body.removeChild( scope );
61
+ } );
62
+
63
+ it( 'falls back to the catalog default when the role is unset', async () => {
64
+ renderChart();
65
+
66
+ await expect( openTooltip() ).resolves.toHaveAttribute( 'stroke', '#dbdbdb' );
67
+ } );
68
+ } );
@@ -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 ) );