@automattic/charts 2.0.1 → 3.1.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 (102) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +109 -50
  3. package/dist/index.cjs +529 -233
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +118 -55
  6. package/dist/index.d.cts +90 -48
  7. package/dist/index.d.ts +90 -48
  8. package/dist/index.js +527 -235
  9. package/dist/index.js.map +1 -1
  10. package/package.json +1 -1
  11. package/src/charts/area-chart/area-chart.module.scss +8 -3
  12. package/src/charts/area-chart/area-chart.tsx +25 -25
  13. package/src/charts/area-chart/private/overlays.tsx +3 -3
  14. package/src/charts/area-chart/test/area-chart.test.tsx +301 -11
  15. package/src/charts/area-chart/types.ts +4 -3
  16. package/src/charts/bar-chart/bar-chart.module.scss +14 -6
  17. package/src/charts/bar-chart/bar-chart.tsx +21 -15
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +255 -2
  19. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +23 -18
  20. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
  21. package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
  22. package/src/charts/geo-chart/geo-chart.module.scss +8 -0
  23. package/src/charts/geo-chart/geo-chart.tsx +12 -4
  24. package/src/charts/heatmap-chart/heatmap-chart.module.scss +22 -9
  25. package/src/charts/heatmap-chart/heatmap-chart.tsx +43 -13
  26. package/src/charts/heatmap-chart/private/build-calendar-data.ts +65 -5
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +125 -0
  28. package/src/charts/heatmap-chart/types.ts +8 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
  30. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +17 -22
  31. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +107 -1
  32. package/src/charts/leaderboard-chart/types.ts +0 -2
  33. package/src/charts/line-chart/line-chart.module.scss +12 -7
  34. package/src/charts/line-chart/line-chart.tsx +32 -28
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
  36. package/src/charts/line-chart/private/line-chart-annotations-overlay.tsx +2 -2
  37. package/src/charts/line-chart/test/line-chart.test.tsx +262 -5
  38. package/src/charts/line-chart/types.ts +4 -3
  39. package/src/charts/pie-chart/pie-chart.tsx +12 -14
  40. package/src/charts/pie-chart/test/pie-chart.test.tsx +73 -1
  41. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +2 -2
  42. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +12 -14
  43. package/src/charts/private/{single-chart-context/single-chart-context.tsx → chart-instance-context/chart-instance-context.ts} +1 -5
  44. package/src/charts/private/chart-instance-context/index.ts +3 -0
  45. package/src/charts/private/{single-chart-context/use-single-chart-context.ts → chart-instance-context/use-chart-instance-context.ts} +1 -3
  46. package/src/charts/private/svg-empty-state/get-all-hidden-message.ts +41 -0
  47. package/src/charts/private/svg-empty-state/index.ts +1 -0
  48. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  49. package/src/charts/private/svg-empty-state/test/get-all-hidden-message.test.ts +15 -0
  50. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
  51. package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
  52. package/src/charts/private/x-zoom/test/x-zoom.test.tsx +2 -2
  53. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  54. package/src/charts/private/x-zoom/x-zoom.tsx +2 -2
  55. package/src/components/legend/legend.tsx +4 -4
  56. package/src/components/legend/private/base-legend.module.scss +1 -1
  57. package/src/components/legend/private/base-legend.tsx +70 -19
  58. package/src/components/legend/test/legend.test.tsx +100 -4
  59. package/src/components/tooltip/accessible-tooltip.tsx +17 -4
  60. package/src/components/tooltip/base-tooltip.tsx +8 -1
  61. package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
  62. package/src/components/trend-indicator/trend-indicator.module.scss +4 -7
  63. package/src/components/trend-indicator/trend-indicator.tsx +3 -0
  64. package/src/hooks/index.ts +1 -1
  65. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
  66. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
  67. package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
  68. package/src/hooks/{use-interactive-legend-data.ts → use-legend-visibility-data.ts} +29 -30
  69. package/src/hooks/use-xychart-theme.ts +6 -4
  70. package/src/index.ts +12 -3
  71. package/src/providers/chart-context/global-charts-provider.tsx +127 -30
  72. package/src/providers/chart-context/hooks/use-default-hidden-series.ts +47 -0
  73. package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
  74. package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
  75. package/src/providers/chart-context/test/chart-context.test.tsx +142 -40
  76. package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
  77. package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
  78. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
  79. package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
  80. package/src/providers/chart-context/test/use-default-hidden-series-paint.test.tsx +56 -0
  81. package/src/providers/chart-context/test/use-default-hidden-series.ssr.test.tsx +39 -0
  82. package/src/providers/chart-context/test/use-default-hidden-series.test.tsx +222 -0
  83. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
  84. package/src/providers/chart-context/themes.ts +18 -27
  85. package/src/providers/chart-context/types.ts +7 -4
  86. package/src/providers/chart-scope/chart-scope-context.ts +8 -0
  87. package/src/providers/chart-scope/index.ts +3 -0
  88. package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
  89. package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
  90. package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
  91. package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
  92. package/src/providers/index.ts +1 -0
  93. package/src/styles/chart-scope-class.ts +6 -0
  94. package/src/styles/chart-scope.scss +83 -0
  95. package/src/styles/test/chart-scope.test.ts +165 -0
  96. package/src/styles/test/wpds-fallbacks.test.ts +131 -0
  97. package/src/types.ts +12 -34
  98. package/src/charts/private/grid-control/grid-control.module.scss +0 -8
  99. package/src/charts/private/grid-control/grid-control.tsx +0 -26
  100. package/src/charts/private/grid-control/index.ts +0 -1
  101. package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
  102. package/src/charts/private/single-chart-context/index.ts +0 -8
@@ -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
 
@@ -35,13 +44,33 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
35
44
  const [ hiddenSeries, setHiddenSeries ] = useState< Map< string, Set< string > > >(
36
45
  () => new Map()
37
46
  );
47
+ // Which charts have had their defaults seeded. Kept apart from `hiddenSeries`
48
+ // because that map drops a chart's entry once its hidden set empties, so an
49
+ // absent entry cannot tell "never seeded" from "the reader revealed the lot".
50
+ const seededCharts = useRef< Set< string > >( new Set() );
38
51
 
39
52
  // Ref to the wrapper element for resolving scoped CSS variables
40
53
  const wrapperRef = useRef< HTMLDivElement >( null );
54
+ const [ scopeNode, setScopeNode ] = useState< HTMLElement | null >( null );
55
+
56
+ const setWrapperNode = useCallback( ( node: HTMLDivElement | null ) => {
57
+ wrapperRef.current = node;
58
+ setScopeNode( node );
59
+ }, [] );
60
+
61
+ // 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).
62
+ const { vars: overrideVars, roles: overriddenRoles } = useMemo(
63
+ () => themeOverrideVars( theme ),
64
+ [ theme ]
65
+ );
41
66
 
42
67
  const providerTheme: CompleteChartTheme = useMemo( () => {
43
- return theme ? mergeThemes( defaultTheme, theme ) : defaultTheme;
44
- }, [ theme ] );
68
+ if ( ! theme ) {
69
+ return defaultTheme;
70
+ }
71
+
72
+ return withCatalogPointers( mergeThemes( defaultTheme, theme ), overriddenRoles );
73
+ }, [ theme, overriddenRoles ] );
45
74
 
46
75
  // Cache expensive color computations that only change when theme colors change
47
76
  // Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
@@ -186,11 +215,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
186
215
  [ colorCache, groupToColorMap ]
187
216
  );
188
217
 
189
- const resolveThemeColor = useCallback< GlobalChartsContextValue[ 'resolveThemeColor' ] >(
190
- value => ( value ? normalizeColorToHex( value, wrapperRef.current, resolveCssVariable ) : '' ),
191
- []
192
- );
193
-
194
218
  const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >(
195
219
  ( { data, index, overrideColor, legendShape } ) => {
196
220
  const isSeriesData = data && typeof data === 'object' && 'data' in data && 'options' in data;
@@ -222,28 +246,90 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
222
246
  [ providerTheme, resolveColor ]
223
247
  );
224
248
 
225
- // Series visibility management methods
226
- const toggleSeriesVisibility = useCallback( ( chartId: string, seriesLabel: string ) => {
227
- setHiddenSeries( prev => {
228
- const newMap = new Map( prev );
229
- const chartHidden = newMap.get( chartId ) || new Set();
230
- const newSet = new Set( chartHidden );
249
+ // Keep hidden-series updates and no-op detection consistent across setters.
250
+ const updateHiddenSeries = useCallback(
251
+ ( chartId: string, update: ( current: Set< string > ) => Set< string > ) => {
252
+ setHiddenSeries( prev => {
253
+ const current = prev.get( chartId );
254
+ const next = update( new Set( current ?? [] ) );
231
255
 
232
- if ( newSet.has( seriesLabel ) ) {
233
- newSet.delete( seriesLabel );
234
- } else {
235
- newSet.add( seriesLabel );
236
- }
256
+ if ( ! current && next.size === 0 ) {
257
+ return prev;
258
+ }
259
+
260
+ // Preserve identity for no-op updates.
261
+ if (
262
+ current &&
263
+ current.size === next.size &&
264
+ [ ...next ].every( label => current.has( label ) )
265
+ ) {
266
+ return prev;
267
+ }
268
+
269
+ const newMap = new Map( prev );
270
+ if ( next.size === 0 ) {
271
+ newMap.delete( chartId );
272
+ } else {
273
+ newMap.set( chartId, next );
274
+ }
275
+
276
+ return newMap;
277
+ } );
278
+ },
279
+ []
280
+ );
237
281
 
238
- if ( newSet.size === 0 ) {
239
- newMap.delete( chartId );
240
- } else {
241
- newMap.set( chartId, newSet );
282
+ const toggleSeriesVisibility = useCallback(
283
+ ( chartId: string, seriesLabel: string ) => {
284
+ updateHiddenSeries( chartId, current => {
285
+ if ( current.has( seriesLabel ) ) {
286
+ current.delete( seriesLabel );
287
+ } else {
288
+ current.add( seriesLabel );
289
+ }
290
+ return current;
291
+ } );
292
+ },
293
+ [ updateHiddenSeries ]
294
+ );
295
+
296
+ const setSeriesVisibility = useCallback(
297
+ ( chartId: string, seriesLabel: string, visible: boolean ) => {
298
+ updateHiddenSeries( chartId, current => {
299
+ if ( visible ) {
300
+ current.delete( seriesLabel );
301
+ } else {
302
+ current.add( seriesLabel );
303
+ }
304
+ return current;
305
+ } );
306
+ },
307
+ [ updateHiddenSeries ]
308
+ );
309
+
310
+ const setChartHiddenSeries = useCallback(
311
+ ( chartId: string, seriesLabels: readonly string[] ) => {
312
+ updateHiddenSeries( chartId, () => new Set( seriesLabels ) );
313
+ },
314
+ [ updateHiddenSeries ]
315
+ );
316
+
317
+ const hasSeededChart = useCallback(
318
+ ( chartId: string ) => seededCharts.current.has( chartId ),
319
+ []
320
+ );
321
+
322
+ const seedChartHiddenSeries = useCallback(
323
+ ( chartId: string, seriesLabels: readonly string[] ) => {
324
+ if ( seededCharts.current.has( chartId ) ) {
325
+ return;
242
326
  }
243
327
 
244
- return newMap;
245
- } );
246
- }, [] );
328
+ seededCharts.current.add( chartId );
329
+ setChartHiddenSeries( chartId, seriesLabels );
330
+ },
331
+ [ setChartHiddenSeries ]
332
+ );
247
333
 
248
334
  const isSeriesVisible = useCallback(
249
335
  ( chartId: string, seriesLabel: string ) => {
@@ -269,8 +355,11 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
269
355
  getChartData,
270
356
  theme: providerTheme,
271
357
  getElementStyles,
272
- resolveThemeColor,
273
358
  toggleSeriesVisibility,
359
+ setSeriesVisibility,
360
+ setChartHiddenSeries,
361
+ seedChartHiddenSeries,
362
+ hasSeededChart,
274
363
  isSeriesVisible,
275
364
  getHiddenSeries,
276
365
  isColorPaletteResolved,
@@ -282,8 +371,11 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
282
371
  getChartData,
283
372
  providerTheme,
284
373
  getElementStyles,
285
- resolveThemeColor,
286
374
  toggleSeriesVisibility,
375
+ setSeriesVisibility,
376
+ setChartHiddenSeries,
377
+ seedChartHiddenSeries,
378
+ hasSeededChart,
287
379
  isSeriesVisible,
288
380
  getHiddenSeries,
289
381
  isColorPaletteResolved,
@@ -292,8 +384,13 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
292
384
 
293
385
  return (
294
386
  <GlobalChartsContext.Provider value={ value }>
295
- <div ref={ wrapperRef } style={ { display: 'contents' } }>
296
- { children }
387
+ <div
388
+ ref={ setWrapperNode }
389
+ className={ CHART_SCOPE_CLASS }
390
+ data-testid="charts-scope"
391
+ style={ { display: 'contents', ...overrideVars } as CSSProperties }
392
+ >
393
+ <ChartScopeContext.Provider value={ scopeNode }>{ children }</ChartScopeContext.Provider>
297
394
  </div>
298
395
  </GlobalChartsContext.Provider>
299
396
  );
@@ -0,0 +1,47 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import { useGlobalChartsContext } from './use-global-charts-context';
3
+
4
+ // Hide seeded series before paint without triggering React's SSR warning.
5
+ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
6
+
7
+ /**
8
+ * Seeds a chart's hidden series once per chart ID, the first time the provider
9
+ * sees that ID.
10
+ *
11
+ * The provider owns the record of what has been seeded, not this hook: a
12
+ * mount-scoped one would re-apply the defaults on every remount, and a chart
13
+ * remounts for reasons the reader never thinks of as a new chart — switching
14
+ * between line and bar, tabbing away and back, a resize flipping tabs into a
15
+ * dropdown. Each of those would throw away the series they had just revealed.
16
+ *
17
+ * @param chartId - The chart ID passed to the provider's visibility methods.
18
+ * @param seriesLabels - Labels to hide when this chart ID is first seen. Later values are ignored.
19
+ * @return The resolved hidden-series set.
20
+ */
21
+ export const useDefaultHiddenSeries = (
22
+ chartId: string,
23
+ seriesLabels?: readonly string[]
24
+ ): Set< string > => {
25
+ const { getHiddenSeries, seedChartHiddenSeries, hasSeededChart } = useGlobalChartsContext();
26
+ // Read through a ref so the effect never depends on a new array identity.
27
+ const labelsRef = useRef( seriesLabels );
28
+ labelsRef.current = seriesLabels;
29
+ const hasSeriesLabels = seriesLabels !== undefined;
30
+ const shouldUseDefaults = hasSeriesLabels && ! hasSeededChart( chartId );
31
+
32
+ useIsomorphicLayoutEffect( () => {
33
+ if ( labelsRef.current === undefined ) {
34
+ return;
35
+ }
36
+ seedChartHiddenSeries( chartId, labelsRef.current );
37
+ }, [ chartId, hasSeriesLabels, seedChartHiddenSeries ] );
38
+
39
+ // getHiddenSeries hands out a fresh copy per call, so hold the set still between
40
+ // visibility changes. Charts derive their rendered series from it, and a new
41
+ // identity every render invalidates every memo downstream — including the one
42
+ // the accessible tooltip watches, which closes a tooltip mid-navigation.
43
+ return useMemo(
44
+ () => ( shouldUseDefaults ? new Set( labelsRef.current ) : getHiddenSeries( chartId ) ),
45
+ [ shouldUseDefaults, getHiddenSeries, chartId ]
46
+ );
47
+ };
@@ -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
+ };
@@ -1728,6 +1728,148 @@ describe( 'ChartContext', () => {
1728
1728
  // Should have empty set after all series are visible
1729
1729
  expect( hidden.size ).toBe( 0 );
1730
1730
  } );
1731
+
1732
+ it( 'setSeriesVisibility sets state absolutely rather than toggling', () => {
1733
+ let contextValue: GlobalChartsContextValue;
1734
+ const TestComponent = () => {
1735
+ contextValue = useGlobalChartsContext();
1736
+ return <div>Test</div>;
1737
+ };
1738
+
1739
+ render(
1740
+ <GlobalChartsProvider>
1741
+ <TestComponent />
1742
+ </GlobalChartsProvider>
1743
+ );
1744
+
1745
+ act( () => {
1746
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1747
+ } );
1748
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false );
1749
+
1750
+ // Calling it again with the same value must not flip it back.
1751
+ act( () => {
1752
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1753
+ } );
1754
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( false );
1755
+
1756
+ act( () => {
1757
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', true );
1758
+ } );
1759
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true );
1760
+ } );
1761
+
1762
+ it( 'setChartHiddenSeries replaces the hidden set rather than merging into it', () => {
1763
+ let contextValue: GlobalChartsContextValue;
1764
+ const TestComponent = () => {
1765
+ contextValue = useGlobalChartsContext();
1766
+ return <div>Test</div>;
1767
+ };
1768
+
1769
+ render(
1770
+ <GlobalChartsProvider>
1771
+ <TestComponent />
1772
+ </GlobalChartsProvider>
1773
+ );
1774
+
1775
+ act( () => {
1776
+ contextValue.setChartHiddenSeries( 'test-chart', [ 'Series 1', 'Series 2' ] );
1777
+ } );
1778
+ expect( contextValue.getHiddenSeries( 'test-chart' ) ).toEqual(
1779
+ new Set( [ 'Series 1', 'Series 2' ] )
1780
+ );
1781
+
1782
+ act( () => {
1783
+ contextValue.setChartHiddenSeries( 'test-chart', [ 'Series 2' ] );
1784
+ } );
1785
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 1' ) ).toBe( true );
1786
+ expect( contextValue.isSeriesVisible( 'test-chart', 'Series 2' ) ).toBe( false );
1787
+
1788
+ act( () => {
1789
+ contextValue.setChartHiddenSeries( 'test-chart', [] );
1790
+ } );
1791
+ expect( contextValue.getHiddenSeries( 'test-chart' ) ).toEqual( new Set() );
1792
+ } );
1793
+
1794
+ it( 'setChartHiddenSeries leaves other charts alone', () => {
1795
+ let contextValue: GlobalChartsContextValue;
1796
+ const TestComponent = () => {
1797
+ contextValue = useGlobalChartsContext();
1798
+ return <div>Test</div>;
1799
+ };
1800
+
1801
+ render(
1802
+ <GlobalChartsProvider>
1803
+ <TestComponent />
1804
+ </GlobalChartsProvider>
1805
+ );
1806
+
1807
+ act( () => {
1808
+ contextValue.setChartHiddenSeries( 'chart-a', [ 'Series 1' ] );
1809
+ contextValue.setChartHiddenSeries( 'chart-b', [ 'Series 2' ] );
1810
+ } );
1811
+
1812
+ expect( contextValue.isSeriesVisible( 'chart-a', 'Series 1' ) ).toBe( false );
1813
+ expect( contextValue.isSeriesVisible( 'chart-a', 'Series 2' ) ).toBe( true );
1814
+ expect( contextValue.isSeriesVisible( 'chart-b', 'Series 2' ) ).toBe( false );
1815
+ } );
1816
+
1817
+ it( 'a redundant setSeriesVisibility write does not change context identity or re-render', () => {
1818
+ let contextValue: GlobalChartsContextValue;
1819
+ let childUpdateTally = 0;
1820
+ const TestComponent = () => {
1821
+ contextValue = useGlobalChartsContext();
1822
+ childUpdateTally++;
1823
+ return <div>Test</div>;
1824
+ };
1825
+
1826
+ render(
1827
+ <GlobalChartsProvider>
1828
+ <TestComponent />
1829
+ </GlobalChartsProvider>
1830
+ );
1831
+
1832
+ act( () => {
1833
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1834
+ } );
1835
+ const isSeriesVisibleAfterFirstWrite = contextValue.isSeriesVisible;
1836
+ const tallyAfterFirstWrite = childUpdateTally;
1837
+
1838
+ // Redundant write: the series is already hidden, so this must be a no-op.
1839
+ act( () => {
1840
+ contextValue.setSeriesVisibility( 'test-chart', 'Series 1', false );
1841
+ } );
1842
+
1843
+ expect( contextValue.isSeriesVisible ).toBe( isSeriesVisibleAfterFirstWrite );
1844
+ expect( childUpdateTally ).toBe( tallyAfterFirstWrite );
1845
+ } );
1846
+
1847
+ it( 'setting an already-empty hidden set does not create then delete a Map entry', () => {
1848
+ let contextValue: GlobalChartsContextValue;
1849
+ let childUpdateTally = 0;
1850
+ const TestComponent = () => {
1851
+ contextValue = useGlobalChartsContext();
1852
+ childUpdateTally++;
1853
+ return <div>Test</div>;
1854
+ };
1855
+
1856
+ render(
1857
+ <GlobalChartsProvider>
1858
+ <TestComponent />
1859
+ </GlobalChartsProvider>
1860
+ );
1861
+
1862
+ const tallyBeforeWrite = childUpdateTally;
1863
+ const isSeriesVisibleBefore = contextValue.isSeriesVisible;
1864
+
1865
+ // This chart has no hidden-series entry at all yet.
1866
+ act( () => {
1867
+ contextValue.setChartHiddenSeries( 'test-chart', [] );
1868
+ } );
1869
+
1870
+ expect( childUpdateTally ).toBe( tallyBeforeWrite );
1871
+ expect( contextValue.isSeriesVisible ).toBe( isSeriesVisibleBefore );
1872
+ } );
1731
1873
  } );
1732
1874
 
1733
1875
  describe( 'GlobalChartsProvider - CSS Variable Support', () => {
@@ -2536,46 +2678,6 @@ describe( 'ChartContext', () => {
2536
2678
  expect( afterRerenderColor ).toBe( '#ff0000' );
2537
2679
  } );
2538
2680
  } );
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
2681
  } );
2580
2682
 
2581
2683
  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
+ } );