@automattic/charts 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/TOKENS.md +107 -48
- package/dist/index.cjs +294 -110
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +97 -43
- package/dist/index.d.cts +40 -35
- package/dist/index.d.ts +40 -35
- package/dist/index.js +292 -111
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/area-chart/area-chart.module.scss +8 -3
- package/src/charts/area-chart/area-chart.tsx +1 -1
- package/src/charts/bar-chart/bar-chart.module.scss +14 -6
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +17 -12
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +107 -86
- package/src/charts/conversion-funnel-chart/test/chart-scope.test.tsx +36 -0
- package/src/charts/geo-chart/geo-chart.tsx +12 -4
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +13 -9
- package/src/charts/heatmap-chart/heatmap-chart.tsx +12 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +13 -7
- package/src/charts/leaderboard-chart/types.ts +0 -2
- package/src/charts/line-chart/line-chart.module.scss +10 -5
- package/src/charts/line-chart/line-chart.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-annotation.tsx +4 -4
- package/src/charts/pie-chart/pie-chart.tsx +4 -1
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +4 -1
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +16 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +18 -11
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/components/legend/private/base-legend.tsx +3 -1
- package/src/components/tooltip/accessible-tooltip.tsx +4 -2
- package/src/components/tooltip/base-tooltip.tsx +8 -1
- package/src/components/trend-indicator/test/trend-indicator.test.tsx +20 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +3 -6
- package/src/components/trend-indicator/trend-indicator.tsx +3 -0
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +28 -0
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +19 -13
- package/src/hooks/test/use-xychart-theme.test.tsx +25 -0
- package/src/hooks/use-xychart-theme.ts +6 -4
- package/src/index.ts +9 -2
- package/src/providers/chart-context/global-charts-provider.tsx +35 -12
- package/src/providers/chart-context/private/theme-override-vars.ts +87 -0
- package/src/providers/chart-context/private/with-catalog-pointers.ts +50 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -40
- package/src/providers/chart-context/test/chart-scope-class.test.tsx +19 -0
- package/src/providers/chart-context/test/sparse-theme.test.ts +35 -0
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +29 -0
- package/src/providers/chart-context/test/theme-override-vars.test.ts +118 -0
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +47 -0
- package/src/providers/chart-context/themes.ts +18 -27
- package/src/providers/chart-context/types.ts +0 -3
- package/src/providers/chart-scope/chart-scope-context.ts +8 -0
- package/src/providers/chart-scope/index.ts +3 -0
- package/src/providers/chart-scope/test/chart-scope.test.tsx +24 -0
- package/src/providers/chart-scope/test/use-standalone-scope-class.test.tsx +20 -0
- package/src/providers/chart-scope/use-chart-scope-element.ts +9 -0
- package/src/providers/chart-scope/use-standalone-scope-class.ts +13 -0
- package/src/providers/index.ts +1 -0
- package/src/styles/chart-scope-class.ts +6 -0
- package/src/styles/chart-scope.scss +83 -0
- package/src/styles/test/chart-scope.test.ts +165 -0
- package/src/types.ts +0 -34
- package/src/charts/private/grid-control/grid-control.module.scss +0 -8
- package/src/charts/private/grid-control/grid-control.tsx +0 -26
- package/src/charts/private/grid-control/index.ts +0 -1
- package/src/charts/private/grid-control/test/grid-control.test.tsx +0 -56
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ export {
|
|
|
32
32
|
useGlobalChartsTheme,
|
|
33
33
|
GlobalChartsContext,
|
|
34
34
|
defaultTheme,
|
|
35
|
+
useChartScopeElement,
|
|
36
|
+
useChartRegistration,
|
|
35
37
|
} from './providers';
|
|
36
38
|
|
|
37
39
|
// Types - explicit exports (excludes internal types like DataPointPercentageCalculated)
|
|
@@ -60,7 +62,6 @@ export type {
|
|
|
60
62
|
ChartLegendConfig,
|
|
61
63
|
SeriesChartLegendConfig,
|
|
62
64
|
BaseChartProps,
|
|
63
|
-
GridProps,
|
|
64
65
|
GoogleDataTableColumn,
|
|
65
66
|
GoogleDataTableColumnRoleType,
|
|
66
67
|
GoogleDataTableRow,
|
|
@@ -122,6 +123,12 @@ export type { ArcData } from './charts/pie-semi-circle-chart';
|
|
|
122
123
|
export type { SparklineProps, GradientConfig, SparklineDataPoint } from './charts/sparkline';
|
|
123
124
|
|
|
124
125
|
// Utilities
|
|
125
|
-
export {
|
|
126
|
+
export {
|
|
127
|
+
parseAsLocalDate,
|
|
128
|
+
formatMetricValue,
|
|
129
|
+
formatPercentage,
|
|
130
|
+
mergeThemes,
|
|
131
|
+
resolveCssVariable,
|
|
132
|
+
} from './utils';
|
|
126
133
|
export * from './utils/color-utils';
|
|
127
134
|
export type { MetricValueType } from './utils';
|
|
@@ -8,6 +8,11 @@ import {
|
|
|
8
8
|
useLayoutEffect,
|
|
9
9
|
useRef,
|
|
10
10
|
} from 'react';
|
|
11
|
+
// Side-effect import: emits the `--a8c-charts-*` catalog, scoped to the `a8c-charts-scope` class applied to this component's wrapper below. Every chart either sits under a `GlobalChartsProvider` or auto-mounts its own, so importing it here guarantees the catalog always reaches the bundle.
|
|
12
|
+
//
|
|
13
|
+
// This must be a direct import of the stylesheet, not through a re-export barrel: a barrel `.ts` file doesn't match this package's `sideEffects` glob (only `*.css`/`*.scss` do), so tsdown/Rolldown treats an unused barrel import as side-effect-free and drops it — taking the nested stylesheet import with it. The file also can't be named `*.module.scss`: it declares zero CSS-module class names (only a `:where()`-wrapped selector), and `@tsdown/css` marks a `.module.*` file's generated JS proxy `moduleSideEffects: false` (tree-shakeable unless a class name is read); a plain stylesheet gets `moduleSideEffects: "no-treeshake"` instead, so it always ships. `tools/assert-charts-scope-emitted.ts` fails `pnpm run build` if this regresses — a passing test suite alone does not catch a dropped stylesheet.
|
|
14
|
+
import '../../styles/chart-scope.scss';
|
|
15
|
+
import { CHART_SCOPE_CLASS } from '../../styles/chart-scope-class';
|
|
11
16
|
import {
|
|
12
17
|
getItemShapeStyles,
|
|
13
18
|
getSeriesBarStyles,
|
|
@@ -16,11 +21,15 @@ import {
|
|
|
16
21
|
resolveCssVariable,
|
|
17
22
|
normalizeColorToHex,
|
|
18
23
|
} from '../../utils';
|
|
24
|
+
// Imported from the module rather than the `chart-scope` barrel: the barrel also pulls `use-standalone-scope-class`, which imports `GlobalChartsContext` back from this file. That cycle resolves today only because the binding is read lazily inside the hook body.
|
|
25
|
+
import { ChartScopeContext } from '../chart-scope/chart-scope-context';
|
|
19
26
|
import { getChartColor, type ColorCache } from './private/get-chart-color';
|
|
27
|
+
import { themeOverrideVars } from './private/theme-override-vars';
|
|
28
|
+
import { withCatalogPointers } from './private/with-catalog-pointers';
|
|
20
29
|
import { defaultTheme } from './themes';
|
|
21
30
|
import type { GlobalChartsContextValue, ChartRegistration } from './types';
|
|
22
31
|
import type { ChartTheme, CompleteChartTheme } from '../../types';
|
|
23
|
-
import type { FC, ReactNode } from 'react';
|
|
32
|
+
import type { CSSProperties, FC, ReactNode } from 'react';
|
|
24
33
|
|
|
25
34
|
export const GlobalChartsContext = createContext< GlobalChartsContextValue | null >( null );
|
|
26
35
|
|
|
@@ -38,10 +47,26 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
38
47
|
|
|
39
48
|
// Ref to the wrapper element for resolving scoped CSS variables
|
|
40
49
|
const wrapperRef = useRef< HTMLDivElement >( null );
|
|
50
|
+
const [ scopeNode, setScopeNode ] = useState< HTMLElement | null >( null );
|
|
51
|
+
|
|
52
|
+
const setWrapperNode = useCallback( ( node: HTMLDivElement | null ) => {
|
|
53
|
+
wrapperRef.current = node;
|
|
54
|
+
setScopeNode( node );
|
|
55
|
+
}, [] );
|
|
56
|
+
|
|
57
|
+
// themeOverrideVars reads the raw `theme` prop, never `providerTheme` — feeding it the restored theme below would make an overridden role's pointer look like a self-reference and drop the var (see themeOverrideVars' own doc comment).
|
|
58
|
+
const { vars: overrideVars, roles: overriddenRoles } = useMemo(
|
|
59
|
+
() => themeOverrideVars( theme ),
|
|
60
|
+
[ theme ]
|
|
61
|
+
);
|
|
41
62
|
|
|
42
63
|
const providerTheme: CompleteChartTheme = useMemo( () => {
|
|
43
|
-
|
|
44
|
-
|
|
64
|
+
if ( ! theme ) {
|
|
65
|
+
return defaultTheme;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return withCatalogPointers( mergeThemes( defaultTheme, theme ), overriddenRoles );
|
|
69
|
+
}, [ theme, overriddenRoles ] );
|
|
45
70
|
|
|
46
71
|
// Cache expensive color computations that only change when theme colors change
|
|
47
72
|
// Using useState + useLayoutEffect instead of useMemo to ensure CSS variables
|
|
@@ -186,11 +211,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
186
211
|
[ colorCache, groupToColorMap ]
|
|
187
212
|
);
|
|
188
213
|
|
|
189
|
-
const resolveThemeColor = useCallback< GlobalChartsContextValue[ 'resolveThemeColor' ] >(
|
|
190
|
-
value => ( value ? normalizeColorToHex( value, wrapperRef.current, resolveCssVariable ) : '' ),
|
|
191
|
-
[]
|
|
192
|
-
);
|
|
193
|
-
|
|
194
214
|
const getElementStyles = useCallback< GlobalChartsContextValue[ 'getElementStyles' ] >(
|
|
195
215
|
( { data, index, overrideColor, legendShape } ) => {
|
|
196
216
|
const isSeriesData = data && typeof data === 'object' && 'data' in data && 'options' in data;
|
|
@@ -269,7 +289,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
269
289
|
getChartData,
|
|
270
290
|
theme: providerTheme,
|
|
271
291
|
getElementStyles,
|
|
272
|
-
resolveThemeColor,
|
|
273
292
|
toggleSeriesVisibility,
|
|
274
293
|
isSeriesVisible,
|
|
275
294
|
getHiddenSeries,
|
|
@@ -282,7 +301,6 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
282
301
|
getChartData,
|
|
283
302
|
providerTheme,
|
|
284
303
|
getElementStyles,
|
|
285
|
-
resolveThemeColor,
|
|
286
304
|
toggleSeriesVisibility,
|
|
287
305
|
isSeriesVisible,
|
|
288
306
|
getHiddenSeries,
|
|
@@ -292,8 +310,13 @@ export const GlobalChartsProvider: FC< GlobalChartsProviderProps > = ( { childre
|
|
|
292
310
|
|
|
293
311
|
return (
|
|
294
312
|
<GlobalChartsContext.Provider value={ value }>
|
|
295
|
-
<div
|
|
296
|
-
{
|
|
313
|
+
<div
|
|
314
|
+
ref={ setWrapperNode }
|
|
315
|
+
className={ CHART_SCOPE_CLASS }
|
|
316
|
+
data-testid="charts-scope"
|
|
317
|
+
style={ { display: 'contents', ...overrideVars } as CSSProperties }
|
|
318
|
+
>
|
|
319
|
+
<ChartScopeContext.Provider value={ scopeNode }>{ children }</ChartScopeContext.Provider>
|
|
297
320
|
</div>
|
|
298
321
|
</GlobalChartsContext.Provider>
|
|
299
322
|
);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ChartTheme } from '../../../types';
|
|
2
|
+
|
|
3
|
+
// The consumer theme fields that correspond to an emitted catalog role, and the role each one overrides. Each role must be read by exactly the elements its field controlled before the role existed: `svgLabelSmall.fill` maps to the narrow `--a8c-charts-color-label-axis` rather than `--a8c-charts-color-label`, because the broad role is also read by legend labels, heatmap cell values, funnel labels and the line-chart tooltip, which that field never moved.
|
|
4
|
+
const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
|
|
5
|
+
[
|
|
6
|
+
[ '--a8c-charts-color-background', theme => theme.backgroundColor ],
|
|
7
|
+
[ '--a8c-charts-color-grid', theme => theme.gridStyles?.stroke ],
|
|
8
|
+
[ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
|
|
9
|
+
[ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
|
|
10
|
+
[ '--a8c-charts-color-label-axis', theme => theme.svgLabelSmall?.fill ],
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The variable a `theme` prop override is published as, one layer outside the role itself.
|
|
15
|
+
*
|
|
16
|
+
* `chart-scope.scss` declares each of these roles as `var(<role>-theme, <catalog default>)`, so an override that is invalid at computed-value time — a fallback-less `var()` naming a token the host never set — only invalidates this variable, and the role still resolves its mapped `--wpds-*` token. Published as the role directly, such a value took the role and every bare `var(<role>)` read site down with it.
|
|
17
|
+
*
|
|
18
|
+
* @param role - The catalog role being overridden.
|
|
19
|
+
* @return The custom property carrying the consumer's value.
|
|
20
|
+
*/
|
|
21
|
+
export const themeLayerVar = ( role: string ): string => `${ role }-theme`;
|
|
22
|
+
|
|
23
|
+
/** Every role whose catalog entry must read a theme layer. `src/styles/test/chart-scope.test.ts` checks the stylesheet against this. */
|
|
24
|
+
export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ role ] ) => role );
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
|
|
28
|
+
*
|
|
29
|
+
* The dependency runs `<role>: var(<role>-theme, …)`, so a value naming `<role>` closes a cycle through the catalog entry. CSS marks every custom property in a cycle invalid at computed-value time — the role's own fallback is *not* used, so the token resolves to nothing and every chart loses that colour. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
|
|
30
|
+
*
|
|
31
|
+
* Three things this has to get right, none of which a plain substring test gets:
|
|
32
|
+
*
|
|
33
|
+
* `var()` permits whitespace after the opening paren, so testing for `var(<role>` misses `var( --a8c-charts-color-grid, … )`.
|
|
34
|
+
*
|
|
35
|
+
* Several roles are prefixes of others (`--a8c-charts-color-label` of `--a8c-charts-color-label-secondary`), and every role is a prefix of its own theme layer. A prefix match would read a legitimate cross-role pointer as a self-reference and silently drop the override, so the role must be followed by something that cannot continue an identifier — `-` included. A value naming `<role>-theme` is therefore not treated as a self-reference: that cycle is confined to the theme layer, which leaves the role free to fall back to the catalog default.
|
|
36
|
+
*
|
|
37
|
+
* `resolveCssVariable` also accepts a bare custom-property name with no `var()` wrapper (`--a8c-charts-color-grid`), so that is legal `theme` input too. It forms no cycle — custom properties accept arbitrary token streams — so it survives as a literal string and drops silently at the use site instead of falling back to the catalog default.
|
|
38
|
+
*
|
|
39
|
+
* @param value - The consumer's value for the field.
|
|
40
|
+
* @param role - The catalog role this value would override.
|
|
41
|
+
* @return True when the value reads `role`, so it must not be published.
|
|
42
|
+
*/
|
|
43
|
+
const readsOwnRole = ( value: string, role: string ): boolean =>
|
|
44
|
+
value.trim() === role ||
|
|
45
|
+
new RegExp( `var\\(\\s*${ role.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ) }(?![\\w-])` ).test(
|
|
46
|
+
value
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
export type ThemeOverrides = {
|
|
50
|
+
/** The custom properties to write on the provider wrapper. */
|
|
51
|
+
vars: Record< string, string >;
|
|
52
|
+
/** Every mapped role the consumer set a value for, whether or not it was published. */
|
|
53
|
+
roles: readonly string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colours read one source.
|
|
58
|
+
*
|
|
59
|
+
* A value that reads the role it would override is left unpublished — that is the default theme's own pointer surviving `mergeThemes`, and publishing it would invalidate the role. The role is still reported in `roles`: `withCatalogPointers` restores its theme field to the catalog pointer either way, so CSS and visx agree on the catalog default rather than visx painting a literal CSS never sees.
|
|
60
|
+
*
|
|
61
|
+
* @param theme - The consumer's `theme` prop, before merging with the default theme.
|
|
62
|
+
* @return The wrapper's custom properties, and the roles the consumer overrode.
|
|
63
|
+
*/
|
|
64
|
+
export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrides => {
|
|
65
|
+
if ( ! theme ) {
|
|
66
|
+
return { vars: {}, roles: [] };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const vars: Record< string, string > = {};
|
|
70
|
+
const roles: string[] = [];
|
|
71
|
+
|
|
72
|
+
for ( const [ role, read ] of ROLE_FOR_FIELD ) {
|
|
73
|
+
const value = read( theme );
|
|
74
|
+
|
|
75
|
+
if ( typeof value !== 'string' || value === '' ) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
roles.push( role );
|
|
80
|
+
|
|
81
|
+
if ( ! readsOwnRole( value, role ) ) {
|
|
82
|
+
vars[ themeLayerVar( role ) ] = value;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { vars, roles };
|
|
87
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defaultTheme } from '../themes';
|
|
2
|
+
import type { CompleteChartTheme } from '../../../types';
|
|
3
|
+
|
|
4
|
+
// Restores each overridden role's mapped theme field to the catalog pointer `defaultTheme` already carries for it, so the value comes from `themes.ts` itself rather than a second, hand-copied literal.
|
|
5
|
+
const CATALOG_RESTORE_FOR_ROLE: Record<
|
|
6
|
+
string,
|
|
7
|
+
( theme: CompleteChartTheme ) => Partial< CompleteChartTheme >
|
|
8
|
+
> = {
|
|
9
|
+
'--a8c-charts-color-background': () => ( { backgroundColor: defaultTheme.backgroundColor } ),
|
|
10
|
+
'--a8c-charts-color-grid': theme => ( {
|
|
11
|
+
gridStyles: { ...theme.gridStyles, stroke: defaultTheme.gridStyles.stroke },
|
|
12
|
+
} ),
|
|
13
|
+
'--a8c-charts-color-axis': theme => ( {
|
|
14
|
+
xAxisLineStyles: { ...theme.xAxisLineStyles, stroke: defaultTheme.xAxisLineStyles.stroke },
|
|
15
|
+
} ),
|
|
16
|
+
'--a8c-charts-color-tick': theme => ( {
|
|
17
|
+
xTickLineStyles: { ...theme.xTickLineStyles, stroke: defaultTheme.xTickLineStyles.stroke },
|
|
18
|
+
} ),
|
|
19
|
+
'--a8c-charts-color-label-axis': theme => ( {
|
|
20
|
+
svgLabelSmall: { ...theme.svgLabelSmall, fill: defaultTheme.svgLabelSmall.fill },
|
|
21
|
+
} ),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Restores the mapped theme fields of `overriddenRoles` to their catalog pointer, so the theme-layer variable `themeOverrideVars` writes on the provider wrapper is the only carrier for an overridden role — CSS and the JS bridge (`useXYChartTheme`) then resolve it through the same cascade instead of visx reading a baked literal that can disagree with a closer CSS override.
|
|
26
|
+
*
|
|
27
|
+
* This covers roles `themeOverrideVars` deliberately left unpublished as well as the ones it published. A value that reads its own role is not publishable, but leaving visx the consumer's literal is what the whole mechanism exists to prevent: `theme={ { gridStyles: { stroke: 'var(--brand, var(--a8c-charts-color-grid, red))' } } }` would have CSS paint the catalog default while visx painted `--brand`.
|
|
28
|
+
*
|
|
29
|
+
* Every field outside the five mapped roles is left exactly as `merged` provided it: those aren't published as theme-layer vars, so rewriting them would erase a consumer's override with no replacement carrier.
|
|
30
|
+
*
|
|
31
|
+
* @param merged - The consumer theme merged onto `defaultTheme` (`mergeThemes` output).
|
|
32
|
+
* @param overriddenRoles - The catalog roles the consumer overrode, from `themeOverrideVars`.
|
|
33
|
+
* @return A new theme object; `merged` is not mutated.
|
|
34
|
+
*/
|
|
35
|
+
export const withCatalogPointers = (
|
|
36
|
+
merged: CompleteChartTheme,
|
|
37
|
+
overriddenRoles: readonly string[]
|
|
38
|
+
): CompleteChartTheme => {
|
|
39
|
+
let result = merged;
|
|
40
|
+
|
|
41
|
+
for ( const role of overriddenRoles ) {
|
|
42
|
+
const restore = CATALOG_RESTORE_FOR_ROLE[ role ];
|
|
43
|
+
|
|
44
|
+
if ( restore ) {
|
|
45
|
+
result = { ...result, ...restore( result ) };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
@@ -2536,46 +2536,6 @@ describe( 'ChartContext', () => {
|
|
|
2536
2536
|
expect( afterRerenderColor ).toBe( '#ff0000' );
|
|
2537
2537
|
} );
|
|
2538
2538
|
} );
|
|
2539
|
-
|
|
2540
|
-
describe( 'resolveThemeColor', () => {
|
|
2541
|
-
let contextValue: GlobalChartsContextValue;
|
|
2542
|
-
|
|
2543
|
-
const TestComponent = () => {
|
|
2544
|
-
contextValue = useGlobalChartsContext();
|
|
2545
|
-
return <div>Test</div>;
|
|
2546
|
-
};
|
|
2547
|
-
|
|
2548
|
-
const mountProvider = () =>
|
|
2549
|
-
render(
|
|
2550
|
-
<GlobalChartsProvider>
|
|
2551
|
-
<TestComponent />
|
|
2552
|
-
</GlobalChartsProvider>
|
|
2553
|
-
);
|
|
2554
|
-
|
|
2555
|
-
it( 'resolves a CSS variable against the provider scope, not the document root', () => {
|
|
2556
|
-
window.getComputedStyle = jest.fn( ( element: Element ) => ( {
|
|
2557
|
-
getPropertyValue: ( prop: string ) =>
|
|
2558
|
-
prop === '--surface' && element !== document.documentElement ? '#1e1e1e' : '#ffffff',
|
|
2559
|
-
} ) ) as unknown as typeof window.getComputedStyle;
|
|
2560
|
-
|
|
2561
|
-
mountProvider();
|
|
2562
|
-
|
|
2563
|
-
expect( contextValue.resolveThemeColor( 'var(--surface, #fff)' ) ).toBe( '#1e1e1e' );
|
|
2564
|
-
} );
|
|
2565
|
-
|
|
2566
|
-
it( 'passes a resolved color through to hex', () => {
|
|
2567
|
-
mountProvider();
|
|
2568
|
-
|
|
2569
|
-
expect( contextValue.resolveThemeColor( '#abcdef' ) ).toBe( '#abcdef' );
|
|
2570
|
-
expect( contextValue.resolveThemeColor( 'rgb(255, 0, 0)' ) ).toBe( '#ff0000' );
|
|
2571
|
-
} );
|
|
2572
|
-
|
|
2573
|
-
it( 'returns an empty string for an empty value', () => {
|
|
2574
|
-
mountProvider();
|
|
2575
|
-
|
|
2576
|
-
expect( contextValue.resolveThemeColor( '' ) ).toBe( '' );
|
|
2577
|
-
} );
|
|
2578
|
-
} );
|
|
2579
2539
|
} );
|
|
2580
2540
|
|
|
2581
2541
|
describe( 'defaultTheme', () => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { CHART_SCOPE_CLASS } from '../../../styles/chart-scope-class';
|
|
3
|
+
import { GlobalChartsProvider } from '../global-charts-provider';
|
|
4
|
+
|
|
5
|
+
describe( 'GlobalChartsProvider wrapper', () => {
|
|
6
|
+
it( 'carries the a8c-charts-scope class and nothing that affects layout', () => {
|
|
7
|
+
render(
|
|
8
|
+
<GlobalChartsProvider>
|
|
9
|
+
<div>child</div>
|
|
10
|
+
</GlobalChartsProvider>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const wrapper = screen.getByTestId( 'charts-scope' );
|
|
14
|
+
|
|
15
|
+
expect( wrapper ).toHaveClass( CHART_SCOPE_CLASS );
|
|
16
|
+
// The catalog needs an element to sit on; `display: contents` is what keeps that element out of the consumer's layout.
|
|
17
|
+
expect( wrapper.style.cssText.replace( /\s/g, '' ) ).toBe( 'display:contents;' );
|
|
18
|
+
} );
|
|
19
|
+
} );
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defaultTheme } from '../themes';
|
|
2
|
+
|
|
3
|
+
const flatten = ( value: unknown ): string[] => {
|
|
4
|
+
if ( typeof value === 'string' ) {
|
|
5
|
+
return [ value ];
|
|
6
|
+
}
|
|
7
|
+
if ( Array.isArray( value ) ) {
|
|
8
|
+
return value.flatMap( flatten );
|
|
9
|
+
}
|
|
10
|
+
if ( value && typeof value === 'object' ) {
|
|
11
|
+
return Object.values( value ).flatMap( flatten );
|
|
12
|
+
}
|
|
13
|
+
return [];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe( 'defaultTheme', () => {
|
|
17
|
+
const strings = flatten( defaultTheme );
|
|
18
|
+
|
|
19
|
+
it( 'holds no --wpds-* mapping — that lives only in the catalog stylesheet', () => {
|
|
20
|
+
expect( strings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
|
|
21
|
+
} );
|
|
22
|
+
|
|
23
|
+
it( 'points every themed colour at an --a8c-charts-* catalog role', () => {
|
|
24
|
+
const varStrings = strings.filter( value => value.startsWith( 'var(' ) );
|
|
25
|
+
|
|
26
|
+
expect( varStrings.length ).toBeGreaterThan( 0 );
|
|
27
|
+
expect( varStrings.every( value => value.startsWith( 'var(--a8c-charts-' ) ) ).toBe( true );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
it( 'gives every catalog pointer a terminal literal for the SSR and jsdom paths', () => {
|
|
31
|
+
const varStrings = strings.filter( value => value.startsWith( 'var(--a8c-charts-' ) );
|
|
32
|
+
|
|
33
|
+
expect( varStrings.every( value => value.includes( ',' ) ) ).toBe( true );
|
|
34
|
+
} );
|
|
35
|
+
} );
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
} );
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
} )
|
|
33
|
+
).toEqual( {
|
|
34
|
+
vars: {
|
|
35
|
+
'--a8c-charts-color-background-theme': '#111',
|
|
36
|
+
'--a8c-charts-color-grid-theme': '#222',
|
|
37
|
+
'--a8c-charts-color-axis-theme': '#333',
|
|
38
|
+
'--a8c-charts-color-tick-theme': '#444',
|
|
39
|
+
'--a8c-charts-color-label-axis-theme': '#555',
|
|
40
|
+
},
|
|
41
|
+
roles: [
|
|
42
|
+
'--a8c-charts-color-background',
|
|
43
|
+
'--a8c-charts-color-grid',
|
|
44
|
+
'--a8c-charts-color-axis',
|
|
45
|
+
'--a8c-charts-color-tick',
|
|
46
|
+
'--a8c-charts-color-label-axis',
|
|
47
|
+
],
|
|
48
|
+
} );
|
|
49
|
+
} );
|
|
50
|
+
|
|
51
|
+
it( 'publishes only theme-layer variables, never a catalog role directly', () => {
|
|
52
|
+
const { vars } = themeOverrideVars( {
|
|
53
|
+
backgroundColor: '#111',
|
|
54
|
+
gridStyles: { stroke: '#222' },
|
|
55
|
+
xAxisLineStyles: { stroke: '#333' },
|
|
56
|
+
xTickLineStyles: { stroke: '#444' },
|
|
57
|
+
svgLabelSmall: { fill: '#555' },
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
expect( Object.keys( vars ) ).toEqual( THEME_LAYERED_ROLES.map( themeLayerVar ) );
|
|
61
|
+
} );
|
|
62
|
+
|
|
63
|
+
// 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.
|
|
64
|
+
it( 'publishes an svg label override as the narrow axis-label role, not the broad one', () => {
|
|
65
|
+
expect( themeOverrideVars( { svgLabelSmall: { fill: 'red' } } ) ).toEqual( {
|
|
66
|
+
vars: { '--a8c-charts-color-label-axis-theme': 'red' },
|
|
67
|
+
roles: [ '--a8c-charts-color-label-axis' ],
|
|
68
|
+
} );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'ignores a value that is itself a pointer at the role it would override', () => {
|
|
72
|
+
expect(
|
|
73
|
+
themeOverrideVars( { gridStyles: { stroke: 'var(--a8c-charts-color-grid, #dbdbdb)' } } ).vars
|
|
74
|
+
).toEqual( {} );
|
|
75
|
+
} );
|
|
76
|
+
|
|
77
|
+
// 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.
|
|
78
|
+
it( 'still reports the role of a value it refuses to publish', () => {
|
|
79
|
+
expect(
|
|
80
|
+
themeOverrideVars( {
|
|
81
|
+
gridStyles: { stroke: 'var(--brand, var(--a8c-charts-color-grid, red))' },
|
|
82
|
+
} )
|
|
83
|
+
).toEqual( { vars: {}, roles: [ '--a8c-charts-color-grid' ] } );
|
|
84
|
+
} );
|
|
85
|
+
|
|
86
|
+
// `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.
|
|
87
|
+
it( 'ignores a self-referential pointer padded with whitespace', () => {
|
|
88
|
+
expect(
|
|
89
|
+
themeOverrideVars( { gridStyles: { stroke: 'var( --a8c-charts-color-grid, #dbdbdb )' } } )
|
|
90
|
+
.vars
|
|
91
|
+
).toEqual( {} );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'still emits a value that merely mentions a different role', () => {
|
|
95
|
+
expect(
|
|
96
|
+
themeOverrideVars( { gridStyles: { stroke: 'var( --a8c-charts-color-axis, #dbdbdb )' } } )
|
|
97
|
+
.vars
|
|
98
|
+
).toEqual( {
|
|
99
|
+
'--a8c-charts-color-grid-theme': 'var( --a8c-charts-color-axis, #dbdbdb )',
|
|
100
|
+
} );
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
// 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.
|
|
104
|
+
it( 'does not mistake a longer property name for the role it guards', () => {
|
|
105
|
+
expect(
|
|
106
|
+
themeOverrideVars( {
|
|
107
|
+
xAxisLineStyles: { stroke: 'var(--a8c-charts-color-axis-emphasis, #000)' },
|
|
108
|
+
} ).vars
|
|
109
|
+
).toEqual( { '--a8c-charts-color-axis-theme': 'var(--a8c-charts-color-axis-emphasis, #000)' } );
|
|
110
|
+
} );
|
|
111
|
+
|
|
112
|
+
// `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.
|
|
113
|
+
it( 'ignores a self-referential pointer written as a bare custom-property name', () => {
|
|
114
|
+
expect(
|
|
115
|
+
themeOverrideVars( { gridStyles: { stroke: '--a8c-charts-color-grid' } } ).vars
|
|
116
|
+
).toEqual( {} );
|
|
117
|
+
} );
|
|
118
|
+
} );
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
} as Partial< ChartTheme > );
|
|
15
|
+
|
|
16
|
+
const result = withCatalogPointers( merged, [
|
|
17
|
+
'--a8c-charts-color-background',
|
|
18
|
+
'--a8c-charts-color-grid',
|
|
19
|
+
'--a8c-charts-color-axis',
|
|
20
|
+
'--a8c-charts-color-tick',
|
|
21
|
+
'--a8c-charts-color-label-axis',
|
|
22
|
+
] );
|
|
23
|
+
|
|
24
|
+
expect( result.backgroundColor ).toBe( defaultTheme.backgroundColor );
|
|
25
|
+
expect( result.gridStyles.stroke ).toBe( defaultTheme.gridStyles.stroke );
|
|
26
|
+
expect( result.xAxisLineStyles.stroke ).toBe( defaultTheme.xAxisLineStyles.stroke );
|
|
27
|
+
expect( result.xTickLineStyles.stroke ).toBe( defaultTheme.xTickLineStyles.stroke );
|
|
28
|
+
expect( result.svgLabelSmall.fill ).toBe( defaultTheme.svgLabelSmall.fill );
|
|
29
|
+
|
|
30
|
+
expect( merged.backgroundColor ).toBe( '#111' );
|
|
31
|
+
expect( merged.gridStyles.stroke ).toBe( '#222' );
|
|
32
|
+
} );
|
|
33
|
+
|
|
34
|
+
it( 'leaves fields alone when no role was overridden', () => {
|
|
35
|
+
const merged = mergeThemes( defaultTheme, {
|
|
36
|
+
tickLength: 8,
|
|
37
|
+
leaderboardChart: { deltaColors: [ 'red', 'grey', 'green' ] },
|
|
38
|
+
} as Partial< ChartTheme > );
|
|
39
|
+
|
|
40
|
+
const result = withCatalogPointers( merged, [] );
|
|
41
|
+
|
|
42
|
+
expect( result.tickLength ).toBe( 8 );
|
|
43
|
+
expect( result.gridStyles.stroke ).toBe( defaultTheme.gridStyles.stroke );
|
|
44
|
+
// Not a mapped role, so its literal survives even when other roles are being restored.
|
|
45
|
+
expect( result.leaderboardChart.deltaColors ).toEqual( [ 'red', 'grey', 'green' ] );
|
|
46
|
+
} );
|
|
47
|
+
} );
|