Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 8c8b2f7

Browse filesBrowse 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
Copy full SHA for 8c8b2f7

10 files changed

+164-35Lines changed: 164 additions & 35 deletions

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
Expand file treeCollapse file tree
Open diff view settings
Collapse file

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

Copy file name to clipboardExpand all lines: goldens/public-api/platform-browser/index.api.md
+1-1Lines changed: 1 addition & 1 deletion
  • Display the source diff
  • Display the rich diff
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?: {
Collapse file

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

Copy file name to clipboardExpand all lines: packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js
+1-1Lines 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
}
Collapse file

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

Copy file name to clipboardExpand all lines: packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js
+1-1Lines 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
}
Collapse file

‎packages/compiler/src/shadow_css.ts‎

Copy file name to clipboardExpand all lines: packages/compiler/src/shadow_css.ts
+11-16Lines 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

Collapse file

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

Copy file name to clipboardExpand all lines: packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts
+3Lines 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);
Collapse file

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

Copy file name to clipboardExpand all lines: packages/compiler/src/template_parser/binding_parser.ts
+11-2Lines 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) {
Collapse file

‎packages/compiler/src/util.ts‎

Copy file name to clipboardExpand all lines: packages/compiler/src/util.ts
+22Lines 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+
}
Collapse file

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

Copy file name to clipboardExpand all lines: packages/platform-browser/src/dom/dom_renderer.ts
+28-10Lines 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.
Collapse file

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

Copy file name to clipboardExpand all lines: packages/platform-browser/test/dom/css_var_namespacer_spec.ts
+16-1Lines 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],
Collapse file

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

Copy file name to clipboardExpand all lines: packages/platform-browser/test/dom/dom_renderer_spec.ts
+70-3Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.