diff --git a/goldens/public-api/platform-browser/index.api.md b/goldens/public-api/platform-browser/index.api.md index 60adc31f9a2d..9551b004f2c3 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; @@ -198,6 +207,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(): Provider[]; 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/src/render3/view/compiler.ts b/packages/compiler/src/render3/view/compiler.ts index 77ac761b57ac..82e510a56e59 100644 --- a/packages/compiler/src/render3/view/compiler.ts +++ b/packages/compiler/src/render3/view/compiler.ts @@ -11,7 +11,7 @@ import * as core from '../../core'; import {CssSelector} from '../../directive_matching'; 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'; @@ -300,10 +300,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 cadfaf55e85c..48b8cb8be1bb 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -1130,6 +1130,14 @@ 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 `/* DISABLE_NAMESPACING */` optionally followed by any characters +// that are not a semicolon or closing brace (e.g. whitespace or a property name), +// up until it hits a CSS variable `--some-name`. +const _cssNamespaceRe = new RegExp( + String.raw`(?:(/\*\s*DISABLE_NAMESPACING\s*\*/)[^;{}]*?)?(--[a-zA-Z0-9_-]+)`, + 'g', +); + export class CssRule { constructor( public selector: string, @@ -1137,6 +1145,21 @@ export class CssRule { ) {} } +/** + * 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 declaration or usage is preceded by `/* DISABLE_NAMESPACING *\/`, it is NOT transformed. + */ +export function namespaceCssVariables(cssText: string): string { + return cssText.replace(_cssNamespaceRe, (match, disableComment, varName) => { + if (disableComment) return match; + return `--%NS%${varName.substring('--'.length)}`; + }); +} + export function processRules(input: string, ruleCallback: (rule: CssRule) => CssRule): string { const escaped = escapeInStrings(input); const inputWithEscapedBlocks = escapeBlocks(escaped, CONTENT_PAIRS, BLOCK_PLACEHOLDER); diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 89b1a74efdab..a69629f976e1 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', () => { @@ -397,4 +398,91 @@ describe('ShadowCss', () => { ); }); }); + + 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 `/* DISABLE_NAMESPACING */` comment is present', () => { + const input = ` +.foo { + /* DISABLE_NAMESPACING */ + --my-color: green; + background: var( + /* DISABLE_NAMESPACING */ + --my-color + ); +} + `.trim(); + + const expected = ` +.foo { + /* DISABLE_NAMESPACING */ + --my-color: green; + background: var( + /* DISABLE_NAMESPACING */ + --my-color + ); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + it('should not inject `%NS%` when `/* DISABLE_NAMESPACING */` comment is present on the property declaration', () => { + const input = ` +.foo { + /* DISABLE_NAMESPACING */ + color: var(--my-color, blue); +} + `.trim(); + + const expected = ` +.foo { + /* DISABLE_NAMESPACING */ + color: var(--my-color, blue); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + + it('should correctly handle multiple `var()` functions with isolated `/* DISABLE_NAMESPACING */` comments', () => { + const input = ` +.foo { + border: var(/* DISABLE_NAMESPACING */ --border-size) solid var(--border-color); + box-shadow: + var(--shadow-1), + var(/* DISABLE_NAMESPACING */ --shadow-2), + var(--shadow-3); +} + `.trim(); + + const expected = ` +.foo { + border: var(/* DISABLE_NAMESPACING */ --border-size) solid var(--%NS%border-color); + box-shadow: + var(--%NS%shadow-1), + var(/* DISABLE_NAMESPACING */ --shadow-2), + var(--%NS%shadow-3); +} + `.trim(); + + expect(namespaceCssVariables(input)).toEqualCss(expected); + }); + }); }); 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 0186364ae23a..2775195fa8c5 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -55,6 +55,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 c1ccf31797b9..badb9f47eb20 100644 --- a/packages/core/test/bundling/create_component/bundle.golden_symbols.json +++ b/packages/core/test/bundling/create_component/bundle.golden_symbols.json @@ -32,6 +32,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 02df63f09355..7a808f640c85 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -14,6 +14,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 d3d4656805f8..1b713bc38bc3 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/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/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index d9d788172199..4823d2d4c653 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 fa9196f41537..786762cf9d89 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -40,6 +40,7 @@ "CONTENT_ATTR", "CONTEXT", "CSP_NONCE", + "CSS_VAR_NAMESPACE", "ChainedInjector", "ChangeDetectionScheduler", "ChangeDetectionSchedulerImpl", diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index 4fbd3a5fd610..22efcc2aa44a 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 c3291a91a21f..73955f2481a2 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -32,6 +32,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 87b51bc0e260..97d4853d510a 100644 --- a/packages/platform-browser/src/dom/dom_renderer.ts +++ b/packages/platform-browser/src/dom/dom_renderer.ts @@ -26,6 +26,8 @@ import { ɵTracingSnapshot as TracingSnapshot, Optional, ɵallLeavingAnimations as allLeavingAnimations, + makeEnvironmentProviders, + type EnvironmentProviders, } from '@angular/core'; import {RuntimeErrorCode} from '../errors'; @@ -33,6 +35,30 @@ import {RuntimeErrorCode} from '../errors'; import {EventManager} from './events/event_manager'; import {createLinkElement, SharedStylesHost} from './shared_styles_host'; +/** + * 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 const NAMESPACE_URIS: {[ns: string]: string} = { 'svg': 'http://www.w3.org/2000/svg', 'xhtml': 'http://www.w3.org/1999/xhtml', @@ -134,6 +160,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { EmulatedEncapsulationDomRenderer2 | NoneEncapsulationDomRenderer >(); private readonly defaultRenderer: Renderer2; + private readonly cssVarNamespace: string; constructor( private readonly eventManager: EventManager, @@ -146,7 +173,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); } @@ -200,6 +229,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; case ViewEncapsulation.ShadowDom: @@ -211,6 +241,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, sharedStylesHost, ); case ViewEncapsulation.ExperimentalIsolatedShadowDom: @@ -222,6 +253,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { ngZone, this.nonce, tracingService, + this.cssVarNamespace, ); default: @@ -233,6 +265,7 @@ export class DomRendererFactory2 implements RendererFactory2, OnDestroy { doc, ngZone, tracingService, + this.cssVarNamespace, ); break; } @@ -501,6 +534,7 @@ class ShadowDomRenderer extends DefaultDomRenderer2 { ngZone: NgZone, nonce: string | null, tracingService: TracingService | null, + cssVarNamespace: string, private sharedStylesHost?: SharedStylesHost, ) { super(eventManager, doc, ngZone, tracingService); @@ -518,7 +552,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'); @@ -588,6 +624,7 @@ class NoneEncapsulationDomRenderer extends DefaultDomRenderer2 { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + cssVarNamespace: string, compId?: string, ) { super(eventManager, doc, ngZone, tracingService); @@ -598,7 +635,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); } @@ -629,6 +667,7 @@ class EmulatedEncapsulationDomRenderer2 extends NoneEncapsulationDomRenderer { doc: Document, ngZone: NgZone, tracingService: TracingService | null, + cssVarNamespace: string, ) { const compId = appId + '-' + component.id; super( @@ -639,6 +678,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 d108085a07b6..ea26ec473478 100644 --- a/packages/platform-browser/src/platform-browser.ts +++ b/packages/platform-browser/src/platform-browser.ts @@ -17,6 +17,7 @@ export { export {Meta, MetaDefinition} from './browser/meta'; export {Title} from './browser/title'; export {disableDebugTools, enableDebugTools} from './browser/tools/tools'; +export {CssVarNamespacer} from './dom/css_var_namespacer'; export {By} from './dom/debug/by'; export {REMOVE_STYLES_ON_COMPONENT_DESTROY} from './dom/dom_renderer'; export {EVENT_MANAGER_PLUGINS, EventManager} from './dom/events/event_manager'; @@ -28,6 +29,7 @@ export { HammerLoader, HammerModule, } from './dom/events/hammer_gestures'; +export {provideCssVarNamespacing} from './dom/dom_renderer'; export { DomSanitizer, SafeHtml, 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..a6944dfd40ac --- /dev/null +++ b/packages/platform-browser/test/dom/css_var_namespacer_spec.ts @@ -0,0 +1,43 @@ +/** + * @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 5d00e88e231f..7701a27018fb 100644 --- a/packages/platform-browser/test/dom/dom_renderer_spec.ts +++ b/packages/platform-browser/test/dom/dom_renderer_spec.ts @@ -11,6 +11,7 @@ import {By} from '../../src/dom/debug/by'; import { addBaseHrefToCssSourceMap, NAMESPACE_URIS, + provideCssVarNamespacing, REMOVE_STYLES_ON_COMPONENT_DESTROY, } from '../../src/dom/dom_renderer'; import {expect} from '@angular/private/testing/matchers'; @@ -307,6 +308,166 @@ describe('DefaultDomRendererV2', () => { }); }); + 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('should support namespaces', () => { it('should create SVG elements', () => { expect(