diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index be229811f9ff..42986e198e04 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -596,18 +596,21 @@ export class ShadowCss { let selector = rule.selector; let content = rule.content; if (rule.selector[0] !== '@') { - selector = this._scopeSelector({ - selector, - scopeSelector, - hostSelector, - isParentSelector: true, - }); + if (rule.isBlock) { + selector = this._scopeSelector({ + selector, + scopeSelector, + hostSelector, + isParentSelector: true, + }); + content = this._scopeSelectors(rule.content, scopeSelector, hostSelector); + } } else if (scopedAtRuleIdentifiers.some((atRule) => rule.selector.startsWith(atRule))) { content = this._scopeSelectors(rule.content, scopeSelector, hostSelector); } else if (rule.selector.startsWith('@font-face') || rule.selector.startsWith('@page')) { content = this._stripScopingSelectors(rule.content); } - return new CssRule(selector, content); + return new CssRule(selector, content, rule.isBlock); }); } @@ -637,7 +640,7 @@ export class ShadowCss { const selector = rule.selector .replace(_shadowDeepSelectors, ' ') .replace(_polyfillHostNoCombinatorRe, ' '); - return new CssRule(selector, rule.content); + return new CssRule(selector, rule.content, rule.isBlock); }); } @@ -748,7 +751,7 @@ export class ShadowCss { const _scopeSelectorPart = (p: string) => { let scopedP = p.trim(); - if (!scopedP) { + if (!scopedP || scopedP === '&') { return p; } @@ -1021,7 +1024,7 @@ const _commentWithHashPlaceHolderRe = new RegExp(COMMENT_PLACEHOLDER, 'g'); const BLOCK_PLACEHOLDER = '%BLOCK%'; const _ruleRe = new RegExp( - `(\\s*(?:${COMMENT_PLACEHOLDER}\\s*)*)([^;\\{\\}]+?)(\\s*)((?:{%BLOCK%}?\\s*;?)|(?:\\s*;))`, + `(\\s*(?:${COMMENT_PLACEHOLDER}\\s*)*)([^;\\{\\}]+?)(\\s*)((?:{%BLOCK%}?\\s*;?)|(?:\\s*;)|$)`, 'g', ); const CONTENT_PAIRS = new Map([['{', '}']]); @@ -1029,10 +1032,14 @@ const CONTENT_PAIRS = new Map([['{', '}']]); const COMMA_IN_PLACEHOLDER = '%COMMA_IN_PLACEHOLDER%'; const SEMI_IN_PLACEHOLDER = '%SEMI_IN_PLACEHOLDER%'; const COLON_IN_PLACEHOLDER = '%COLON_IN_PLACEHOLDER%'; +const LBRACE_IN_PLACEHOLDER = '%LBRACE_IN_PLACEHOLDER%'; +const RBRACE_IN_PLACEHOLDER = '%RBRACE_IN_PLACEHOLDER%'; const _cssCommaInPlaceholderReGlobal = new RegExp(COMMA_IN_PLACEHOLDER, 'g'); const _cssSemiInPlaceholderReGlobal = new RegExp(SEMI_IN_PLACEHOLDER, 'g'); const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g'); +const _cssLbraceInPlaceholderReGlobal = new RegExp(LBRACE_IN_PLACEHOLDER, 'g'); +const _cssRbraceInPlaceholderReGlobal = new RegExp(RBRACE_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 @@ -1067,8 +1074,9 @@ export function namespaceCssVariables(cssText: string): string { export class CssRule { constructor( - public selector: string, - public content: string, + readonly selector: string, + readonly content: string, + readonly isBlock: boolean, ) {} } @@ -1081,12 +1089,14 @@ export function processRules(input: string, ruleCallback: (rule: CssRule) => Css let content = ''; let suffix = m[4]; let contentPrefix = ''; + let hasBlock = false; if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) { content = inputWithEscapedBlocks.blocks[nextBlockIndex++]; suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1); contentPrefix = '{'; + hasBlock = true; } - const rule = ruleCallback(new CssRule(selector, content)); + const rule = ruleCallback(new CssRule(selector, content, hasBlock)); return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`; }); return unescapeInStrings(escapedResult); @@ -1155,6 +1165,8 @@ const ESCAPE_IN_STRING_MAP: {[key: string]: string} = { ';': SEMI_IN_PLACEHOLDER, ',': COMMA_IN_PLACEHOLDER, ':': COLON_IN_PLACEHOLDER, + '{': LBRACE_IN_PLACEHOLDER, + '}': RBRACE_IN_PLACEHOLDER, }; /** @@ -1225,6 +1237,8 @@ function unescapeInStrings(input: string): string { let result = input.replace(_cssCommaInPlaceholderReGlobal, ','); result = result.replace(_cssSemiInPlaceholderReGlobal, ';'); result = result.replace(_cssColonInPlaceholderReGlobal, ':'); + result = result.replace(_cssLbraceInPlaceholderReGlobal, '{'); + result = result.replace(_cssRbraceInPlaceholderReGlobal, '}'); return result; } diff --git a/packages/compiler/test/shadow_css/nesting_spec.ts b/packages/compiler/test/shadow_css/nesting_spec.ts new file mode 100644 index 000000000000..a9bfc6520b99 --- /dev/null +++ b/packages/compiler/test/shadow_css/nesting_spec.ts @@ -0,0 +1,377 @@ +/** + * @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 {shim} from './utils'; + +describe('ShadowCss nesting', () => { + it('should shim simple nested selector', () => { + const css = ` + .parent { + color: blue; + background: red; + + .child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + .child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim nested selector with ampersand', () => { + const css = ` + .parent { + color: blue; + background: red; + + & .child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + & .child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim selector with modifier applying to ampersand', () => { + const css = ` + .parent { + color: blue; + + &.modifier { + color: red; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + + &.modifier[contenta] { + color: red; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim selector with multiple ampersands', () => { + const css = ` + .parent { + color: blue; + + & ~ & { + color: red; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + + & ~ & { + color: red; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim nested selector with multiple child selectors', () => { + const css = ` + .parent { + color: blue; + background: red; + + .child { + color: red; + background: blue; + } + + .other-child { + color: green; + background: yellow; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + .child[contenta] { + color: red; + background: blue; + } + + .other-child[contenta] { + color: green; + background: yellow; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim nested selector with comma-separated child selectors', () => { + const css = ` + .parent { + color: blue; + background: red; + + .child, .other-child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + .child[contenta], .other-child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim nested selector targeting a direct descendant', () => { + const css = ` + .parent { + color: blue; + background: red; + + > .child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + > .child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim multiple levels of nested selectors', () => { + const css = ` + .parent { + color: blue; + background: red; + + > .child { + color: red; + background: blue; + + > .grand-child { + color: green; + background: orange; + + .great-grand-child { + color: orange; + background: green; + } + } + } + } + `; + + const expected = ` + .parent[contenta] { + color: blue; + background: red; + + > .child[contenta] { + color: red; + background: blue; + + > .grand-child[contenta] { + color: green; + background: orange; + + .great-grand-child[contenta] { + color: orange; + background: green; + } + } + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim selectors nested in :host', () => { + const css = ` + :host(.foo) { + color: blue; + background: red; + + .child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .foo[a-host] { + color: blue; + background: red; + + .child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta', 'a-host'); + expect(result).toEqualCss(expected); + }); + + it('should shim selectors nested in :host-context', () => { + const css = ` + :host-context(.foo) { + color: blue; + background: red; + + .child { + color: red; + background: blue; + } + } + `; + + const expected = ` + .foo[a-host], .foo [a-host] { + color: blue; + background: red; + + .child[contenta] { + color: red; + background: blue; + } + } + `; + + const result = shim(css, 'contenta', 'a-host'); + expect(result).toEqualCss(expected); + }); + + it('should shim a selector with a nested media query', () => { + const css = ` + .parent { + color: red; + + @media (width >= 1024px) { + color: blue; + } + } + `; + + const expected = ` + .parent[contenta] { + color: red; + + @media (width >= 1024px) { + color: blue; + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); + + it('should shim a selector with a nested media query and an ampersand', () => { + const css = ` + .parent { + color: red; + + @media (width >= 1024px) { + &.modifier { + color: blue; + } + } + } + `; + + const expected = ` + .parent[contenta] { + color: red; + + @media (width >= 1024px) { + &.modifier[contenta] { + color: blue; + } + } + } + `; + + const result = shim(css, 'contenta'); + expect(result).toEqualCss(expected); + }); +}); diff --git a/packages/compiler/test/shadow_css/process_rules_spec.ts b/packages/compiler/test/shadow_css/process_rules_spec.ts index 1227cc45af2f..91375ac21173 100644 --- a/packages/compiler/test/shadow_css/process_rules_spec.ts +++ b/packages/compiler/test/shadow_css/process_rules_spec.ts @@ -24,24 +24,24 @@ describe('ShadowCss, processRules', () => { }); it('should capture a rule without body', () => { - expect(captureRules('a;')).toEqual([new CssRule('a', '')]); + expect(captureRules('a;')).toEqual([new CssRule('a', '', false)]); }); it('should capture css rules with body', () => { - expect(captureRules('a {b}')).toEqual([new CssRule('a', 'b')]); + expect(captureRules('a {b}')).toEqual([new CssRule('a', 'b', true)]); }); it('should capture css rules with nested rules', () => { expect(captureRules('a {b {c}} d {e}')).toEqual([ - new CssRule('a', 'b {c}'), - new CssRule('d', 'e'), + new CssRule('a', 'b {c}', true), + new CssRule('d', 'e', true), ]); }); it('should capture multiple rules where some have no body', () => { expect(captureRules('@import a ; b {c}')).toEqual([ - new CssRule('@import a', ''), - new CssRule('b', 'c'), + new CssRule('@import a', '', false), + new CssRule('b', 'c', true), ]); }); }); @@ -51,7 +51,7 @@ describe('ShadowCss, processRules', () => { expect( processRules( '@import a; b {c {d}} e {f}', - (cssRule: CssRule) => new CssRule(cssRule.selector + '2', cssRule.content), + (cssRule: CssRule) => new CssRule(cssRule.selector + '2', cssRule.content, false), ), ).toEqual('@import a2; b2 {c {d}} e2 {f}'); }); @@ -60,7 +60,7 @@ describe('ShadowCss, processRules', () => { expect( processRules( 'a {b}', - (cssRule: CssRule) => new CssRule(cssRule.selector, cssRule.content + '2'), + (cssRule: CssRule) => new CssRule(cssRule.selector, cssRule.content + '2', false), ), ).toEqual('a {b2}'); });