WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit d1b1682

Browse files
committed
feat: Add property for preserving css variables in token resolution
1 parent d6bd388 commit d1b1682

File tree

9 files changed

+306
-30
lines changed

9 files changed

+306
-30
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import { test, expect } from 'vitest';
4+
import { buildThemedComponentsInternal } from '../internal';
5+
import { Theme, resolveTheme, resolveContext } from '../../shared/theme';
6+
import { isReferenceToken, generateReferenceTokenDefaults, generateReferenceTokenName } from '../../shared/theme/utils';
7+
import { createBuildDeclarations } from '../../shared/declaration';
8+
import { mkdtemp, rm } from 'fs/promises';
9+
import { tmpdir } from 'os';
10+
import { join } from 'path';
11+
12+
const testTheme: Theme = {
13+
id: 'test',
14+
selector: ':root',
15+
tokens: {
16+
colorPrimary: '#0073bb',
17+
colorBackground: '{colorPrimary}',
18+
colorPrimary500: '#direct-primary-500', // Different from reference token to test precedence
19+
colorSecondary: '{colorNeutral900}', // References a token that exists in both places
20+
colorNeutral900: '#direct-neutral', // Different from reference token
21+
},
22+
modes: {},
23+
contexts: {},
24+
tokenModeMap: {},
25+
referenceTokens: {
26+
color: {
27+
primary: { 500: '#reference-primary-value' }, // Also defined in tokens - tests precedence
28+
neutral: { 900: '#reference-neutral-value' }, // Also defined in tokens - tests precedence
29+
success: { 200: '#reference-success-value' }, // Only in reference tokens - tests auto-generation
30+
},
31+
},
32+
};
33+
34+
const propertiesMap = {
35+
colorPrimary: '--color-primary',
36+
colorBackground: '--color-background',
37+
colorPrimary500: '--color-primary-500',
38+
colorSecondary: '--color-secondary',
39+
colorNeutral900: '--color-neutral-900',
40+
colorSuccess200: '--color-success-200', // For the reference-only token
41+
};
42+
43+
test('CSS variable optimization works without errors', async () => {
44+
const tempDir = await mkdtemp(join(tmpdir(), 'css-vars-test-'));
45+
46+
try {
47+
await buildThemedComponentsInternal({
48+
primary: testTheme,
49+
exposed: ['colorPrimary', 'colorBackground'],
50+
themeable: ['colorPrimary', 'colorBackground'],
51+
variablesMap: {
52+
colorPrimary: 'color-primary',
53+
colorBackground: 'color-background',
54+
},
55+
componentsOutputDir: tempDir,
56+
scssDir: tempDir,
57+
useCssVars: true,
58+
skip: ['preset', 'design-tokens'],
59+
});
60+
61+
expect(true).toBe(true);
62+
} finally {
63+
await rm(tempDir, { recursive: true, force: true });
64+
}
65+
});
66+
67+
test('isReferenceToken identifies reference tokens correctly', () => {
68+
expect(isReferenceToken(testTheme, 'colorPrimary500')).toBe(true);
69+
expect(isReferenceToken(testTheme, 'colorNeutral900')).toBe(true);
70+
expect(isReferenceToken(testTheme, 'colorPrimary')).toBe(false);
71+
expect(isReferenceToken(testTheme, 'colorSuccess500')).toBe(false); // Not in referenceTokens
72+
});
73+
74+
test('generateReferenceTokenName creates correct token names', () => {
75+
expect(generateReferenceTokenName('primary', '500')).toBe('colorPrimary500');
76+
expect(generateReferenceTokenName('neutral', '900')).toBe('colorNeutral900');
77+
});
78+
79+
test('generateReferenceTokenDefaults creates CSS variable declarations', () => {
80+
const defaults = generateReferenceTokenDefaults(testTheme, propertiesMap);
81+
82+
expect(defaults).toEqual({
83+
'--color-primary-500': '#reference-primary-value',
84+
'--color-neutral-900': '#reference-neutral-value',
85+
'--color-success-200': '#reference-success-value',
86+
});
87+
});
88+
89+
test('resolveTheme with CSS variables returns CSS var() for reference tokens', () => {
90+
const resolved = resolveTheme(testTheme, undefined, {
91+
useCssVars: true,
92+
propertiesMap,
93+
});
94+
95+
// Direct token takes precedence over reference token
96+
expect(resolved.colorPrimary500).toBe('#direct-primary-500');
97+
// Token referencing a reference token should get CSS var
98+
expect(resolved.colorSecondary).toBe('var(--color-neutral-900)');
99+
expect(resolved.colorPrimary).toBe('#0073bb'); // Not a reference token
100+
});
101+
102+
test('token precedence: direct tokens override reference tokens', () => {
103+
const resolved = resolveTheme(testTheme, undefined);
104+
105+
// Direct token value should be used, not reference token value
106+
expect(resolved.colorPrimary500).toBe('#direct-primary-500');
107+
expect(resolved.colorNeutral900).toBe('#direct-neutral');
108+
});
109+
110+
test('resolveContext with CSS variables preserves var() references', () => {
111+
const context = {
112+
id: 'test-context',
113+
selector: '.test',
114+
tokens: {
115+
colorSecondary: '{colorNeutral900}', // References a token that should become CSS var
116+
},
117+
};
118+
119+
const resolved = resolveContext(testTheme, context, undefined, undefined, {
120+
useCssVars: true,
121+
propertiesMap,
122+
});
123+
124+
// Should resolve to CSS var since colorNeutral900 is a reference token
125+
expect(resolved.colorSecondary).toBe('var(--color-neutral-900)');
126+
});
127+
128+
test('createBuildDeclarations includes reference tokens when useCssVars enabled', () => {
129+
const css = createBuildDeclarations(testTheme, [], propertiesMap, (selector) => selector, [], {
130+
useCssVars: true,
131+
propertiesMap,
132+
});
133+
134+
expect(css).toContain('--color-primary-500');
135+
expect(css).toContain('--color-neutral-900');
136+
expect(css).not.toContain('--color-success-200'); // Not auto-generated because it's not in used tokens
137+
});

src/build/inline-stylesheets.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@ export function getInlineStylesheets(
1818
resolution: SpecificResolution,
1919
variablesMap: Record<string, string>,
2020
propertiesMap: Record<string, string>,
21-
neededTokens: string[]
21+
neededTokens: string[],
22+
useCssVars?: boolean
2223
): InlineStylesheet[] {
2324
const declarations = createBuildDeclarations(
2425
primary,
2526
secondary,
2627
propertiesMap,
2728
(selector) => markGlobal(selector),
28-
neededTokens
29+
neededTokens,
30+
useCssVars ? { useCssVars, propertiesMap } : undefined
2931
);
3032

3133
const declaration = {

src/build/internal.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export interface BuildThemedComponentsInternalParams {
4040
jsonSchema?: boolean;
4141
/** Fail the build when SASS deprecation warning occurs **/
4242
failOnDeprecations?: boolean;
43+
/** Use CSS variables instead of resolved values for better runtime theme swapping */
44+
useCssVars?: boolean;
4345
}
4446
/**
4547
* Builds themed components and optionally design tokens, if not skipped.
@@ -71,6 +73,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent
7173
descriptions = {},
7274
jsonSchema = false,
7375
failOnDeprecations,
76+
useCssVars = false,
7477
} = params;
7578

7679
if (!skip.includes('design-tokens') && !designTokensOutputDir) {
@@ -86,7 +89,7 @@ export async function buildThemedComponentsInternal(params: BuildThemedComponent
8689
const styleTask = buildStyles(
8790
scssDir,
8891
componentsOutputDir,
89-
getInlineStylesheets(primary, secondary, defaults, variablesMap, propertiesMap, neededTokens),
92+
getInlineStylesheets(primary, secondary, defaults, variablesMap, propertiesMap, neededTokens, useCssVars),
9093
{ failOnDeprecations }
9194
);
9295
const internalTokensTask = createInternalTokenFiles(defaults, propertiesMap, componentsOutputDir);

src/shared/declaration/index.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3-
import { mergeInPlace, Override, Theme } from '../theme';
3+
import { mergeInPlace, Override, Theme, ResolveOptions } from '../theme';
4+
import { generateReferenceTokenName } from '../theme/utils';
45
import type { PropertiesMap, SelectorCustomizer } from './interfaces';
56
import { RuleCreator } from './rule';
67
import { SingleThemeCreator } from './single';
@@ -35,12 +36,13 @@ export function createOverrideDeclarations(
3536
base: Theme,
3637
override: Override,
3738
propertiesMap: PropertiesMap,
38-
selectorCustomizer: SelectorCustomizer
39+
selectorCustomizer: SelectorCustomizer,
40+
options?: ResolveOptions
3941
): string {
4042
// create theme containing only modified tokens
4143
const minimalTheme = createMinimalTheme(base, override);
4244
const ruleCreator = new RuleCreator(new Selector(selectorCustomizer), new AllPropertyRegistry(propertiesMap));
43-
const stylesheetCreator = new SingleThemeCreator(minimalTheme, ruleCreator, base);
45+
const stylesheetCreator = new SingleThemeCreator(minimalTheme, ruleCreator, base, options);
4446
const stylesheet = stylesheetCreator.create();
4547
return stylesheet.toString();
4648
}
@@ -50,10 +52,33 @@ export function createBuildDeclarations(
5052
secondary: Theme[],
5153
propertiesMap: PropertiesMap,
5254
selectorCustomizer: SelectorCustomizer,
53-
used: string[]
55+
used: string[],
56+
options?: ResolveOptions
5457
): string {
55-
const ruleCreator = new RuleCreator(new Selector(selectorCustomizer), new UsedPropertyRegistry(propertiesMap, used));
56-
const stylesheetCreator = new MultiThemeCreator([primary, ...secondary], ruleCreator);
58+
// When CSS vars are enabled, include reference tokens from all themes in the used list
59+
let effectiveUsed = used;
60+
if (options?.useCssVars) {
61+
const allThemes = [primary, ...secondary];
62+
const referenceTokens: string[] = [];
63+
allThemes.forEach((theme) => {
64+
if (theme.referenceTokens?.color) {
65+
Object.entries(theme.referenceTokens.color).forEach(([colorName, palette]) => {
66+
if (palette) {
67+
Object.keys(palette).forEach((step) => {
68+
referenceTokens.push(generateReferenceTokenName(colorName, step));
69+
});
70+
}
71+
});
72+
}
73+
});
74+
effectiveUsed = [...used, ...referenceTokens];
75+
}
76+
77+
const ruleCreator = new RuleCreator(
78+
new Selector(selectorCustomizer),
79+
new UsedPropertyRegistry(propertiesMap, effectiveUsed)
80+
);
81+
const stylesheetCreator = new MultiThemeCreator([primary, ...secondary], ruleCreator, options);
5782
const stylesheet = stylesheetCreator.create();
5883
const transformer = new MinimalTransformer();
5984
const minimal = transformer.transform(stylesheet);

src/shared/declaration/multi.ts

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33
import { isGlobalSelector } from '../styles/selector';
4-
import { defaultsReducer, modeReducer, OptionalState, reduce, resolveContext, resolveTheme, Theme } from '../theme';
4+
import {
5+
defaultsReducer,
6+
modeReducer,
7+
OptionalState,
8+
reduce,
9+
resolveContext,
10+
resolveTheme,
11+
Theme,
12+
ResolveOptions,
13+
} from '../theme';
14+
import { generateReferenceTokenDefaults } from '../theme/utils';
515
import { AbstractCreator } from './abstract';
616
import type { StylesheetCreator } from './interfaces';
717
import { RuleCreator, SelectorConfig } from './rule';
@@ -16,11 +26,13 @@ import { compact } from './utils';
1626
export class MultiThemeCreator extends AbstractCreator implements StylesheetCreator {
1727
themes: Theme[];
1828
ruleCreator: RuleCreator;
29+
options?: ResolveOptions;
1930

20-
constructor(themes: Theme[], ruleCreator: RuleCreator) {
31+
constructor(themes: Theme[], ruleCreator: RuleCreator, options?: ResolveOptions) {
2132
super();
2233
this.themes = themes;
2334
this.ruleCreator = ruleCreator;
35+
this.options = options;
2436
}
2537

2638
create(): Stylesheet {
@@ -38,7 +50,9 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea
3850

3951
if (!globalThemes.length) {
4052
// If there is no root theme, all themes are scoped by their root selector. No interference.
41-
const stylesheets = this.themes.map((theme) => new SingleThemeCreator(theme, this.ruleCreator).create());
53+
const stylesheets = this.themes.map((theme) =>
54+
new SingleThemeCreator(theme, this.ruleCreator, undefined, this.options).create()
55+
);
4256
const result = new Stylesheet();
4357
stylesheets.forEach((stylesheet) => {
4458
stylesheet.getAllRules().map((rule) => result.appendRuleWithPath(rule, stylesheet.getPath(rule) ?? []));
@@ -48,7 +62,7 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea
4862

4963
const [globalTheme] = globalThemes;
5064

51-
const stylesheet = new SingleThemeCreator(globalTheme, this.ruleCreator).create();
65+
const stylesheet = new SingleThemeCreator(globalTheme, this.ruleCreator, undefined, this.options).create();
5266
const secondaries = this.getThemesWithout(globalTheme);
5367
secondaries.forEach((secondary) => {
5468
this.appendRulesForSecondary(stylesheet, globalTheme, secondary);
@@ -58,8 +72,15 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea
5872
}
5973

6074
appendRulesForSecondary(stylesheet: Stylesheet, primary: Theme, secondary: Theme) {
61-
const secondaryResolution = resolveTheme(secondary);
75+
const secondaryResolution = resolveTheme(secondary, undefined, this.options);
6276
const defaults = reduce(secondaryResolution, secondary, defaultsReducer());
77+
78+
// Add CSS variable declarations for reference tokens when useCssVars is enabled
79+
if (this.options?.useCssVars && this.options?.propertiesMap) {
80+
const referenceDefaults = generateReferenceTokenDefaults(secondary, this.options.propertiesMap);
81+
Object.assign(defaults, referenceDefaults);
82+
}
83+
6384
const rootRule = this.ruleCreator.create({ global: [secondary.selector] }, defaults);
6485
const parentRule = this.findRule(stylesheet, { global: [primary.selector] });
6586
MultiThemeCreator.appendRuleToStylesheet(stylesheet, rootRule, compact([parentRule]));
@@ -80,7 +101,11 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea
80101
});
81102

82103
MultiThemeCreator.forEachContext(secondary, (context) => {
83-
const contextResolution = reduce(resolveContext(secondary, context), secondary, defaultsReducer());
104+
const contextResolution = reduce(
105+
resolveContext(secondary, context, undefined, undefined, this.options),
106+
secondary,
107+
defaultsReducer()
108+
);
84109
const contextRule = this.ruleCreator.create(
85110
{ global: [secondary.selector], local: [context.selector] },
86111
contextResolution
@@ -110,7 +135,11 @@ export class MultiThemeCreator extends AbstractCreator implements StylesheetCrea
110135

111136
MultiThemeCreator.forEachContextWithinOptionalModeState(secondary, (context, mode, state) => {
112137
const optionalState = mode.states[state] as OptionalState;
113-
const contextResolution = reduce(resolveContext(secondary, context), secondary, modeReducer(mode, state));
138+
const contextResolution = reduce(
139+
resolveContext(secondary, context, undefined, undefined, this.options),
140+
secondary,
141+
modeReducer(mode, state)
142+
);
114143
const contextRule = this.findRule(stylesheet, { global: [secondary.selector], local: [context.selector] });
115144
const modeRule = this.findRule(stylesheet, {
116145
global: [secondary.selector, optionalState.selector],

0 commit comments

Comments
 (0)