Skip to content

Commit db8e85a

Browse files
zombieJclaude
andauthored
feat: support multiple scopes in CSS variable registration (#245)
* feat: support multiple scopes in CSS variable registration Allow the `scope` option to accept an array of strings, enabling CSS variables to be scoped to multiple classes simultaneously. This generates comma-separated selectors when multiple scopes are provided. Changes: - Update type definitions to support `string | string[]` for scope parameter - Refactor selector generation logic in `serializeCSSVar` to handle multiple scopes - Add test coverage for multiple scope functionality Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: filter empty scopes in CSS variable registration - Add filter(Boolean) to remove empty strings from scope array - Use @@ as separator for array scope in cache key Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: simplify scope handling in css variables --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ede3c77 commit db8e85a

3 files changed

Lines changed: 189 additions & 9 deletions

File tree

src/hooks/useCSSVarRegister.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,22 @@ const useCSSVarRegister = <V, T extends Record<string, V>>(
2828
prefix?: string;
2929
unitless?: Record<string, boolean>;
3030
ignore?: Record<string, boolean>;
31-
scope?: string;
31+
scope?: string | string[];
3232
token: any;
3333
hashId?: string;
3434
},
3535
fn: () => T,
3636
) => {
37-
const { key, prefix, unitless, ignore, token, hashId, scope = '' } = config;
37+
const { key, prefix, unitless, ignore, token, hashId, scope } = config;
3838
const {
3939
cache: { instanceId },
4040
container,
4141
hashPriority,
4242
} = useContext(StyleContext);
4343
const { _tokenKey: tokenKey } = token;
4444

45-
const stylePath = [...config.path, key, scope, tokenKey];
45+
const scopeKey = Array.isArray(scope) ? scope.join('@@') : scope;
46+
const stylePath = [...config.path, key, scopeKey, tokenKey];
4647

4748
const cache = useGlobalCache<CSSVarCacheValue<V, T>>(
4849
CSS_VAR_PREFIX,

src/util/css-variables.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,23 @@ export const serializeCSSVar = <T extends Record<string, any>>(
1313
cssVars: T,
1414
hashId: string,
1515
options?: {
16-
scope?: string;
16+
scope?: string | string[];
1717
hashCls?: string;
1818
hashPriority?: HashPriority;
1919
},
2020
) => {
21-
const { hashCls, hashPriority = 'low' } = options || {};
21+
const { hashCls, hashPriority = 'low', scope } = options || {};
2222
if (!Object.keys(cssVars).length) {
2323
return '';
2424
}
25-
return `${where({ hashCls, hashPriority })}.${hashId}${
26-
options?.scope ? `.${options.scope}` : ''
27-
}{${Object.entries(cssVars)
25+
26+
const baseSelector = `${where({ hashCls, hashPriority })}.${hashId}`;
27+
const scopes = [scope].flat().filter(Boolean);
28+
const selector = scopes.length
29+
? scopes.map((s) => `${baseSelector}.${s}`).join(', ')
30+
: baseSelector;
31+
32+
return `${selector}{${Object.entries(cssVars)
2833
.map(([key, value]) => `${key}:${value};`)
2934
.join('')}}`;
3035
};
@@ -53,7 +58,7 @@ export const transformToken = <
5358
preserve?: {
5459
[key in keyof T]?: boolean;
5560
};
56-
scope?: string;
61+
scope?: string | string[];
5762
hashCls?: string;
5863
hashPriority?: HashPriority;
5964
},

tests/css-variables.spec.tsx

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,4 +406,178 @@ describe('CSS Variables', () => {
406406
expect(styleStyle).toContain('line-height:var(--rc-line-height)');
407407
expect(styleStyle).not.toContain('--rc-line-height:1.5;');
408408
});
409+
410+
it('support multiple scope', () => {
411+
const cache = createCache();
412+
const BoxWithMultipleScopes = (props: { className?: string }) => {
413+
const [token, hashId, cssVarKey, realToken] = useToken();
414+
415+
const getComponentToken = () => ({ boxColor: '#5c21ff' });
416+
417+
const [cssVarToken] = useCSSVarRegister(
418+
{
419+
path: ['Box'],
420+
key: cssVarKey,
421+
token: realToken,
422+
prefix: 'rc-box',
423+
unitless: {
424+
lineHeight: true,
425+
},
426+
ignore: {
427+
lineHeightBase: true,
428+
},
429+
scope: ['box', 'container'],
430+
},
431+
cssVarKey ? getComponentToken : () => ({}),
432+
) as [{ boxColor?: string }, string, string, string];
433+
434+
useStyleRegister(
435+
{
436+
theme,
437+
token,
438+
hashId,
439+
path: ['BoxMultipleScope'],
440+
},
441+
() => {
442+
const mergedToken = {
443+
...token,
444+
...cssVarToken,
445+
boxColor: cssVarToken?.boxColor || '#5c21ff',
446+
} as DerivativeToken & { boxColor: string };
447+
448+
return {
449+
'.box': {
450+
lineHeight: mergedToken.lineHeight,
451+
color: mergedToken.boxColor,
452+
backgroundColor: mergedToken.primaryColor,
453+
},
454+
};
455+
},
456+
);
457+
458+
return (
459+
<div
460+
className={clsx(
461+
hashId,
462+
cssVarKey ? cssVarKey : '',
463+
'box',
464+
props.className,
465+
)}
466+
/>
467+
);
468+
};
469+
470+
render(
471+
<StyleProvider cache={cache}>
472+
<DesignTokenProvider
473+
theme={{
474+
cssVar: {
475+
key: 'apple',
476+
},
477+
}}
478+
>
479+
<BoxWithMultipleScopes className="target" />
480+
</DesignTokenProvider>
481+
</StyleProvider>,
482+
);
483+
484+
const styles = Array.from(document.head.querySelectorAll('style'));
485+
expect(styles.length).toBe(3);
486+
487+
// Check that the CSS variable style includes both scopes
488+
const cssVarStyle = styles.find((style) =>
489+
style.textContent?.includes('--rc-box-box-color'),
490+
);
491+
expect(cssVarStyle).toBeDefined();
492+
expect(cssVarStyle?.textContent).toContain('--rc-box-box-color:#5c21ff');
493+
// Should generate: .apple.box, .apple.container { ... }
494+
expect(cssVarStyle?.textContent).toMatch(
495+
/\.apple\.box,\s*\.apple\.container\{/,
496+
);
497+
});
498+
499+
it('should filter empty scopes', () => {
500+
const BoxWithEmptyScope = (props: { className?: string }) => {
501+
const [token, hashId, cssVarKey, realToken] = useToken();
502+
503+
const getComponentToken = () => ({ boxColor: '#5c21ff' });
504+
505+
const [cssVarToken] = useCSSVarRegister(
506+
{
507+
path: ['Box'],
508+
key: cssVarKey,
509+
token: realToken,
510+
prefix: 'rc-box',
511+
unitless: {
512+
lineHeight: true,
513+
},
514+
ignore: {
515+
lineHeightBase: true,
516+
},
517+
scope: ['box', '', 'container'],
518+
},
519+
cssVarKey ? getComponentToken : () => ({}),
520+
) as [{ boxColor?: string }, string, string, string];
521+
522+
useStyleRegister(
523+
{
524+
theme,
525+
token,
526+
hashId,
527+
path: ['BoxEmptyScope'],
528+
},
529+
() => {
530+
const mergedToken = {
531+
...token,
532+
...cssVarToken,
533+
boxColor: cssVarToken?.boxColor || '#5c21ff',
534+
} as DerivativeToken & { boxColor: string };
535+
536+
return {
537+
'.box': {
538+
lineHeight: mergedToken.lineHeight,
539+
color: mergedToken.boxColor,
540+
},
541+
};
542+
},
543+
);
544+
545+
return (
546+
<div
547+
className={clsx(
548+
hashId,
549+
cssVarKey ? cssVarKey : '',
550+
'box',
551+
props.className,
552+
)}
553+
/>
554+
);
555+
};
556+
557+
render(
558+
<StyleProvider cache={createCache()}>
559+
<DesignTokenProvider
560+
theme={{
561+
cssVar: {
562+
key: 'orange',
563+
},
564+
}}
565+
>
566+
<BoxWithEmptyScope className="target" />
567+
</DesignTokenProvider>
568+
</StyleProvider>,
569+
);
570+
571+
const styles = Array.from(document.head.querySelectorAll('style'));
572+
const cssVarStyle = styles.find((style) =>
573+
style.textContent?.includes('--rc-box-box-color'),
574+
);
575+
expect(cssVarStyle).toBeDefined();
576+
// Should NOT contain empty scope selector like .orange.
577+
expect(cssVarStyle?.textContent).not.toMatch(/\.orange\.\{/);
578+
// Should only contain valid scopes: .orange.box and .orange.container
579+
expect(cssVarStyle?.textContent).toMatch(
580+
/\.orange\.box,\s*\.orange\.container\{/,
581+
);
582+
});
409583
});

0 commit comments

Comments
 (0)