@automattic/charts 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/TOKENS.md +53 -58
  3. package/dist/index.cjs +1012 -542
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +72 -29
  6. package/dist/index.d.cts +151 -179
  7. package/dist/index.d.ts +151 -179
  8. package/dist/index.js +1013 -544
  9. package/dist/index.js.map +1 -1
  10. package/package.json +6 -6
  11. package/src/charts/area-chart/area-chart.tsx +39 -25
  12. package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
  13. package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
  14. package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
  15. package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
  16. package/src/charts/area-chart/types.ts +4 -7
  17. package/src/charts/bar-chart/bar-chart.tsx +2 -1
  18. package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
  20. package/src/charts/bar-list-chart/bar-list-chart.tsx +5 -0
  21. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
  22. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
  23. package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
  24. package/src/charts/geo-chart/geo-chart.module.scss +1 -0
  25. package/src/charts/geo-chart/geo-chart.tsx +5 -10
  26. package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
  27. package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
  28. package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
  29. package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
  30. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
  31. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
  32. package/src/charts/heatmap-chart/types.ts +2 -2
  33. package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
  34. package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
  35. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
  36. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
  37. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
  38. package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
  39. package/src/charts/line-chart/line-chart.module.scss +10 -18
  40. package/src/charts/line-chart/line-chart.tsx +93 -24
  41. package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
  42. package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
  43. package/src/charts/line-chart/test/line-chart.test.tsx +27 -3
  44. package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
  45. package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
  46. package/src/charts/line-chart/types.ts +4 -1
  47. package/src/charts/pie-chart/pie-chart.module.scss +13 -1
  48. package/src/charts/pie-chart/pie-chart.tsx +30 -40
  49. package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
  50. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
  51. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
  52. package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
  53. package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
  54. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  55. package/src/charts/private/test/axis-formatting.test.tsx +85 -0
  56. package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
  57. package/src/charts/private/test/time-axis-options.test.ts +267 -0
  58. package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
  59. package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
  60. package/src/charts/private/test/time-axis.test.ts +9 -69
  61. package/src/charts/private/time-axis-options.ts +106 -0
  62. package/src/charts/private/time-axis.ts +318 -136
  63. package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
  64. package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
  65. package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
  66. package/src/charts/sparkline/types.ts +1 -1
  67. package/src/components/legend/private/base-legend.module.scss +9 -5
  68. package/src/components/tooltip/accessible-tooltip.tsx +13 -15
  69. package/src/components/tooltip/base-tooltip.module.scss +3 -3
  70. package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
  71. package/src/components/tooltip/test/accessible-tooltip.test.tsx +2 -3
  72. package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
  73. package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
  74. package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
  75. package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
  76. package/src/hooks/test/use-xychart-theme.test.tsx +16 -3
  77. package/src/hooks/use-xychart-theme.ts +25 -9
  78. package/src/index.ts +4 -0
  79. package/src/providers/chart-context/global-charts-provider.tsx +37 -38
  80. package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
  81. package/src/providers/chart-context/index.ts +1 -0
  82. package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
  83. package/src/providers/chart-context/private/series-palette.ts +3 -13
  84. package/src/providers/chart-context/test/{sparse-theme.test.ts → catalog-pointers.test.ts} +22 -10
  85. package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
  86. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -25
  87. package/src/providers/chart-context/themes.ts +7 -52
  88. package/src/providers/chart-context/types.ts +3 -0
  89. package/src/styles/chart-scope.scss +39 -62
  90. package/src/styles/test/chart-scope.test.ts +19 -47
  91. package/src/test-utils/series-fixtures.ts +31 -0
  92. package/src/types.ts +79 -166
  93. package/src/utils/bucket-info.ts +87 -0
  94. package/src/utils/color-utils.ts +0 -27
  95. package/src/utils/date-formatting.ts +174 -0
  96. package/src/utils/index.ts +3 -0
  97. package/src/utils/test/bucket-info.test.ts +185 -0
  98. package/src/utils/test/color-utils.test.ts +1 -48
  99. package/src/utils/test/date-formatting.test.ts +89 -0
  100. package/src/utils/test/get-styles.test.ts +10 -7
  101. package/src/utils/test/merge-themes.test.ts +18 -46
  102. package/src/visx/types.ts +29 -2
  103. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
  104. package/src/providers/chart-context/private/theme-override-vars.ts +0 -131
  105. package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -93
  106. package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
  107. package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -183
  108. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -104
  109. package/src/styles/test/wpds-fallbacks.test.ts +0 -131
@@ -1,131 +0,0 @@
1
- import { SERIES_SLOT_COUNT, seriesRole } from './series-palette';
2
- import type { ChartTheme } from '../../../types';
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
-
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.
13
- const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
14
- [
15
- [ '--a8c-charts-color-background', theme => theme.backgroundColor ],
16
- [ '--a8c-charts-color-grid', theme => theme.gridStyles?.stroke ],
17
- [ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
18
- [ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
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
- ),
29
- ];
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
-
51
- /**
52
- * The variable a `theme` prop override is published as, one layer outside the role itself.
53
- *
54
- * `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.
55
- *
56
- * @param role - The catalog role being overridden.
57
- * @return The custom property carrying the consumer's value.
58
- */
59
- export const themeLayerVar = ( role: string ): string => `${ role }-theme`;
60
-
61
- /** Every role whose catalog entry must read a theme layer. `src/styles/test/chart-scope.test.ts` checks the stylesheet against this. */
62
- export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ role ] ) => role );
63
-
64
- /**
65
- * Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
66
- *
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`.
68
- *
69
- * Three things this has to get right, none of which a plain substring test gets:
70
- *
71
- * `var()` permits whitespace after the opening paren, so testing for `var(<role>` misses `var( --a8c-charts-color-grid, … )`.
72
- *
73
- * 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.
74
- *
75
- * `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.
76
- *
77
- * @param value - The consumer's value for the field.
78
- * @param role - The catalog role this value would override.
79
- * @return True when the value reads `role`, so it must not be published.
80
- */
81
- const readsOwnRole = ( value: string, role: string ): boolean =>
82
- value.trim() === role ||
83
- new RegExp( `var\\(\\s*${ role.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) }(?![\\w-])` ).test(
84
- value
85
- );
86
-
87
- export type ThemeOverrides = {
88
- /** The custom properties to write on the provider wrapper. */
89
- vars: Record< string, string >;
90
- /** Every mapped role the consumer set a value for, whether or not it was published. */
91
- roles: readonly string[];
92
- };
93
-
94
- /**
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.
96
- *
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.
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
- *
101
- * @param theme - The consumer's `theme` prop, before merging with the default theme.
102
- * @return The wrapper's custom properties, and the roles the consumer overrode.
103
- */
104
- export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrides => {
105
- if ( ! theme ) {
106
- return { vars: {}, roles: [] };
107
- }
108
-
109
- if ( Array.isArray( theme.colors ) && theme.colors.length > SERIES_SLOT_COUNT ) {
110
- warnOnceAboutExtraColors( theme.colors.length );
111
- }
112
-
113
- const vars: Record< string, string > = {};
114
- const roles: string[] = [];
115
-
116
- for ( const [ role, read ] of ROLE_FOR_FIELD ) {
117
- const value = read( theme );
118
-
119
- if ( typeof value !== 'string' || value === '' ) {
120
- continue;
121
- }
122
-
123
- roles.push( role );
124
-
125
- if ( ! readsOwnRole( value, role ) ) {
126
- vars[ themeLayerVar( role ) ] = value;
127
- }
128
- }
129
-
130
- return { vars, roles };
131
- };
@@ -1,93 +0,0 @@
1
- import { defaultTheme } from '../themes';
2
- import { SERIES_PALETTE_POINTERS, SERIES_SLOT_COUNT, seriesRole } from './series-palette';
3
- import type { CompleteChartTheme } from '../../../types';
4
-
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.
33
- const CATALOG_RESTORE_FOR_ROLE: Record<
34
- string,
35
- ( theme: CompleteChartTheme ) => Partial< CompleteChartTheme >
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
- ),
48
- '--a8c-charts-color-background': () => ( { backgroundColor: defaultTheme.backgroundColor } ),
49
- '--a8c-charts-color-grid': theme => ( {
50
- gridStyles: { ...theme.gridStyles, stroke: defaultTheme.gridStyles.stroke },
51
- } ),
52
- '--a8c-charts-color-axis': theme => ( {
53
- xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: defaultTheme.xAxisLineStyles.stroke },
54
- } ),
55
- '--a8c-charts-color-tick': theme => ( {
56
- xTickLineStyles: { ...theme.xTickLineStyles, stroke: defaultTheme.xTickLineStyles.stroke },
57
- } ),
58
- '--a8c-charts-color-label-axis': theme => ( {
59
- svgLabelSmall: { ...theme.svgLabelSmall, fill: defaultTheme.svgLabelSmall.fill },
60
- } ),
61
- '--a8c-charts-color-label-background': () => ( {
62
- labelBackgroundColor: defaultTheme.labelBackgroundColor,
63
- } ),
64
- '--a8c-charts-color-label-on-fill': () => ( { labelTextColor: defaultTheme.labelTextColor } ),
65
- };
66
-
67
- /**
68
- * 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.
69
- *
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`.
71
- *
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.
73
- *
74
- * @param merged - The consumer theme merged onto `defaultTheme` (`mergeThemes` output).
75
- * @param overriddenRoles - The catalog roles the consumer overrode, from `themeOverrideVars`.
76
- * @return A new theme object; `merged` is not mutated.
77
- */
78
- export const withCatalogPointers = (
79
- merged: CompleteChartTheme,
80
- overriddenRoles: readonly string[]
81
- ): CompleteChartTheme => {
82
- let result = merged;
83
-
84
- for ( const role of overriddenRoles ) {
85
- const restore = CATALOG_RESTORE_FOR_ROLE[ role ];
86
-
87
- if ( restore ) {
88
- result = { ...result, ...restore( result ) };
89
- }
90
- }
91
-
92
- return result;
93
- };
@@ -1,29 +0,0 @@
1
- import { render, screen } from '@testing-library/react';
2
- import { GlobalChartsProvider } from '../global-charts-provider';
3
-
4
- // The stock defaultTheme's mapped fields are themselves self-referential pointers (e.g. `gridStyles.stroke: 'var(--a8c-charts-color-grid, #dbdbdb)'`), so themeOverrideVars' own self-reference guard already filters them out no matter which theme object the provider passes in. That makes "reads theme, not providerTheme" unfalsifiable against the real default theme. Mocking the default here to a plain, non-pointer value removes that overlap, so this test only passes when the provider reads the raw consumer `theme` prop rather than the merged `providerTheme`.
5
- jest.mock( '../themes', () => {
6
- const actual = jest.requireActual( '../themes' );
7
- return {
8
- ...actual,
9
- defaultTheme: {
10
- ...actual.defaultTheme,
11
- gridStyles: { ...actual.defaultTheme.gridStyles, stroke: '#dbdbdb' },
12
- },
13
- };
14
- } );
15
-
16
- describe( 'GlobalChartsProvider wrapper style (discriminating guard test)', () => {
17
- it( 'does not emit an instance override for a role the consumer left unset, even though the merged theme carries a concrete default value', () => {
18
- render(
19
- <GlobalChartsProvider theme={ { tickLength: 8 } }>
20
- <div>child</div>
21
- </GlobalChartsProvider>
22
- );
23
-
24
- const wrapper = screen.getByTestId( 'charts-scope' );
25
-
26
- expect( wrapper.style.getPropertyValue( '--a8c-charts-color-grid-theme' ) ).toBe( '' );
27
- expect( wrapper.style.getPropertyValue( '--a8c-charts-color-grid' ) ).toBe( '' );
28
- } );
29
- } );
@@ -1,183 +0,0 @@
1
- import {
2
- THEME_LAYERED_ROLES,
3
- themeLayerVar,
4
- themeOverrideVars,
5
- } from '../private/theme-override-vars';
6
-
7
- describe( 'themeOverrideVars', () => {
8
- it( 'returns nothing for an absent theme', () => {
9
- expect( themeOverrideVars( undefined ) ).toEqual( { vars: {}, roles: [] } );
10
- } );
11
-
12
- it( 'returns nothing for a theme that overrides no mapped role', () => {
13
- expect( themeOverrideVars( { tickLength: 8 } ) ).toEqual( { vars: {}, roles: [] } );
14
- } );
15
-
16
- // The value is published one layer outside the role — `chart-scope.scss` declares the role as `var(<role>-theme, <catalog default>)`. Published as the role itself, a value that is invalid at computed-value time (`var(--wpds-token)` with the token unset) would make the role invalid too, and every bare `var(--a8c-charts-color-grid)` read site would paint `unset` instead of falling back to the catalog default.
17
- it( 'maps a grid stroke override onto the grid role’s theme layer', () => {
18
- expect( themeOverrideVars( { gridStyles: { stroke: 'red' } } ) ).toEqual( {
19
- vars: { '--a8c-charts-color-grid-theme': 'red' },
20
- roles: [ '--a8c-charts-color-grid' ],
21
- } );
22
- } );
23
-
24
- it( 'maps every supported role at once', () => {
25
- expect(
26
- themeOverrideVars( {
27
- backgroundColor: '#111',
28
- gridStyles: { stroke: '#222' },
29
- xAxisLineStyles: { stroke: '#333' },
30
- xTickLineStyles: { stroke: '#444' },
31
- svgLabelSmall: { fill: '#555' },
32
- labelBackgroundColor: '#556',
33
- labelTextColor: '#557',
34
- colors: [ '#661', '#662', '#663', '#664', '#665' ],
35
- } )
36
- ).toEqual( {
37
- vars: {
38
- '--a8c-charts-color-background-theme': '#111',
39
- '--a8c-charts-color-grid-theme': '#222',
40
- '--a8c-charts-color-axis-theme': '#333',
41
- '--a8c-charts-color-tick-theme': '#444',
42
- '--a8c-charts-color-label-axis-theme': '#555',
43
- '--a8c-charts-color-label-background-theme': '#556',
44
- '--a8c-charts-color-label-on-fill-theme': '#557',
45
- '--a8c-charts-color-series-1-theme': '#661',
46
- '--a8c-charts-color-series-2-theme': '#662',
47
- '--a8c-charts-color-series-3-theme': '#663',
48
- '--a8c-charts-color-series-4-theme': '#664',
49
- '--a8c-charts-color-series-5-theme': '#665',
50
- },
51
- roles: [
52
- '--a8c-charts-color-background',
53
- '--a8c-charts-color-grid',
54
- '--a8c-charts-color-axis',
55
- '--a8c-charts-color-tick',
56
- '--a8c-charts-color-label-axis',
57
- '--a8c-charts-color-label-background',
58
- '--a8c-charts-color-label-on-fill',
59
- '--a8c-charts-color-series-1',
60
- '--a8c-charts-color-series-2',
61
- '--a8c-charts-color-series-3',
62
- '--a8c-charts-color-series-4',
63
- '--a8c-charts-color-series-5',
64
- ],
65
- } );
66
- } );
67
-
68
- it( 'publishes only theme-layer variables, never a catalog role directly', () => {
69
- const { vars } = themeOverrideVars( {
70
- backgroundColor: '#111',
71
- gridStyles: { stroke: '#222' },
72
- xAxisLineStyles: { stroke: '#333' },
73
- xTickLineStyles: { stroke: '#444' },
74
- svgLabelSmall: { fill: '#555' },
75
- labelBackgroundColor: '#556',
76
- labelTextColor: '#557',
77
- colors: [ '#661', '#662', '#663', '#664', '#665' ],
78
- } );
79
-
80
- expect( Object.keys( vars ) ).toEqual( THEME_LAYERED_ROLES.map( themeLayerVar ) );
81
- } );
82
-
83
- // The narrow role is the whole point: the broad `--a8c-charts-color-label` is also read by legend labels, heatmap cell values, funnel labels and the line-chart tooltip, none of which `svgLabelSmall.fill` moved before the role was published as a custom property.
84
- it( 'publishes an svg label override as the narrow axis-label role, not the broad one', () => {
85
- expect( themeOverrideVars( { svgLabelSmall: { fill: 'red' } } ) ).toEqual( {
86
- vars: { '--a8c-charts-color-label-axis-theme': 'red' },
87
- roles: [ '--a8c-charts-color-label-axis' ],
88
- } );
89
- } );
90
-
91
- // A short array is not padded: the later slots stay unset, so the palette compacts rather than repeating a color the consumer never chose.
92
- it( 'publishes one palette slot per color and leaves the rest unset', () => {
93
- expect( themeOverrideVars( { colors: [ 'red', 'blue' ] } ) ).toEqual( {
94
- vars: {
95
- '--a8c-charts-color-series-1-theme': 'red',
96
- '--a8c-charts-color-series-2-theme': 'blue',
97
- },
98
- roles: [ '--a8c-charts-color-series-1', '--a8c-charts-color-series-2' ],
99
- } );
100
- } );
101
-
102
- it( 'drops palette entries past the last slot', () => {
103
- // Spied rather than asserted on: an over-long palette warns, and `@wordpress/jest-console` fails any test that leaves a console call unclaimed. The warning itself is covered below.
104
- const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
105
- const { vars } = themeOverrideVars( {
106
- colors: [ 'a', 'b', 'c', 'd', 'e', 'f', 'g' ],
107
- } );
108
-
109
- expect( vars[ '--a8c-charts-color-series-5-theme' ] ).toBe( 'e' );
110
- expect( vars[ '--a8c-charts-color-series-6-theme' ] ).toBeUndefined();
111
-
112
- warn.mockRestore();
113
- } );
114
-
115
- // Isolated so the warning's module-level latch starts unset — the assertion is that it warns once per module instance, not once per call.
116
- it( 'warns once about the dropped entries, not once per render', () => {
117
- jest.isolateModules( () => {
118
- const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
119
- const {
120
- themeOverrideVars: freshThemeOverrideVars,
121
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- isolateModules needs a synchronous require to get a module instance whose latch is unset.
122
- } = require( '../private/theme-override-vars' ) as {
123
- themeOverrideVars: typeof themeOverrideVars;
124
- };
125
-
126
- freshThemeOverrideVars( { colors: [ 'a', 'b', 'c', 'd', 'e', 'f' ] } );
127
- freshThemeOverrideVars( { colors: [ 'a', 'b', 'c', 'd', 'e', 'f' ] } );
128
-
129
- expect( warn ).toHaveBeenCalledTimes( 1 );
130
- expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'holds 6 colors' ) );
131
-
132
- warn.mockRestore();
133
- } );
134
- } );
135
-
136
- it( 'ignores a value that is itself a pointer at the role it would override', () => {
137
- expect(
138
- themeOverrideVars( { gridStyles: { stroke: 'var(--a8c-charts-color-grid, #dbdbdb)' } } ).vars
139
- ).toEqual( {} );
140
- } );
141
-
142
- // A skipped value still reports its role, so `withCatalogPointers` restores the theme field. Without that, CSS would paint the catalog default while visx painted the consumer's literal — the divergence this whole mechanism exists to remove.
143
- it( 'still reports the role of a value it refuses to publish', () => {
144
- expect(
145
- themeOverrideVars( {
146
- gridStyles: { stroke: 'var(--brand, var(--a8c-charts-color-grid, red))' },
147
- } )
148
- ).toEqual( { vars: {}, roles: [ '--a8c-charts-color-grid' ] } );
149
- } );
150
-
151
- // `var()` allows whitespace after the opening paren. The role reads its theme layer, so a value naming the role closes a cycle through the catalog entry: CSS marks every custom property in a cycle invalid at computed-value time, and the role's own fallback is not used, so the token resolves to nothing at all.
152
- it( 'ignores a self-referential pointer padded with whitespace', () => {
153
- expect(
154
- themeOverrideVars( { gridStyles: { stroke: 'var( --a8c-charts-color-grid, #dbdbdb )' } } )
155
- .vars
156
- ).toEqual( {} );
157
- } );
158
-
159
- it( 'still emits a value that merely mentions a different role', () => {
160
- expect(
161
- themeOverrideVars( { gridStyles: { stroke: 'var( --a8c-charts-color-axis, #dbdbdb )' } } )
162
- .vars
163
- ).toEqual( {
164
- '--a8c-charts-color-grid-theme': 'var( --a8c-charts-color-axis, #dbdbdb )',
165
- } );
166
- } );
167
-
168
- // A mapped role can be a strict prefix of another custom property — a catalog role such as `--a8c-charts-color-label` of `--a8c-charts-color-label-axis`, the role's own theme layer, or a name the consumer defined themselves. A prefix match would read a legitimate cross-property pointer as a self-reference and silently drop the override, so the role must be followed by something that cannot continue an identifier.
169
- it( 'does not mistake a longer property name for the role it guards', () => {
170
- expect(
171
- themeOverrideVars( {
172
- xAxisLineStyles: { stroke: 'var(--a8c-charts-color-axis-emphasis, #000)' },
173
- } ).vars
174
- ).toEqual( { '--a8c-charts-color-axis-theme': 'var(--a8c-charts-color-axis-emphasis, #000)' } );
175
- } );
176
-
177
- // `resolveCssVariable` also accepts a bare custom-property name with no `var()` wrapper, so `theme={ { gridStyles: { stroke: '--a8c-charts-color-grid' } } }` is legal input. It forms no cycle, so it must be caught explicitly or it survives as a literal string value and drops silently at the use site.
178
- it( 'ignores a self-referential pointer written as a bare custom-property name', () => {
179
- expect(
180
- themeOverrideVars( { gridStyles: { stroke: '--a8c-charts-color-grid' } } ).vars
181
- ).toEqual( {} );
182
- } );
183
- } );
@@ -1,104 +0,0 @@
1
- import { mergeThemes } from '../../../utils';
2
- import { withCatalogPointers } from '../private/with-catalog-pointers';
3
- import { defaultTheme } from '../themes';
4
- import type { ChartTheme } from '../../../types';
5
-
6
- describe( 'withCatalogPointers', () => {
7
- it( 'restores every overridden role to its catalog pointer, without mutating the input theme', () => {
8
- const merged = mergeThemes( defaultTheme, {
9
- backgroundColor: '#111',
10
- gridStyles: { stroke: '#222' },
11
- xAxisLineStyles: { stroke: '#333' },
12
- xTickLineStyles: { stroke: '#444' },
13
- svgLabelSmall: { fill: '#555' },
14
- labelBackgroundColor: '#556',
15
- labelTextColor: '#557',
16
- } as Partial< ChartTheme > );
17
-
18
- const result = withCatalogPointers( merged, [
19
- '--a8c-charts-color-background',
20
- '--a8c-charts-color-grid',
21
- '--a8c-charts-color-axis',
22
- '--a8c-charts-color-tick',
23
- '--a8c-charts-color-label-axis',
24
- '--a8c-charts-color-label-background',
25
- '--a8c-charts-color-label-on-fill',
26
- ] );
27
-
28
- expect( result.backgroundColor ).toBe( defaultTheme.backgroundColor );
29
- expect( result.gridStyles.stroke ).toBe( defaultTheme.gridStyles.stroke );
30
- expect( result.xAxisLineStyles.stroke ).toBe( defaultTheme.xAxisLineStyles.stroke );
31
- expect( result.xTickLineStyles.stroke ).toBe( defaultTheme.xTickLineStyles.stroke );
32
- expect( result.svgLabelSmall.fill ).toBe( defaultTheme.svgLabelSmall.fill );
33
- expect( result.labelBackgroundColor ).toBe( defaultTheme.labelBackgroundColor );
34
- expect( result.labelTextColor ).toBe( defaultTheme.labelTextColor );
35
-
36
- expect( merged.backgroundColor ).toBe( '#111' );
37
- expect( merged.gridStyles.stroke ).toBe( '#222' );
38
- } );
39
-
40
- it( 'leaves fields alone when no role was overridden', () => {
41
- const merged = mergeThemes( defaultTheme, {
42
- tickLength: 8,
43
- leaderboardChart: { deltaColors: [ 'red', 'grey', 'green' ] },
44
- } as Partial< ChartTheme > );
45
-
46
- const result = withCatalogPointers( merged, [] );
47
-
48
- expect( result.tickLength ).toBe( 8 );
49
- expect( result.gridStyles.stroke ).toBe( defaultTheme.gridStyles.stroke );
50
- // Not a mapped role, so its literal survives even when other roles are being restored.
51
- expect( result.leaderboardChart.deltaColors ).toEqual( [ 'red', 'grey', 'green' ] );
52
- } );
53
-
54
- // The palette is restored as a whole, so a consumer's short array cannot shorten it. `mergeThemes` replaces arrays outright, and a two-entry palette would leave slots 3 to 5 unreadable — a CSS declaration of one of them would have no entry to resolve through.
55
- it( 'restores the full five-slot palette from a shorter consumer array', () => {
56
- const merged = mergeThemes( defaultTheme, {
57
- colors: [ '#111', '#222' ],
58
- } as Partial< ChartTheme > );
59
-
60
- const result = withCatalogPointers( merged, [
61
- '--a8c-charts-color-series-1',
62
- '--a8c-charts-color-series-2',
63
- ] );
64
-
65
- expect( result.colors ).toEqual( [
66
- 'var(--a8c-charts-color-series-1, #111)',
67
- 'var(--a8c-charts-color-series-2, #222)',
68
- 'var(--a8c-charts-color-series-3)',
69
- 'var(--a8c-charts-color-series-4)',
70
- 'var(--a8c-charts-color-series-5)',
71
- ] );
72
- } );
73
-
74
- // Each slot's restore rewrites the whole array, so restoring five of them must not wrap the pointers five times over.
75
- it( 'restores the palette idempotently across every overridden slot', () => {
76
- const merged = mergeThemes( defaultTheme, {
77
- colors: [ '#111', '#222', '#333', '#444', '#555' ],
78
- } as Partial< ChartTheme > );
79
-
80
- const result = withCatalogPointers( merged, [
81
- '--a8c-charts-color-series-1',
82
- '--a8c-charts-color-series-2',
83
- '--a8c-charts-color-series-3',
84
- '--a8c-charts-color-series-4',
85
- '--a8c-charts-color-series-5',
86
- ] );
87
-
88
- expect( result.colors ).toEqual( [
89
- 'var(--a8c-charts-color-series-1, #111)',
90
- 'var(--a8c-charts-color-series-2, #222)',
91
- 'var(--a8c-charts-color-series-3, #333)',
92
- 'var(--a8c-charts-color-series-4, #444)',
93
- 'var(--a8c-charts-color-series-5, #555)',
94
- ] );
95
- } );
96
-
97
- // The literal is only reached where getComputedStyle resolves nothing, and the palette is the one field where falling back to the catalog default would be visible: every series would paint the same seeded blue.
98
- it( 'keeps the consumer color as each pointer’s terminal literal, for SSR and jsdom', () => {
99
- const merged = mergeThemes( defaultTheme, { colors: [ 'red' ] } as Partial< ChartTheme > );
100
- const result = withCatalogPointers( merged, [ '--a8c-charts-color-series-1' ] );
101
-
102
- expect( result.colors[ 0 ] ).toBe( 'var(--a8c-charts-color-series-1, red)' );
103
- } );
104
- } );