diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 66bdbb7d604b..7c864eb2f7c5 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -47,6 +47,13 @@ const animationKeywords = new Set([ 'start', ]); +/** Contains parts of selectors that should never be scoped. */ +const unscopeableSelectorParts = new Set([ + // Ampersands don't need scoping, because it's assumed that + // the selector above them has been scoped already. + '&', +]); + /** * The following array contains all of the CSS at-rule identifiers which are scoped. */ @@ -615,14 +622,15 @@ export class ShadowCss { return processRules(cssText, (rule: CssRule) => { let selector = rule.selector; let content = rule.content; - if (rule.selector[0] !== '@') { + if (rule.selector[0] !== '@' && rule.isBlock) { selector = this._scopeSelector(rule.selector, scopeSelector, hostSelector); + 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); }); } @@ -652,7 +660,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); }); } @@ -730,7 +738,11 @@ export class ShadowCss { return p; } - if (p.includes(_polyfillHostNoCombinator)) { + if (unscopeableSelectorParts.has(scopedP)) { + return scopedP; + } + + if (p.indexOf(_polyfillHostNoCombinator) > -1) { scopedP = this._applySimpleSelectorScope(p, scopeSelector, hostSelector); } else { // remove :host since it should be unnecessary @@ -905,6 +917,7 @@ const _ruleRe = new RegExp( 'g', ); const CONTENT_PAIRS = new Map([['{', '}']]); +const QUOTES = new Set([`'`, `"`]); const COMMA_IN_PLACEHOLDER = '%COMMA_IN_PLACEHOLDER%'; const SEMI_IN_PLACEHOLDER = '%SEMI_IN_PLACEHOLDER%'; @@ -918,6 +931,7 @@ export class CssRule { constructor( public selector: string, public content: string, + readonly isBlock: boolean, ) {} } @@ -930,12 +944,14 @@ export function processRules(input: string, ruleCallback: (rule: CssRule) => Css let content = ''; let suffix = m[4]; let contentPrefix = ''; + let isBlock = false; if (suffix && suffix.startsWith('{' + BLOCK_PLACEHOLDER)) { content = inputWithEscapedBlocks.blocks[nextBlockIndex++]; suffix = suffix.substring(BLOCK_PLACEHOLDER.length + 1); contentPrefix = '{'; + isBlock = true; } - const rule = ruleCallback(new CssRule(selector, content)); + const rule = ruleCallback(new CssRule(selector, content, isBlock)); return `${m[1]}${rule.selector}${m[3]}${contentPrefix}${rule.content}${suffix}`; }); return unescapeInStrings(escapedResult); @@ -960,11 +976,18 @@ function escapeBlocks( let blockStartIndex = -1; let openChar: string | undefined; let closeChar: string | undefined; + let currentQuote: string | undefined; for (let i = 0; i < input.length; i++) { const char = input[i]; if (char === '\\') { i++; + } else if (QUOTES.has(char)) { + if (!currentQuote) { + currentQuote = char; + } else if (currentQuote === char) { + currentQuote = undefined; + } } else if (char === closeChar) { openCharCount--; if (openCharCount === 0) { @@ -976,7 +999,7 @@ function escapeBlocks( } } else if (char === openChar) { openCharCount++; - } else if (openCharCount === 0 && charPairs.has(char)) { + } else if (openCharCount === 0 && charPairs.has(char) && !currentQuote) { openChar = char; closeChar = charPairs.get(char); openCharCount = 1; diff --git a/packages/compiler/test/shadow_css/keyframes_spec.ts b/packages/compiler/test/shadow_css/keyframes_spec.ts index 97344480eea1..5854d9a3b49b 100644 --- a/packages/compiler/test/shadow_css/keyframes_spec.ts +++ b/packages/compiler/test/shadow_css/keyframes_spec.ts @@ -129,12 +129,12 @@ describe('ShadowCss, keyframes and animations', () => { const css = `.test { animation:my-anim 1s,my-anim2 2s, my-anim3 3s,my-anim4 4s; } - + @keyframes my-anim { 0% {color: red} 100% {color: blue} } - + @keyframes my-anim2 { 0% {font-size: 1em} 100% {font-size: 1.2em} @@ -162,12 +162,12 @@ describe('ShadowCss, keyframes and animations', () => { const css = `.test { animation:, my-anim 1s,my-anim2 2s, my-anim3 3s,my-anim4 4s; } - + @keyframes my-anim { 0% {color: red} 100% {color: blue} } - + @keyframes my-anim2 { 0% {font-size: 1em} 100% {font-size: 1.2em} @@ -460,14 +460,14 @@ describe('ShadowCss, keyframes and animations', () => { div { animation: 1s 'foo', 1.5s 'bar'; animation: 2s 'fo\\'o', 2.5s 'bar'; - animation: 3s 'foo\\'', 3.5s 'bar', 3.7s 'ba\\'r'; + animation: 3s 'foo', 3.5s 'bar', 3.7s 'ba\\'r'; animation: 4s 'foo\\\\', 4.5s 'bar', 4.7s 'baz\\''; animation: 5s 'fo\\\\\\'o', 5.5s 'bar', 5.7s 'baz\\''; } @keyframes foo {} @keyframes 'fo\\'o' {} - @keyframes 'foo'' {} + @keyframes 'foo' {} @keyframes 'foo\\\\' {} @keyframes "bar" {} @keyframes 'ba\\'r' {} @@ -476,7 +476,7 @@ describe('ShadowCss, keyframes and animations', () => { const result = shim(css, 'host-a'); expect(result).toContain('@keyframes host-a_foo {}'); expect(result).toContain("@keyframes 'host-a_fo\\'o' {}"); - expect(result).toContain("@keyframes 'host-a_foo'' {}"); + expect(result).toContain("@keyframes 'host-a_foo' {}"); expect(result).toContain("@keyframes 'host-a_foo\\\\' {}"); expect(result).toContain('@keyframes "host-a_bar" {}'); expect(result).toContain("@keyframes 'host-a_ba\\'r' {}"); @@ -484,7 +484,7 @@ describe('ShadowCss, keyframes and animations', () => { expect(result).toContain("animation: 1s 'host-a_foo', 1.5s 'host-a_bar';"); expect(result).toContain("animation: 2s 'host-a_fo\\'o', 2.5s 'host-a_bar';"); expect(result).toContain( - "animation: 3s 'host-a_foo\\'', 3.5s 'host-a_bar', 3.7s 'host-a_ba\\'r';", + "animation: 3s 'host-a_foo', 3.5s 'host-a_bar', 3.7s 'host-a_ba\\'r';", ); expect(result).toContain("animation: 4s 'host-a_foo\\\\', 4.5s 'host-a_bar', 4.7s 'baz\\'';"); expect(result).toContain("animation: 5s 'host-a_fo\\\\\\'o', 5.5s 'host-a_bar', 5.7s 'baz\\''"); diff --git a/packages/compiler/test/shadow_css/nested_spec.ts b/packages/compiler/test/shadow_css/nested_spec.ts new file mode 100644 index 000000000000..03cfffc64b13 --- /dev/null +++ b/packages/compiler/test/shadow_css/nested_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.io/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 018e0b33827f..61a86300fdbc 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, true), ), ).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', true), ), ).toEqual('a {b2}'); });