diff --git a/goldens/public-api/platform-browser/index.api.md b/goldens/public-api/platform-browser/index.api.md index 8d123284bf8..a511fc7ec94 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 e3d79f87e1a..cec9497d290 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/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 9959c84773d..5b2b2bc0b48 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'; @@ -269,10 +269,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 7671c5bab0f..2979ff8a3b7 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -1033,6 +1033,36 @@ 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; + } + + 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/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 32148b61f28..2656dd14c9f 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,198 @@ 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); +} + `); + }); + }); }); diff --git a/packages/core/test/acceptance/csp_spec.ts b/packages/core/test/acceptance/csp_spec.ts index 06924c3979c..23cd90d10e7 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 b2a7da2856a..e676e2c62d4 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 1df8f3b7138..6fa3a7cdde3 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 08da59bce48..10972b74c69 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 82c368294e5..ad2e5ba5d80 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 600e48744a8..ddffa9d94a4 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 bc01eefdee0..081254f3868 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 4bfb687efc3..9fc67714bb4 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -48,6 +48,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 f39040273e1..5455e4f8ae0 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 00000000000..f6036bc18bd --- /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, Service} 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 22.1 + */ +@Service() +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 c4463bc6a7d..2eefd32834e 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,7 +174,9 @@ 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.cssVarNamespace = cssVarNamespace ?? ''; this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService); } @@ -201,6 +230,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; case ViewEncapsulation.ShadowDom: @@ -212,6 +242,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, sharedStylesHost, ); case ViewEncapsulation.ExperimentalIsolatedShadowDom: @@ -223,6 +254,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, ); default: @@ -234,6 +266,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; } @@ -502,6 +535,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { ngZone: NgZone, nonce: string | null, tracingService: TracingService | null, + cssVarNamespace: string, private sharedStylesHost?: SharedStylesHost, ) { super(eventManager, doc, ngZone, tracingService); @@ -519,7 +553,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,6 +625,7 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + cssVarNamespace: string, compId?: string, ) { super(eventManager, doc, ngZone, tracingService); @@ -599,7 +636,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 +668,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + cssVarNamespace: string, ) { const compId = appId + '-' + component.id; super( @@ -640,6 +679,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 8e3b27de3f5..6e61bd8165c 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 00000000000..2da85b35e8c --- /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 0d6c05d6658..ea1bb99691c 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,166 @@ 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('addBaseHrefToCssSourceMap', () => {