diff --git a/goldens/public-api/platform-browser/index.api.md b/goldens/public-api/platform-browser/index.api.md index 8d123284bf8d..a511fc7ec94f 100644 --- a/goldens/public-api/platform-browser/index.api.md +++ b/goldens/public-api/platform-browser/index.api.md @@ -54,6 +54,15 @@ export class By { // @public export function createApplication(options?: ApplicationConfig, context?: BootstrapContext): Promise; +// @public +export class CssVarNamespacer { + namespace(name: string): string; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵprov: i0.ɵɵInjectableDeclaration; +} + // @public export function disableDebugTools(): void; @@ -158,6 +167,9 @@ export const platformBrowser: (extraProviders?: StaticProvider[]) => PlatformRef // @public export function provideClientHydration(...features: HydrationFeature[]): EnvironmentProviders; +// @public +export function provideCssVarNamespacing(namespace: string): EnvironmentProviders; + // @public export function provideProtractorTestingSupport(options?: { usePendingTasksForStability?: boolean; diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/component_styles/encapsulation_default.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/component_styles/encapsulation_default.js index e3d79f87e1a7..cec9497d2906 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/component_styles/encapsulation_default.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/component_styles/encapsulation_default.js @@ -1 +1 @@ -styles: ["div.foo[_ngcontent-%COMP%] { color: red; }", "[_nghost-%COMP%] p[_ngcontent-%COMP%]:nth-child(even) { --webkit-transition: 1s linear all; }"] \ No newline at end of file +styles: ["div.foo[_ngcontent-%COMP%] { color: red; }", "[_nghost-%COMP%] p[_ngcontent-%COMP%]:nth-child(even) { --%NS%webkit-transition: 1s linear all; }"] \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js index c6f5c0691d5d..fb952a07a280 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/host_bindings/css_custom_properties.js @@ -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); } } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js index f6ae98875ae3..09e233d21b20 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_styling/style_bindings/css_custom_properties.js @@ -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); } } diff --git a/packages/compiler/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 2a68935853c8..769fda1c6968 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -10,7 +10,7 @@ import {ConstantPool} from '../../constant_pool'; import * as core from '../../core'; import * as o from '../../output/output_ast'; import {ParseError, ParseSourceSpan} from '../../parse_util'; -import {ShadowCss} from '../../shadow_css'; +import {namespaceCssVariables, ShadowCss} from '../../shadow_css'; import {CompilationJobKind, TemplateCompilationMode} from '../../template/pipeline/src/compilation'; import {emitHostBindingFunction, emitTemplateFn, transform} from '../../template/pipeline/src/emit'; import {ingestComponent, ingestHostBinding} from '../../template/pipeline/src/ingest'; @@ -268,10 +268,11 @@ export function compileComponentFromMetadata( let hasStyles = !!meta.externalStyles?.length; // e.g. `styles: [str1, str2]` if (meta.styles && meta.styles.length) { + const namespacedStyles = meta.styles.map((s) => namespaceCssVariables(s)); const styleValues = meta.encapsulation == core.ViewEncapsulation.Emulated - ? compileStyles(meta.styles, CONTENT_ATTR, HOST_ATTR) - : meta.styles; + ? compileStyles(namespacedStyles, CONTENT_ATTR, HOST_ATTR) + : namespacedStyles; const styleNodes = styleValues.reduce((result, style) => { if (style.trim().length > 0) { result.push(constantPool.getConstLiteral(o.literal(style))); diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 7671c5bab0f1..bc99627ce2e0 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ import * as chars from './chars'; +import {getInvalidCssGlobalError} from './util'; /** * The following set contains all keywords that can be used in the animation css shorthand @@ -1033,6 +1034,40 @@ const _cssCommaInPlaceholderReGlobal = new RegExp(COMMA_IN_PLACEHOLDER, 'g'); const _cssSemiInPlaceholderReGlobal = new RegExp(SEMI_IN_PLACEHOLDER, 'g'); const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g'); +// Matches any CSS variable name, defined by a double-hyphen followed by any valid ident. +// https://www.w3.org/TR/css-syntax-3/#ident-token-diagram +const _cssVariableRe = /(var\(\s*)?(--(?:[a-zA-Z0-9_-]|[^\x00-\x7F])+)(\s*:)?/g; + +/** + * Transforms CSS variables within a stylesheet to include a namespace placeholder. + * + * E.g. `--foo: bar;` becomes `--%NS%foo: bar;` + * E.g. `color: var(--foo);` becomes `color: var(--%NS%foo);` + * + * If a variable is prefixed with `--global--`, it is NOT namespaced and the prefix is removed. + * E.g. `--global--mycolor: red;` becomes `--mycolor: red;` + */ +export function namespaceCssVariables(cssText: string): string { + return cssText.replace(_cssVariableRe, (match, leadingVar, varName, trailingColon) => { + if (!leadingVar && !trailingColon) { + return match; + } + + if (varName.startsWith('--global-') && !varName.startsWith('--global--')) { + throw new Error(getInvalidCssGlobalError(varName)); + } + + let result; + if (varName.startsWith('--global--')) { + result = `--${varName.substring('--global--'.length)}`; + } else { + result = `--%NS%${varName.substring('--'.length)}`; + } + + return (leadingVar || '') + result + (trailingColon || ''); + }); +} + export class CssRule { constructor( public selector: string, diff --git a/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts b/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts index e353ee779a05..705017217bf3 100644 --- a/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts +++ b/packages/compiler/src/template/pipeline/src/phases/host_style_property_parsing.ts @@ -9,6 +9,7 @@ import * as ir from '../../ir'; import type {CompilationJob} from '../compilation'; +import {getInvalidCssGlobalError} from '../../../../util'; const STYLE_DOT = 'style.'; const CLASS_DOT = 'class.'; @@ -40,6 +41,15 @@ export function parseHostStyleProperties(job: CompilationJob): void { if (!isCssCustomProperty(op.name)) { op.name = hyphenate(op.name); + } else { + if (op.name.startsWith('--global-') && !op.name.startsWith('--global--')) { + throw new Error(getInvalidCssGlobalError(op.name)); + } + if (op.name.startsWith('--global--')) { + op.name = '--' + op.name.substring('--global--'.length); + } else { + op.name = '--%NS%' + op.name.substring('--'.length); + } } const {property, suffix} = parseProperty(op.name); diff --git a/packages/compiler/src/template_parser/binding_parser.ts b/packages/compiler/src/template_parser/binding_parser.ts index 35c461b218fc..a00d43d0cf8d 100644 --- a/packages/compiler/src/template_parser/binding_parser.ts +++ b/packages/compiler/src/template_parser/binding_parser.ts @@ -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 {getInvalidCssGlobalError, splitAtColon, splitAtPeriod} from '../util'; import {MATH_ML_NAMESPACE, SVG_NAMESPACE} from '../template/pipeline/src/namespaces'; const PROPERTY_PARTS_SEPARATOR = '.'; @@ -594,7 +594,19 @@ 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 { + if (boundName.startsWith('--global-') && !boundName.startsWith('--global--')) { + this._reportError(getInvalidCssGlobalError(boundName), boundProp.sourceSpan); + } + if (boundName.startsWith('--global--')) { + boundPropertyName = '--' + boundName.substring('--global--'.length); + } else { + boundPropertyName = '--%NS%' + boundName.substring('--'.length); + } + } bindingType = BindingType.Style; securityContexts = [SecurityContext.STYLE]; } else if (parts[0] == ANIMATE_PREFIX) { diff --git a/packages/compiler/src/util.ts b/packages/compiler/src/util.ts index 25c8ae860448..7cf39f55be55 100644 --- a/packages/compiler/src/util.ts +++ b/packages/compiler/src/util.ts @@ -153,3 +153,14 @@ export function getJitStandaloneDefaultForVersion(version: string): boolean { // All other Angular versions (v19+) default to true. return true; } + +/** + * Formats the compile-time error message for invalid global CSS variable names + * (i.e., those starting with `--global-` with a single hyphen instead of two). + */ +export function getInvalidCssGlobalError(varName: string): string { + return ( + `CSS variable "${varName}" has a single hyphen after "--global". ` + + `Use two hyphens ("--global--${varName.substring('--global-'.length)}") to opt-out of namespacing.` + ); +} diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 32148b61f281..d1d29ed930ea 100644 --- a/packages/compiler/test/shadow_css/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ +import {namespaceCssVariables} from '../../src/shadow_css'; import {shim} from './utils'; describe('ShadowCss', () => { @@ -390,4 +391,210 @@ describe('ShadowCss', () => { expect(shim('/* comment 1 */ /* comment 2 */ b {}', 'contenta')).toBe(' b[contenta] {}'); }); }); + + describe('CSS variable namespacing', () => { + it('should inject `%NS%` placeholder into CSS variable declarations and usages', () => { + const input = ` +.foo { + --my-color: red; + color: var(--my-color, blue); +} + `.trim(); + + const expected = ` +.foo { + --%NS%my-color: red; + color: var(--%NS%my-color, blue); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + + it('should not inject `%NS%` when `--global--` prefix is present', () => { + const input = ` +.foo { + --global--my-color: green; + background: var(--global--my-color); +} + `.trim(); + + const expected = ` +.foo { + --my-color: green; + background: var(--my-color); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + + it('should handle multiple variables with mixed namespacing', () => { + const input = ` +.foo { + border: var(--global--border-size) solid var(--border-color); + box-shadow: + var(--shadow-1), + var(--global--shadow-2), + var(--shadow-3); +} + `.trim(); + + const expected = ` +.foo { + border: var(--border-size) solid var(--%NS%border-color); + box-shadow: + var(--%NS%shadow-1), + var(--shadow-2), + var(--%NS%shadow-3); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + + it('should not namespace or modify -- in comments', () => { + expect(namespaceCssVariables('/* --bar */')).toBe('/* --bar */'); + expect(namespaceCssVariables('/* --global--bar */')).toBe('/* --global--bar */'); + }); + + it('should not namespace or modify -- in strings', () => { + expect(namespaceCssVariables('div { content: "--bar"; }')).toBe('div { content: "--bar"; }'); + expect(namespaceCssVariables('div { content: "--global--bar"; }')).toBe( + 'div { content: "--global--bar"; }', + ); + }); + + it('should not namespace or modify -- in the middle of identifiers', () => { + expect(namespaceCssVariables('.foo--bar { color: red; }')).toBe('.foo--bar { color: red; }'); + expect(namespaceCssVariables('.foo--global--bar { color: red; }')).toBe( + '.foo--global--bar { color: red; }', + ); + }); + + it('should not namespace or modify -- in attribute names', () => { + expect(namespaceCssVariables('[data---bar] { color: red; }')).toBe( + '[data---bar] { color: red; }', + ); + expect(namespaceCssVariables('[data---global--bar] { color: red; }')).toBe( + '[data---global--bar] { color: red; }', + ); + }); + + it('should not namespace or modify -- in unquoted attribute values', () => { + expect(namespaceCssVariables('[data-status=foo--bar] { color: red; }')).toBe( + '[data-status=foo--bar] { color: red; }', + ); + expect(namespaceCssVariables('[data-status=foo--global--bar] { color: red; }')).toBe( + '[data-status=foo--global--bar] { color: red; }', + ); + }); + + it('should not namespace or modify CDO and CDC tokens', () => { + expect(namespaceCssVariables('')).toBe('--global-->'); + }); + + it('should not namespace or modify -- in URLs', () => { + expect(namespaceCssVariables('div { background: url(--bar); }')).toBe( + 'div { background: url(--bar); }', + ); + expect(namespaceCssVariables('div { background: url(--global--bar); }')).toBe( + 'div { background: url(--global--bar); }', + ); + }); + + it('should not namespace or modify -- in custom media queries', () => { + expect(namespaceCssVariables('@custom-media --bar (max-width: 30em);')).toBe( + '@custom-media --bar (max-width: 30em);', + ); + expect(namespaceCssVariables('@custom-media --global--bar (max-width: 30em);')).toBe( + '@custom-media --global--bar (max-width: 30em);', + ); + }); + + it('should handle whitespace in variable declarations and usages', () => { + expect( + namespaceCssVariables(` +p { + color: var( + --bgc + ); + --bgc + : red; +} + `), + ).toBe(` +p { + color: var( + --%NS%bgc + ); + --%NS%bgc + : red; +} + `); + + expect( + namespaceCssVariables(` +p { + color: var( + --global--bgc + ); + --global--bgc + : red; +} + `), + ).toBe(` +p { + color: var( + --bgc + ); + --bgc + : red; +} + `); + }); + + it('should handle non-ascii characters', () => { + expect( + namespaceCssVariables(` +p { + --🅰️ngular: red; + color: var(--🅰️ngular); +} + `), + ).toEqualCss(` +p { + --%NS%🅰️ngular: red; + color: var(--%NS%🅰️ngular); +} + `); + + expect( + namespaceCssVariables(` +p { + --global--🅰️ngular: red; + color: var(--global--🅰️ngular); +} + `), + ).toEqualCss(` +p { + --🅰️ngular: red; + color: var(--🅰️ngular); +} + `); + }); + + it('should throw an error when a CSS variable has a single hyphen after --global', () => { + expect(() => namespaceCssVariables('p { --global-foo: red; }')).toThrowError( + 'CSS variable "--global-foo" has a single hyphen after "--global". Use two hyphens ("--global--foo") to opt-out of namespacing.', + ); + expect(() => namespaceCssVariables('p { color: var(--global-my-color); }')).toThrowError( + 'CSS variable "--global-my-color" has a single hyphen after "--global". Use two hyphens ("--global--my-color") to opt-out of namespacing.', + ); + expect(() => namespaceCssVariables('p { --global-: red; }')).toThrowError( + 'CSS variable "--global-" has a single hyphen after "--global". Use two hyphens ("--global--") to opt-out of namespacing.', + ); + }); + }); }); diff --git a/packages/core/test/acceptance/csp_spec.ts b/packages/core/test/acceptance/csp_spec.ts index 06924c3979c5..23cd90d10e71 100644 --- a/packages/core/test/acceptance/csp_spec.ts +++ b/packages/core/test/acceptance/csp_spec.ts @@ -30,7 +30,7 @@ describe('CSP integration', () => { for (let i = 0; i < styles.length; i++) { const style = styles[i]; const nonce = style.getAttribute('nonce'); - if (nonce && style.textContent?.includes('--csp-test-var')) { + if (nonce && style.textContent?.includes('csp-test-var')) { nonces.push(nonce); } } diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index be87b58e0565..e8de8d6b39ea 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -54,6 +54,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/core/test/bundling/create_component/bundle.golden_symbols.json b/packages/core/test/bundling/create_component/bundle.golden_symbols.json index fed3a869f115..2bf01f761b96 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -31,6 +31,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index adf543bd8e35..d1cc7e6c4c24 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -13,6 +13,7 @@ "COMPONENT_REGEX", "COMPONENT_VARIABLE", "CONTENT_ATTR", + "CSS_VAR_NAMESPACE", "DefaultDomRenderer2", "DomAdapter", "DomEventsPlugin", diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 0cae143dbd4f..3a4aef6d37b5 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -49,6 +49,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 099ed8a17a20..52818baa5858 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -48,6 +48,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index 2b39026bfaf1..ffa484da7575 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -48,6 +48,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "CachedInjectorService", "ChainedInjector", "ChangeDetectionScheduler", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index ecb572e7b2c2..4c6ecdec1cd8 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -47,6 +47,7 @@ "CONTEXT", "CREATE_VIEW_TRANSITION", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "CanActivate", "CanDeactivate", "ChainedInjector", diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 17795cb5ac89..46b6f08a5769 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -31,6 +31,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/platform-browser/src/dom/css_var_namespacer.ts b/packages/platform-browser/src/dom/css_var_namespacer.ts new file mode 100644 index 000000000000..380e0c900e18 --- /dev/null +++ b/packages/platform-browser/src/dom/css_var_namespacer.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {inject, Injectable} from '@angular/core'; +import {CSS_VAR_NAMESPACE} from './dom_renderer'; + +/** + * A service that can be used to manually namespace CSS variable names at runtime. + * This is useful when reading or setting CSS variables dynamically in JavaScript that + * were transformed by the compiler during the build. + * + * @publicApi + */ +@Injectable({providedIn: 'root'}) +export class CssVarNamespacer { + private readonly namespacePrefix = inject(CSS_VAR_NAMESPACE, {optional: true}) ?? ''; + + /** + * Prepends the namespace prefix to a CSS variable name. + * + * @param name The CSS variable name to namespace, including the leading `--`. + * @returns The namespaced CSS variable name, including the leading `--`. Returns the input + * unchanged if no namespace is configured. + */ + namespace(name: string): string { + // Validate that the whole `--foo` variable is passed in. + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!name.startsWith('--')) { + throw new Error( + `CSS variable names passed to \`CssVarNamespacer\` must start with '--', got: '${name}'`, + ); + } + } + + // We want to support libraries which might be used by applications which do and don't + // namespace variables. Therefore the library always needs to use `CssVarNamespacer`, even + // though the application may not actually be namespacing anything. + if (!this.namespacePrefix) return name; + + return `--${this.namespacePrefix}${name.substring('--'.length)}`; + } +} diff --git a/packages/platform-browser/src/dom/dom_renderer.ts b/packages/platform-browser/src/dom/dom_renderer.ts index c4463bc6a7da..9b14d2376d31 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -27,6 +27,8 @@ import { Optional, ɵallLeavingAnimations as allLeavingAnimations, ɵSHARED_STYLES_HOST as SHARED_STYLES_HOST, + makeEnvironmentProviders, + type EnvironmentProviders, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; @@ -70,6 +72,30 @@ export const REMOVE_STYLES_ON_COMPONENT_DESTROY = new InjectionToken( }, ); +/** + * An injection token that allows an application to configure a prefix to be used for all + * CSS variables generated compiled with CSS namespacing enabled. + * + * Typically set via {@link provideCssVarNamespacing}. + */ +export const CSS_VAR_NAMESPACE = new InjectionToken('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_'. + * @publicApi + */ +export function provideCssVarNamespacing(namespace: string): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: CSS_VAR_NAMESPACE, + useValue: namespace, + }, + ]); +} + export function shimContentAttribute(componentShortId: string): string { return CONTENT_ATTR.replace(COMPONENT_REGEX, componentShortId); } @@ -135,6 +161,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { EmulatedEncapsulationDomRenderer2 | NoneEncapsulationDomRenderer >(); private readonly defaultRenderer: Renderer2; + private readonly cssVarNamespace: string; constructor( private readonly eventManager: EventManager, @@ -147,8 +174,16 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { @Inject(TracingService) @Optional() private readonly tracingService: TracingService | null = null, + @Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null, ) { - this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService); + this.cssVarNamespace = cssVarNamespace ?? ''; + this.defaultRenderer = new DefaultDomRenderer2( + eventManager, + doc, + ngZone, + this.tracingService, + this.cssVarNamespace, + ); } createRenderer(element: any, type: RendererType2 | null): Renderer2 { @@ -201,6 +236,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; case ViewEncapsulation.ShadowDom: @@ -212,6 +248,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, sharedStylesHost, ); case ViewEncapsulation.ExperimentalIsolatedShadowDom: @@ -223,6 +260,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, ); default: @@ -234,6 +272,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; } @@ -271,6 +310,7 @@ class DefaultDomRenderer2 implements Renderer2 { private readonly doc: Document, protected readonly ngZone: NgZone, private readonly tracingService: TracingService | null, + private readonly cssVarNamespace: string = '', ) {} destroy(): void {} @@ -379,7 +419,10 @@ class DefaultDomRenderer2 implements Renderer2 { } setStyle(el: any, style: string, value: any, flags: RendererStyleFlags2): void { - if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) { + if (style.startsWith('--')) { + style = style.replace('%NS%', this.cssVarNamespace); + el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : ''); + } else if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) { el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : ''); } else { el.style[style] = value; @@ -387,7 +430,10 @@ class DefaultDomRenderer2 implements Renderer2 { } removeStyle(el: any, style: string, flags: RendererStyleFlags2): void { - if (flags & RendererStyleFlags2.DashCase) { + if (style.startsWith('--')) { + style = style.replace('%NS%', this.cssVarNamespace); + el.style.removeProperty(style); + } else if (flags & RendererStyleFlags2.DashCase) { // removeProperty has no effect when used on camelCased properties. el.style.removeProperty(style); } else { @@ -502,9 +548,10 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { ngZone: NgZone, nonce: string | null, tracingService: TracingService | null, + 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. @@ -519,7 +566,9 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { styles = addBaseHrefToCssSourceMap(baseHref, styles); } - styles = shimStylesContent(component.id, styles); + styles = shimStylesContent(component.id, styles).map((s) => + s.replace(/%NS%/g, cssVarNamespace), + ); for (const style of styles) { const styleEl = document.createElement('style'); @@ -589,9 +638,10 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + 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. @@ -599,7 +649,8 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 { styles = addBaseHrefToCssSourceMap(baseHref, styles); } - this.styles = compId ? shimStylesContent(compId, styles) : styles; + const shimmed = compId ? shimStylesContent(compId, styles) : styles; + this.styles = shimmed.map((s) => s.replace(/%NS%/g, cssVarNamespace)); this.styleUrls = component.getExternalStyles?.(compId); } @@ -630,6 +681,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + cssVarNamespace: string, ) { const compId = appId + '-' + component.id; super( @@ -640,6 +692,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { doc, ngZone, tracingService, + cssVarNamespace, compId, ); this.contentAttr = shimContentAttribute(compId); diff --git a/packages/platform-browser/src/platform-browser.ts b/packages/platform-browser/src/platform-browser.ts index 8e3b27de3f51..6e61bd8165cb 100644 --- a/packages/platform-browser/src/platform-browser.ts +++ b/packages/platform-browser/src/platform-browser.ts @@ -18,7 +18,8 @@ export {Meta, MetaDefinition} from './browser/meta'; export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; export {By} from './dom/debug/by'; -export {REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer'; +export {provideCssVarNamespacing, REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer'; +export {CssVarNamespacer} from './dom/css_var_namespacer'; export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager'; export {EventManagerPlugin} from './dom/events/event_manager_plugin'; export { diff --git a/packages/platform-browser/test/dom/css_var_namespacer_spec.ts b/packages/platform-browser/test/dom/css_var_namespacer_spec.ts new file mode 100644 index 000000000000..2da85b35e8c6 --- /dev/null +++ b/packages/platform-browser/test/dom/css_var_namespacer_spec.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {TestBed} from '@angular/core/testing'; + +import {CssVarNamespacer} from '../../src/dom/css_var_namespacer'; +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_')], + }); + + const namespacer = TestBed.inject(CssVarNamespacer); + + expect(namespacer.namespace('--my-var')).toBe('--test-app_my-var'); + }); + + it('should not namespace variables when `CSS_VAR_NAMESPACE` is not provided', () => { + TestBed.configureTestingModule({ + providers: [CssVarNamespacer], + }); + + const namespacer = TestBed.inject(CssVarNamespacer); + + expect(namespacer.namespace('--my-var')).toBe('--my-var'); + }); + + it('throws an error when a variable is passed in without the leading `--`', () => { + TestBed.configureTestingModule({ + providers: [CssVarNamespacer], + }); + + const namespacer = TestBed.inject(CssVarNamespacer); + + expect(() => namespacer.namespace('my-var')).toThrowError(/must start with '--'/); + }); +}); diff --git a/packages/platform-browser/test/dom/dom_renderer_spec.ts b/packages/platform-browser/test/dom/dom_renderer_spec.ts index 0d6c05d6658c..1427b152e339 100644 --- a/packages/platform-browser/test/dom/dom_renderer_spec.ts +++ b/packages/platform-browser/test/dom/dom_renderer_spec.ts @@ -14,6 +14,7 @@ import {By} from '../../src/dom/debug/by'; import { addBaseHrefToCssSourceMap, NAMESPACE_URIS, + provideCssVarNamespacing, REMOVE_STYLES_ON_COMPONENT_DESTROY, } from '../../src/dom/dom_renderer'; @@ -338,6 +339,233 @@ describe('DefaultDomRendererV2', () => { document.head.innerHTML = ''; }); + + describe('CSS namespacing', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + }); + + describe('with provided namespace', () => { + it('should replace `%NS%` in styles for `Emulated` encapsulation', async () => { + @Component({ + selector: 'cmp-namespace-emulated', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.Emulated, + }) + class CmpNamespaceEmulated {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceEmulated], + providers: [provideCssVarNamespacing('my-namespace_')], + }); + const fixture = TestBed.createComponent(CmpNamespaceEmulated); + fixture.detectChanges(); + + expect(await styleCount(fixture, 'var(--my-namespace_foo)')).toBe(1); + }); + + it('should replace `%NS%` in styles for `None` encapsulation', async () => { + @Component({ + selector: 'cmp-namespace-none', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.None, + }) + class CmpNamespaceNone {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceNone], + providers: [provideCssVarNamespacing('my-namespace_')], + }); + const fixture = TestBed.createComponent(CmpNamespaceNone); + fixture.detectChanges(); + + expect(await styleCount(fixture, 'var(--my-namespace_foo)')).toBe(1); + }); + + it('should replace `%NS%` in styles for `ShadowDom` encapsulation', () => { + @Component({ + selector: 'cmp-namespace-shadow', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.ShadowDom, + }) + class CmpNamespaceShadow {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceShadow], + providers: [provideCssVarNamespacing('my-namespace_')], + }); + const fixture = TestBed.createComponent(CmpNamespaceShadow); + fixture.detectChanges(); + + const styles = fixture.nativeElement.shadowRoot.querySelectorAll( + 'style', + ) as NodeListOf; + const css = Array.from(styles) + .map((s) => s.textContent) + .join('\n\n'); + expect(css).toContain('var(--my-namespace_foo)'); + }); + }); + + describe('with default (empty) namespace', () => { + it('should replace `%NS%` in styles for `Emulated` encapsulation', async () => { + @Component({ + selector: 'cmp-namespace-emulated', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.Emulated, + }) + class CmpNamespaceEmulated {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceEmulated], + providers: [], // No `provideCssVarNamespacing`. + }); + const fixture = TestBed.createComponent(CmpNamespaceEmulated); + fixture.detectChanges(); + + expect(await styleCount(fixture, 'var(--foo)')).toBe(1); + }); + + it('should replace `%NS%` in styles for `None` encapsulation', async () => { + @Component({ + selector: 'cmp-namespace-none', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.None, + }) + class CmpNamespaceNone {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceNone], + providers: [], // No `provideCssVarNamespacing`. + }); + const fixture = TestBed.createComponent(CmpNamespaceNone); + fixture.detectChanges(); + + expect(await styleCount(fixture, 'var(--foo)')).toBe(1); + }); + + it('should replace `%NS%` in styles for `ShadowDom` encapsulation', () => { + @Component({ + selector: 'cmp-namespace-shadow', + template: '', + styles: ` + :host { + color: var(--%NS%foo); + } + `, + encapsulation: ViewEncapsulation.ShadowDom, + }) + class CmpNamespaceShadow {} + + TestBed.configureTestingModule({ + imports: [CmpNamespaceShadow], + providers: [], // No `provideCssVarNamespacing`. + }); + const fixture = TestBed.createComponent(CmpNamespaceShadow); + fixture.detectChanges(); + + const styles = fixture.nativeElement.shadowRoot.querySelectorAll( + 'style', + ) as NodeListOf; + const css = Array.from(styles) + .map((s) => s.textContent) + .join('\n\n'); + 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: `
`, + 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: `
`, + 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(''); + }); + }); + }); }); describe('addBaseHrefToCssSourceMap', () => {