Visitar URL original
feat(compiler): Support css var namespacing in properties (#68846) · angular/angular@8c8b2f7 · GitHub
Skip to content

Commit 8c8b2f7

Browse files
mattrbeckleonsenft
authored andcommitted
feat(compiler): Support css var namespacing in properties (#68846)
Adds support for namespacing css variables in style properties. Behaves as you'd expect following the implementation for stylesheets generally. This change also moves the error message into a util function since we now need to produce the same error in three places. PR Close #68846
1 parent af5e4e1 commit 8c8b2f7

10 files changed

Lines changed: 164 additions & 35 deletions

File tree

‎goldens/public-api/platform-browser/index.api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef
168168
export function provideClientHydration(...features: HydrationFeature<HydrationFeatureKind>[]): EnvironmentProviders;
169169

170170
// @public
171-
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders;
171+
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders;
172172

173173
// @public
174174
export function provideProtractorTestingSupport(options?: {

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ hostAttrs: [2, "--camel-case", "foo", "--kebab-case", "foo"],
33
…
44
hostBindings: function MyDirective_HostBindings(rf, ctx) {
55
if (rf & 2) {
6-
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
6+
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
77
}
88
}

‎packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
if (rf & 1) {
55
i0.ɵɵelement(0, "div", 0);
66
} if (rf & 2) {
7-
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
7+
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
88
}
99
}

‎packages/compiler/src/shadow_css.ts‎

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88
import * as chars from './chars';
9+
import {namespaceCssVariable} from './util';
910

1011
/**
1112
* The following set contains all keywords that can be used in the animation css shorthand
@@ -1048,25 +1049,19 @@ const _cssVariableRe = /(var\(\s*)?(--(?:[a-zA-Z0-9_-]|[^\x00-\x7F])+)(\s*:)?/g;
10481049
*/
10491050
export function namespaceCssVariables(cssText: string): string {
10501051
return cssText.replace(_cssVariableRe, (match, leadingVar, varName, trailingColon) => {
1052+
// Check for a leading `var(` or trailing `:` to approximate whether we're operating on a
1053+
// real CSS variable, not another piece of syntax that resembles it. For example, this
1054+
// guards against:
1055+
// - `.foo--bar {}`
1056+
// - `/* --foo */`
1057+
// - `p { content: "--foo" }`
1058+
// - `[data---bar] {}`
1059+
// - `[data-status=foo--bar] {}`
1060+
// etc.
10511061
if (!leadingVar && !trailingColon) {
10521062
return match;
10531063
}
1054-
1055-
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
1056-
throw new Error(
1057-
`CSS variable "${varName}" has a single hyphen after "--global". ` +
1058-
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
1059-
);
1060-
}
1061-
1062-
let result;
1063-
if (varName.startsWith('--global--')) {
1064-
result = `--${varName.substring('--global--'.length)}`;
1065-
} else {
1066-
result = `--%NS%${varName.substring('--'.length)}`;
1067-
}
1068-
1069-
return (leadingVar || '') + result + (trailingColon || '');
1064+
return (leadingVar ?? '') + namespaceCssVariable(varName) + (trailingColon ?? '');
10701065
});
10711066
}
10721067

‎packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import * as ir from '../../ir';
1010

1111
import type {CompilationJob} from '../compilation';
12+
import {namespaceCssVariable} from '../../../../util';
1213

1314
const STYLE_DOT = 'style.';
1415
const CLASS_DOT = 'class.';
@@ -40,6 +41,8 @@ export function parseHostStyleProperties(job: CompilationJob): void {
4041

4142
if (!isCssCustomProperty(op.name)) {
4243
op.name = hyphenate(op.name);
44+
} else {
45+
op.name = namespaceCssVariable(op.name);
4346
}
4447

4548
const {property, suffix} = parseProperty(op.name);

‎packages/compiler/src/template_parser/binding_parser.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {InterpolatedAttributeToken, InterpolatedTextToken} from '../ml_parser/to
3636
import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util';
3737
import {ElementSchemaRegistry} from '../schema/element_schema_registry';
3838
import {CssSelector} from '../directive_matching';
39-
import {splitAtColon, splitAtPeriod} from '../util';
39+
import {namespaceCssVariable, splitAtColon, splitAtPeriod} from '../util';
4040
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces';
4141

4242
const PROPERTY_PARTS_SEPARATOR = '.';
@@ -594,7 +594,16 @@ export class BindingParser {
594594
securityContexts = [SecurityContext.NONE];
595595
} else if (parts[0] == STYLE_PREFIX) {
596596
unit = parts.length > 2 ? parts[2] : null;
597-
boundPropertyName = parts[1];
597+
const boundName = parts[1];
598+
if (!boundName.startsWith('--')) {
599+
boundPropertyName = boundName;
600+
} else {
601+
try {
602+
boundPropertyName = namespaceCssVariable(boundName);
603+
} catch (e) {
604+
this._reportError((e as Error).message, boundProp.sourceSpan);
605+
}
606+
}
598607
bindingType = BindingType.Style;
599608
securityContexts = [SecurityContext.STYLE];
600609
} else if (parts[0] == ANIMATE_PREFIX) {

‎packages/compiler/src/util.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,25 @@ export function getJitStandaloneDefaultForVersion(version: string): boolean {
153153
// All other Angular versions (v19+) default to true.
154154
return true;
155155
}
156+
157+
/**
158+
* Namespaces a CSS variable name and validates its syntax.
159+
*
160+
* @param varName The CSS variable name starting with `--`.
161+
* @throws An Error if the CSS variable is invalid (e.g. has a single hyphen after "--global").
162+
* @returns The namespaced CSS variable name.
163+
*/
164+
export function namespaceCssVariable(varName: string): string {
165+
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
166+
throw new Error(
167+
`CSS variable "${varName}" has a single hyphen after "--global". ` +
168+
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
169+
);
170+
}
171+
172+
if (varName.startsWith('--global--')) {
173+
return '--' + varName.substring('--global--'.length);
174+
} else {
175+
return '--%NS%' + varName.substring('--'.length);
176+
}
177+
}

‎packages/platform-browser/src/dom/dom_renderer.ts‎

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -78,20 +78,23 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken<boolean>(
7878
*
7979
* Typically set via {@link provideCssVarNamespacing}.
8080
*/
81-
export const CSS_VAR_NAMESPACE = new InjectionToken<string>('CSS_VAR_NAMESPACE');
81+
export const CSS_VAR_NAMESPACE = new InjectionToken<string>(
82+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'CSS_VAR_NAMESPACE' : '',
83+
);
8284

8385
/**
8486
* Configures the application to use the given namespace for all CSS variables.
8587
*
86-
* @param namespace The prefix string to use as a namespace. This is typically the `APP_ID`
87-
* followed by a separator, such as 'my-app_'.
88+
* @param namespace The prefix string to use as a namespace. If not provided, it defaults
89+
* to the `APP_ID`. An underscore is appended unconditionally.
8890
* @publicApi
8991
*/
90-
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders {
92+
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders {
9193
return makeEnvironmentProviders([
9294
{
9395
provide: CSS_VAR_NAMESPACE,
94-
useValue: namespace,
96+
useFactory: (appId: string) => `${namespace ?? appId}_`,
97+
deps: [APP_ID],
9598
},
9699
]);
97100
}
@@ -177,7 +180,13 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
177180
@Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null,
178181
) {
179182
this.cssVarNamespace = cssVarNamespace ?? '';
180-
this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService);
183+
this.defaultRenderer = new DefaultDomRenderer2(
184+
eventManager,
185+
doc,
186+
ngZone,
187+
this.tracingService,
188+
this.cssVarNamespace,
189+
);
181190
}
182191

183192
createRenderer(element: any, type: RendererType2 | null): Renderer2 {
@@ -304,6 +313,7 @@ class DefaultDomRenderer2 implements Renderer2 {
304313
private readonly doc: Document,
305314
protected readonly ngZone: NgZone,
306315
private readonly tracingService: TracingService<TracingSnapshot> | null,
316+
private readonly cssVarNamespace: string = '',
307317
) {}
308318

309319
destroy(): void {}
@@ -412,15 +422,23 @@ class DefaultDomRenderer2 implements Renderer2 {
412422
}
413423

414424
setStyle(el: any, style: string, value: any, flags: RendererStyleFlags2): void {
415-
if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
425+
const isVariable = style.startsWith('--');
426+
if (isVariable) {
427+
style = style.replace('%NS%', this.cssVarNamespace);
428+
}
429+
if (isVariable || flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
416430
el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : '');
417431
} else {
418432
el.style[style] = value;
419433
}
420434
}
421435

422436
removeStyle(el: any, style: string, flags: RendererStyleFlags2): void {
423-
if (flags & RendererStyleFlags2.DashCase) {
437+
const isVariable = style.startsWith('--');
438+
if (isVariable) {
439+
style = style.replace('%NS%', this.cssVarNamespace);
440+
}
441+
if (isVariable || flags & RendererStyleFlags2.DashCase) {
424442
// removeProperty has no effect when used on camelCased properties.
425443
el.style.removeProperty(style);
426444
} else {
@@ -538,7 +556,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
538556
cssVarNamespace: string,
539557
private sharedStylesHost?: SharedStylesHost,
540558
) {
541-
super(eventManager, doc, ngZone, tracingService);
559+
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
542560
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
543561

544562
// SharedStylesHost is used to add styles to the shadow root by ShadowDom.
@@ -628,7 +646,7 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
628646
cssVarNamespace: string,
629647
compId?: string,
630648
) {
631-
super(eventManager, doc, ngZone, tracingService);
649+
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
632650
let styles = component.styles;
633651
if (ngDevMode) {
634652
// We only do this in development, as for production users should not add CSS sourcemaps to components.

‎packages/platform-browser/test/dom/css_var_namespacer_spec.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {APP_ID} from '@angular/core';
910
import {TestBed} from '@angular/core/testing';
1011

1112
import {CssVarNamespacer} from '../../src/dom/css_var_namespacer';
@@ -14,14 +15,28 @@ import {provideCssVarNamespacing} from '../../src/dom/dom_renderer';
1415
describe('CssVarNamespacer', () => {
1516
it('should namespace variables when `CSS_VAR_NAMESPACE` is provided', () => {
1617
TestBed.configureTestingModule({
17-
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app_')],
18+
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app')],
1819
});
1920

2021
const namespacer = TestBed.inject(CssVarNamespacer);
2122

2223
expect(namespacer.namespace('--my-var')).toBe('--test-app_my-var');
2324
});
2425

26+
it('should fallback to `APP_ID` with an underscore when no namespace is provided to `provideCssVarNamespacing`', () => {
27+
TestBed.configureTestingModule({
28+
providers: [
29+
CssVarNamespacer,
30+
{provide: APP_ID, useValue: 'custom-app'},
31+
provideCssVarNamespacing(),
32+
],
33+
});
34+
35+
const namespacer = TestBed.inject(CssVarNamespacer);
36+
37+
expect(namespacer.namespace('--my-var')).toBe('--custom-app_my-var');
38+
});
39+
2540
it('should not namespace variables when `CSS_VAR_NAMESPACE` is not provided', () => {
2641
TestBed.configureTestingModule({
2742
providers: [CssVarNamespacer],

‎packages/platform-browser/test/dom/dom_renderer_spec.ts‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ describe('DefaultDomRendererV2', () => {
361361

362362
TestBed.configureTestingModule({
363363
imports: [CmpNamespaceEmulated],
364-
providers: [provideCssVarNamespacing('my-namespace_')],
364+
providers: [provideCssVarNamespacing('my-namespace')],
365365
});
366366
const fixture = TestBed.createComponent(CmpNamespaceEmulated);
367367
fixture.detectChanges();
@@ -384,7 +384,7 @@ describe('DefaultDomRendererV2', () => {
384384

385385
TestBed.configureTestingModule({
386386
imports: [CmpNamespaceNone],
387-
providers: [provideCssVarNamespacing('my-namespace_')],
387+
providers: [provideCssVarNamespacing('my-namespace')],
388388
});
389389
const fixture = TestBed.createComponent(CmpNamespaceNone);
390390
fixture.detectChanges();
@@ -407,7 +407,7 @@ describe('DefaultDomRendererV2', () => {
407407

408408
TestBed.configureTestingModule({
409409
imports: [CmpNamespaceShadow],
410-
providers: [provideCssVarNamespacing('my-namespace_')],
410+
providers: [provideCssVarNamespacing('my-namespace')],
411411
});
412412
const fixture = TestBed.createComponent(CmpNamespaceShadow);
413413
fixture.detectChanges();
@@ -498,6 +498,73 @@ describe('DefaultDomRendererV2', () => {
498498
expect(css).toContain('var(--foo)');
499499
});
500500
});
501+
502+
describe('style property bindings namespacing', () => {
503+
it('should namespace style property bindings starting with `--`', () => {
504+
@Component({
505+
selector: 'cmp-style-prop-namespace',
506+
template: `<div [style.--foo]="'blue'"></div>`,
507+
standalone: true,
508+
})
509+
class CmpStylePropNamespace {}
510+
511+
TestBed.configureTestingModule({
512+
imports: [CmpStylePropNamespace],
513+
providers: [provideCssVarNamespacing('my-namespace')],
514+
});
515+
const fixture = TestBed.createComponent(CmpStylePropNamespace);
516+
fixture.detectChanges();
517+
518+
const div = fixture.nativeElement.querySelector('div');
519+
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
520+
expect(div.style.getPropertyValue('--foo')).toBe('');
521+
});
522+
523+
it('should throw an error if style property binding starts with `--global-` with a single hyphen', () => {
524+
@Component({
525+
selector: 'cmp-style-prop-error',
526+
template: `<div [style.--global-foo]="'blue'"></div>`,
527+
standalone: true,
528+
})
529+
class CmpStylePropError {}
530+
531+
expect(() => {
532+
TestBed.configureTestingModule({
533+
imports: [CmpStylePropError],
534+
providers: [provideCssVarNamespacing('my-namespace')],
535+
});
536+
}).toThrowError(/CSS variable "--global-foo" has a single hyphen after "--global"/);
537+
});
538+
539+
it('should namespace styles set via Renderer2.setStyle/removeStyle', () => {
540+
@Component({
541+
selector: 'cmp-renderer-set-style',
542+
template: '',
543+
standalone: true,
544+
})
545+
class CmpRendererSetStyle {
546+
constructor(public renderer: Renderer2) {}
547+
}
548+
549+
TestBed.configureTestingModule({
550+
imports: [CmpRendererSetStyle],
551+
providers: [provideCssVarNamespacing('my-namespace')],
552+
});
553+
const fixture = TestBed.createComponent(CmpRendererSetStyle);
554+
const comp = fixture.componentInstance;
555+
const div = document.createElement('div');
556+
557+
comp.renderer.setStyle(div, '--%NS%foo', 'blue');
558+
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
559+
560+
comp.renderer.setStyle(div, '--bar', 'red');
561+
expect(div.style.getPropertyValue('--bar')).toBe('red');
562+
expect(div.style.getPropertyValue('--my-namespace_bar')).toBe('');
563+
564+
comp.renderer.removeStyle(div, '--%NS%foo');
565+
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('');
566+
});
567+
});
501568
});
502569
});
503570

0 commit comments

Comments
 (0)