mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
@@ -168,7 +168,7 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef
|
||||
export function provideClientHydration(...features: HydrationFeature<HydrationFeatureKind>[]): EnvironmentProviders;
|
||||
|
||||
// @public
|
||||
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders;
|
||||
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders;
|
||||
|
||||
// @public
|
||||
export function provideProtractorTestingSupport(options?: {
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ hostAttrs: [2, "--camel-case", "foo", "--kebab-case", "foo"],
|
||||
…
|
||||
hostBindings: function MyDirective_HostBindings(rf, ctx) {
|
||||
if (rf & 2) {
|
||||
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
|
||||
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
if (rf & 1) {
|
||||
i0.ɵɵelement(0, "div", 0);
|
||||
} if (rf & 2) {
|
||||
i0.ɵɵstyleProp("--camelCase", ctx.value)("--kebab-case", ctx.value);
|
||||
i0.ɵɵstyleProp("--%NS%camelCase", ctx.value)("--%NS%kebab-case", ctx.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
import * as chars from './chars';
|
||||
import {namespaceCssVariable} from './util';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
*/
|
||||
export function namespaceCssVariables(cssText: string): string {
|
||||
return cssText.replace(_cssVariableRe, (match, leadingVar, varName, trailingColon) => {
|
||||
// Check for a leading `var(` or trailing `:` to approximate whether we're operating on a
|
||||
// real CSS variable, not another piece of syntax that resembles it. For example, this
|
||||
// guards against:
|
||||
// - `.foo--bar {}`
|
||||
// - `/* --foo */`
|
||||
// - `p { content: "--foo" }`
|
||||
// - `[data---bar] {}`
|
||||
// - `[data-status=foo--bar] {}`
|
||||
// etc.
|
||||
if (!leadingVar && !trailingColon) {
|
||||
return match;
|
||||
}
|
||||
|
||||
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
|
||||
throw new Error(
|
||||
`CSS variable "${varName}" has a single hyphen after "--global". ` +
|
||||
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
|
||||
);
|
||||
}
|
||||
|
||||
let result;
|
||||
if (varName.startsWith('--global--')) {
|
||||
result = `--${varName.substring('--global--'.length)}`;
|
||||
} else {
|
||||
result = `--%NS%${varName.substring('--'.length)}`;
|
||||
}
|
||||
|
||||
return (leadingVar || '') + result + (trailingColon || '');
|
||||
return (leadingVar ?? '') + namespaceCssVariable(varName) + (trailingColon ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import * as ir from '../../ir';
|
||||
|
||||
import type {CompilationJob} from '../compilation';
|
||||
import {namespaceCssVariable} from '../../../../util';
|
||||
|
||||
const STYLE_DOT = 'style.';
|
||||
const CLASS_DOT = 'class.';
|
||||
@@ -40,6 +41,8 @@ export function parseHostStyleProperties(job: CompilationJob): void {
|
||||
|
||||
if (!isCssCustomProperty(op.name)) {
|
||||
op.name = hyphenate(op.name);
|
||||
} else {
|
||||
op.name = namespaceCssVariable(op.name);
|
||||
}
|
||||
|
||||
const {property, suffix} = parseProperty(op.name);
|
||||
|
||||
@@ -36,7 +36,7 @@ import {InterpolatedAttributeToken, InterpolatedTextToken} from '../ml_parser/to
|
||||
import {ParseError, ParseErrorLevel, ParseSourceSpan} from '../parse_util';
|
||||
import {ElementSchemaRegistry} from '../schema/element_schema_registry';
|
||||
import {CssSelector} from '../directive_matching';
|
||||
import {splitAtColon, splitAtPeriod} from '../util';
|
||||
import {namespaceCssVariable, splitAtColon, splitAtPeriod} from '../util';
|
||||
import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces';
|
||||
|
||||
const PROPERTY_PARTS_SEPARATOR = '.';
|
||||
@@ -594,7 +594,16 @@ export class BindingParser {
|
||||
securityContexts = [SecurityContext.NONE];
|
||||
} else if (parts[0] == STYLE_PREFIX) {
|
||||
unit = parts.length > 2 ? parts[2] : null;
|
||||
boundPropertyName = parts[1];
|
||||
const boundName = parts[1];
|
||||
if (!boundName.startsWith('--')) {
|
||||
boundPropertyName = boundName;
|
||||
} else {
|
||||
try {
|
||||
boundPropertyName = namespaceCssVariable(boundName);
|
||||
} catch (e) {
|
||||
this._reportError((e as Error).message, boundProp.sourceSpan);
|
||||
}
|
||||
}
|
||||
bindingType = BindingType.Style;
|
||||
securityContexts = [SecurityContext.STYLE];
|
||||
} else if (parts[0] == ANIMATE_PREFIX) {
|
||||
|
||||
@@ -153,3 +153,25 @@ export function getJitStandaloneDefaultForVersion(version: string): boolean {
|
||||
// All other Angular versions (v19+) default to true.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespaces a CSS variable name and validates its syntax.
|
||||
*
|
||||
* @param varName The CSS variable name starting with `--`.
|
||||
* @throws An Error if the CSS variable is invalid (e.g. has a single hyphen after "--global").
|
||||
* @returns The namespaced CSS variable name.
|
||||
*/
|
||||
export function namespaceCssVariable(varName: string): string {
|
||||
if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
|
||||
throw new Error(
|
||||
`CSS variable "${varName}" has a single hyphen after "--global". ` +
|
||||
`Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (varName.startsWith('--global--')) {
|
||||
return '--' + varName.substring('--global--'.length);
|
||||
} else {
|
||||
return '--%NS%' + varName.substring('--'.length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,20 +78,23 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken<boolean>(
|
||||
*
|
||||
* Typically set via {@link provideCssVarNamespacing}.
|
||||
*/
|
||||
export const CSS_VAR_NAMESPACE = new InjectionToken<string>('CSS_VAR_NAMESPACE');
|
||||
export const CSS_VAR_NAMESPACE = new InjectionToken<string>(
|
||||
typeof ngDevMode !== 'undefined' && ngDevMode ? 'CSS_VAR_NAMESPACE' : '',
|
||||
);
|
||||
|
||||
/**
|
||||
* Configures the application to use the given namespace for all CSS variables.
|
||||
*
|
||||
* @param namespace The prefix string to use as a namespace. This is typically the `APP_ID`
|
||||
* followed by a separator, such as 'my-app_'.
|
||||
* @param namespace The prefix string to use as a namespace. If not provided, it defaults
|
||||
* to the `APP_ID`. An underscore is appended unconditionally.
|
||||
* @publicApi
|
||||
*/
|
||||
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders {
|
||||
export function provideCssVarNamespacing(namespace?: string): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
{
|
||||
provide: CSS_VAR_NAMESPACE,
|
||||
useValue: namespace,
|
||||
useFactory: (appId: string) => `${namespace ?? appId}_`,
|
||||
deps: [APP_ID],
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -177,7 +180,13 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy {
|
||||
@Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null,
|
||||
) {
|
||||
this.cssVarNamespace = cssVarNamespace ?? '';
|
||||
this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService);
|
||||
this.defaultRenderer = new DefaultDomRenderer2(
|
||||
eventManager,
|
||||
doc,
|
||||
ngZone,
|
||||
this.tracingService,
|
||||
this.cssVarNamespace,
|
||||
);
|
||||
}
|
||||
|
||||
createRenderer(element: any, type: RendererType2 | null): Renderer2 {
|
||||
@@ -304,6 +313,7 @@ class DefaultDomRenderer2 implements Renderer2 {
|
||||
private readonly doc: Document,
|
||||
protected readonly ngZone: NgZone,
|
||||
private readonly tracingService: TracingService<TracingSnapshot> | null,
|
||||
private readonly cssVarNamespace: string = '',
|
||||
) {}
|
||||
|
||||
destroy(): void {}
|
||||
@@ -412,7 +422,11 @@ class DefaultDomRenderer2 implements Renderer2 {
|
||||
}
|
||||
|
||||
setStyle(el: any, style: string, value: any, flags: RendererStyleFlags2): void {
|
||||
if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
|
||||
const isVariable = style.startsWith('--');
|
||||
if (isVariable) {
|
||||
style = style.replace('%NS%', this.cssVarNamespace);
|
||||
}
|
||||
if (isVariable || flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
|
||||
el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : '');
|
||||
} else {
|
||||
el.style[style] = value;
|
||||
@@ -420,7 +434,11 @@ class DefaultDomRenderer2 implements Renderer2 {
|
||||
}
|
||||
|
||||
removeStyle(el: any, style: string, flags: RendererStyleFlags2): void {
|
||||
if (flags & RendererStyleFlags2.DashCase) {
|
||||
const isVariable = style.startsWith('--');
|
||||
if (isVariable) {
|
||||
style = style.replace('%NS%', this.cssVarNamespace);
|
||||
}
|
||||
if (isVariable || flags & RendererStyleFlags2.DashCase) {
|
||||
// removeProperty has no effect when used on camelCased properties.
|
||||
el.style.removeProperty(style);
|
||||
} else {
|
||||
@@ -538,7 +556,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 {
|
||||
cssVarNamespace: string,
|
||||
private sharedStylesHost?: SharedStylesHost,
|
||||
) {
|
||||
super(eventManager, doc, ngZone, tracingService);
|
||||
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
|
||||
this.shadowRoot = (hostEl as any).attachShadow({mode: 'open'});
|
||||
|
||||
// SharedStylesHost is used to add styles to the shadow root by ShadowDom.
|
||||
@@ -628,7 +646,7 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 {
|
||||
cssVarNamespace: string,
|
||||
compId?: string,
|
||||
) {
|
||||
super(eventManager, doc, ngZone, tracingService);
|
||||
super(eventManager, doc, ngZone, tracingService, cssVarNamespace);
|
||||
let styles = component.styles;
|
||||
if (ngDevMode) {
|
||||
// We only do this in development, as for production users should not add CSS sourcemaps to components.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {APP_ID} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
|
||||
import {CssVarNamespacer} from '../../src/dom/css_var_namespacer';
|
||||
@@ -14,7 +15,7 @@ import {provideCssVarNamespacing} from '../../src/dom/dom_renderer';
|
||||
describe('CssVarNamespacer', () => {
|
||||
it('should namespace variables when `CSS_VAR_NAMESPACE` is provided', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app_')],
|
||||
providers: [CssVarNamespacer, provideCssVarNamespacing('test-app')],
|
||||
});
|
||||
|
||||
const namespacer = TestBed.inject(CssVarNamespacer);
|
||||
@@ -22,6 +23,20 @@ describe('CssVarNamespacer', () => {
|
||||
expect(namespacer.namespace('--my-var')).toBe('--test-app_my-var');
|
||||
});
|
||||
|
||||
it('should fallback to `APP_ID` with an underscore when no namespace is provided to `provideCssVarNamespacing`', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CssVarNamespacer,
|
||||
{provide: APP_ID, useValue: 'custom-app'},
|
||||
provideCssVarNamespacing(),
|
||||
],
|
||||
});
|
||||
|
||||
const namespacer = TestBed.inject(CssVarNamespacer);
|
||||
|
||||
expect(namespacer.namespace('--my-var')).toBe('--custom-app_my-var');
|
||||
});
|
||||
|
||||
it('should not namespace variables when `CSS_VAR_NAMESPACE` is not provided', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [CssVarNamespacer],
|
||||
|
||||
@@ -361,7 +361,7 @@ describe('DefaultDomRendererV2', () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpNamespaceEmulated],
|
||||
providers: [provideCssVarNamespacing('my-namespace_')],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
const fixture = TestBed.createComponent(CmpNamespaceEmulated);
|
||||
fixture.detectChanges();
|
||||
@@ -384,7 +384,7 @@ describe('DefaultDomRendererV2', () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpNamespaceNone],
|
||||
providers: [provideCssVarNamespacing('my-namespace_')],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
const fixture = TestBed.createComponent(CmpNamespaceNone);
|
||||
fixture.detectChanges();
|
||||
@@ -407,7 +407,7 @@ describe('DefaultDomRendererV2', () => {
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpNamespaceShadow],
|
||||
providers: [provideCssVarNamespacing('my-namespace_')],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
const fixture = TestBed.createComponent(CmpNamespaceShadow);
|
||||
fixture.detectChanges();
|
||||
@@ -498,6 +498,73 @@ describe('DefaultDomRendererV2', () => {
|
||||
expect(css).toContain('var(--foo)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('style property bindings namespacing', () => {
|
||||
it('should namespace style property bindings starting with `--`', () => {
|
||||
@Component({
|
||||
selector: 'cmp-style-prop-namespace',
|
||||
template: `<div [style.--foo]="'blue'"></div>`,
|
||||
standalone: true,
|
||||
})
|
||||
class CmpStylePropNamespace {}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpStylePropNamespace],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
const fixture = TestBed.createComponent(CmpStylePropNamespace);
|
||||
fixture.detectChanges();
|
||||
|
||||
const div = fixture.nativeElement.querySelector('div');
|
||||
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
|
||||
expect(div.style.getPropertyValue('--foo')).toBe('');
|
||||
});
|
||||
|
||||
it('should throw an error if style property binding starts with `--global-` with a single hyphen', () => {
|
||||
@Component({
|
||||
selector: 'cmp-style-prop-error',
|
||||
template: `<div [style.--global-foo]="'blue'"></div>`,
|
||||
standalone: true,
|
||||
})
|
||||
class CmpStylePropError {}
|
||||
|
||||
expect(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpStylePropError],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
}).toThrowError(/CSS variable "--global-foo" has a single hyphen after "--global"/);
|
||||
});
|
||||
|
||||
it('should namespace styles set via Renderer2.setStyle/removeStyle', () => {
|
||||
@Component({
|
||||
selector: 'cmp-renderer-set-style',
|
||||
template: '',
|
||||
standalone: true,
|
||||
})
|
||||
class CmpRendererSetStyle {
|
||||
constructor(public renderer: Renderer2) {}
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CmpRendererSetStyle],
|
||||
providers: [provideCssVarNamespacing('my-namespace')],
|
||||
});
|
||||
const fixture = TestBed.createComponent(CmpRendererSetStyle);
|
||||
const comp = fixture.componentInstance;
|
||||
const div = document.createElement('div');
|
||||
|
||||
comp.renderer.setStyle(div, '--%NS%foo', 'blue');
|
||||
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('blue');
|
||||
|
||||
comp.renderer.setStyle(div, '--bar', 'red');
|
||||
expect(div.style.getPropertyValue('--bar')).toBe('red');
|
||||
expect(div.style.getPropertyValue('--my-namespace_bar')).toBe('');
|
||||
|
||||
comp.renderer.removeStyle(div, '--%NS%foo');
|
||||
expect(div.style.getPropertyValue('--my-namespace_foo')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user