diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index 1d7988568fbf..0a40a35f67f0 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -734,16 +734,18 @@ export class ShadowCss { _polyfillHostRe.lastIndex = 0; if (_polyfillHostRe.test(selector)) { const replaceBy = `[${hostSelector}]`; - return selector - .replace(_polyfillHostNoCombinatorReGlobal, (_hnc, selector) => { + let result = selector; + while (result.match(_polyfillHostNoCombinatorRe)) { + result = result.replace(_polyfillHostNoCombinatorRe, (_hnc, selector) => { return selector.replace( /([^:\)]*)(:*)(.*)/, (_: string, before: string, colon: string, after: string) => { return before + replaceBy + colon + after; }, ); - }) - .replace(_polyfillHostRe, replaceBy + ' '); + }); + } + return result.replace(_polyfillHostRe, replaceBy); } return scopeSelector + ' ' + selector; @@ -765,7 +767,7 @@ export class ShadowCss { const isRe = /\[is=([^\]]*)\]/g; scopeSelector = scopeSelector.replace(isRe, (_: string, ...parts: string[]) => parts[0]); - const attrName = '[' + scopeSelector + ']'; + const attrName = `[${scopeSelector}]`; const _scopeSelectorPart = (p: string) => { let scopedP = p.trim(); @@ -776,15 +778,15 @@ export class ShadowCss { if (p.includes(_polyfillHostNoCombinator)) { scopedP = this._applySimpleSelectorScope(p, scopeSelector, hostSelector); - if (_polyfillHostNoCombinatorWithinPseudoFunction.test(p)) { - const [_, before, colon, after] = scopedP.match(/([^:]*)(:*)(.*)/)!; + if (!p.match(_polyfillHostNoCombinatorOutsidePseudoFunction)) { + const [_, before, colon, after] = scopedP.match(/([^:]*)(:*)([\s\S]*)/)!; scopedP = before + attrName + colon + after; } } else { // remove :host since it should be unnecessary const t = p.replace(_polyfillHostRe, ''); if (t.length > 0) { - const matches = t.match(/([^:]*)(:*)(.*)/); + const matches = t.match(/([^:]*)(:*)([\s\S]*)/); if (matches) { scopedP = matches[1] + attrName + matches[2] + matches[3]; } @@ -800,30 +802,67 @@ export class ShadowCss { const _pseudoFunctionAwareScopeSelectorPart = (selectorPart: string) => { let scopedPart = ''; - const cssPrefixWithPseudoSelectorFunctionMatch = selectorPart.match( - _cssPrefixWithPseudoSelectorFunction, - ); - if (cssPrefixWithPseudoSelectorFunctionMatch) { - const [cssPseudoSelectorFunction] = cssPrefixWithPseudoSelectorFunctionMatch; + // Collect all outer `:where()` and `:is()` selectors, + // counting parenthesis to keep nested selectors intact. + const pseudoSelectorParts = []; + let pseudoSelectorMatch: RegExpExecArray | null; + while ( + (pseudoSelectorMatch = _cssPrefixWithPseudoSelectorFunction.exec(selectorPart)) !== null + ) { + let openedBrackets = 1; + let index = _cssPrefixWithPseudoSelectorFunction.lastIndex; + + while (index < selectorPart.length) { + const currentSymbol = selectorPart[index]; + index++; + if (currentSymbol === '(') { + openedBrackets++; + continue; + } + if (currentSymbol === ')') { + openedBrackets--; + if (openedBrackets === 0) { + break; + } + continue; + } + } - // Unwrap the pseudo selector to scope its contents. - // For example, - // - `:where(selectorToScope)` -> `selectorToScope`; - // - `:is(.foo, .bar)` -> `.foo, .bar`. - const selectorToScope = selectorPart.slice(cssPseudoSelectorFunction.length, -1); + pseudoSelectorParts.push( + `${pseudoSelectorMatch[0]}${selectorPart.slice(_cssPrefixWithPseudoSelectorFunction.lastIndex, index)}`, + ); + _cssPrefixWithPseudoSelectorFunction.lastIndex = index; + } - if (selectorToScope.includes(_polyfillHostNoCombinator)) { - this._shouldScopeIndicator = true; - } + // If selector consists of only `:where()` and `:is()` on the outer level + // scope those pseudo-selectors individually, otherwise scope the whole + // selector. + if (pseudoSelectorParts.join('') === selectorPart) { + scopedPart = pseudoSelectorParts + .map((selectorPart) => { + const [cssPseudoSelectorFunction] = + selectorPart.match(_cssPrefixWithPseudoSelectorFunction) ?? []; + + // Unwrap the pseudo selector to scope its contents. + // For example, + // - `:where(selectorToScope)` -> `selectorToScope`; + // - `:is(.foo, .bar)` -> `.foo, .bar`. + const selectorToScope = selectorPart.slice(cssPseudoSelectorFunction?.length, -1); + + if (selectorToScope.includes(_polyfillHostNoCombinator)) { + this._shouldScopeIndicator = true; + } - const scopedInnerPart = this._scopeSelector({ - selector: selectorToScope, - scopeSelector, - hostSelector, - }); + const scopedInnerPart = this._scopeSelector({ + selector: selectorToScope, + scopeSelector, + hostSelector, + }); - // Put the result back into the pseudo selector function. - scopedPart = `${cssPseudoSelectorFunction}${scopedInnerPart})`; + // Put the result back into the pseudo selector function. + return `${cssPseudoSelectorFunction}${scopedInnerPart})`; + }) + .join(''); } else { this._shouldScopeIndicator = this._shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator); @@ -962,7 +1001,7 @@ class SafeSelector { } const _cssScopedPseudoFunctionPrefix = '(:(where|is)\\()?'; -const _cssPrefixWithPseudoSelectorFunction = /^:(where|is)\(/i; +const _cssPrefixWithPseudoSelectorFunction = /:(where|is)\(/gi; const _cssContentNextSelectorRe = /polyfill-next-selector[^}]*content:[\s]*?(['"])(.*?)\1[;\s]*}([^{]*?){/gim; const _cssContentRuleRe = /(polyfill-rule)[^}]*(content:[\s]*(['"])(.*?)\3)[;\s]*[^}]*}/gim; @@ -979,11 +1018,11 @@ const _cssColonHostContextReGlobal = new RegExp( ); const _cssColonHostContextRe = new RegExp(_polyfillHostContext + _parenSuffix, 'im'); const _polyfillHostNoCombinator = _polyfillHost + '-no-combinator'; -const _polyfillHostNoCombinatorWithinPseudoFunction = new RegExp( - `:.*\\(.*${_polyfillHostNoCombinator}.*\\)`, +const _polyfillHostNoCombinatorOutsidePseudoFunction = new RegExp( + `${_polyfillHostNoCombinator}(?![^(]*\\))`, + 'g', ); -const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s]*)/; -const _polyfillHostNoCombinatorReGlobal = new RegExp(_polyfillHostNoCombinatorRe, 'g'); +const _polyfillHostNoCombinatorRe = /-shadowcsshost-no-combinator([^\s,]*)/; const _shadowDOMSelectorsRe = [ /::shadow/g, /::content/g, diff --git a/packages/compiler/test/shadow_css/host_and_host_context_spec.ts b/packages/compiler/test/shadow_css/host_and_host_context_spec.ts index 5b5689feb2be..6bef826d196b 100644 --- a/packages/compiler/test/shadow_css/host_and_host_context_spec.ts +++ b/packages/compiler/test/shadow_css/host_and_host_context_spec.ts @@ -83,6 +83,21 @@ describe('ShadowCss, :host and :host-context', () => { expect(shim(':host(:not(p)):before {}', 'contenta', 'a-host')).toEqualCss( '[a-host]:not(p):before {}', ); + expect(shim(':host:not(:host.foo) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:not([a-host].foo) {}', + ); + expect(shim(':host:not(.foo:host) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:not(.foo[a-host]) {}', + ); + expect(shim(':host:not(:host.foo, :host.bar) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:not([a-host].foo, .bar[a-host]) {}', + ); + expect(shim(':host:not(:host.foo, .bar :host) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:not([a-host].foo, .bar [a-host]) {}', + ); + expect(shim(':host:not(.foo, .bar) {}', 'contenta', 'a-host')).toEqualCss( + '[a-host]:not(.foo, .bar) {}', + ); }); // see b/63672152 @@ -104,6 +119,17 @@ describe('ShadowCss, :host and :host-context', () => { 'cmp [a-host] child {}', ); }); + + it('should support newlines in the same selector and content ', () => { + const selector = `.foo:not( + :host) { + background-color: + green; + }`; + expect(shim(selector, 'contenta', 'a-host')).toEqualCss( + '.foo[contenta]:not( [a-host]) { background-color:green;}', + ); + }); }); describe(':host-context', () => { @@ -240,6 +266,18 @@ describe('ShadowCss, :host and :host-context', () => { ); }); + it('should handle no selector :host', () => { + // The second selector below should have a `[a-host]` attribute selector + // attached to `.one`, current `:host-context` unwrapping logic doesn't + // work correctly on prefixed selectors, see #58345. + expect(shim(':host:host-context(.one) {}', 'contenta', 'a-host')).toEqualCss( + '.one[a-host][a-host], .one [a-host] {}', + ); + expect(shim(':host-context(.one) :host {}', 'contenta', 'a-host')).toEqualCss( + '.one [a-host] {}', + ); + }); + it('should handle selectors on different elements', () => { expect(shim(':host-context(div) :host(.x) > .y {}', 'contenta', 'a-host')).toEqualCss( 'div .x[a-host] > .y[contenta] {}', diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 77a0a361a319..8705f65e5e6a 100644 --- a/packages/compiler/test/shadow_css/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -47,6 +47,17 @@ describe('ShadowCss', () => { expect(shim(css, 'contenta')).toEqualCss(expected); }); + it('should support newlines in the same selector and content ', () => { + const selector = `.foo:not( + .bar) { + background-color: + green; + }`; + expect(shim(selector, 'contenta', 'a-host')).toEqualCss( + '.foo[contenta]:not( .bar) { background-color:green;}', + ); + }); + it('should handle complicated selectors', () => { expect(shim('one::before {}', 'contenta')).toEqualCss('one[contenta]::before {}'); expect(shim('one two {}', 'contenta')).toEqualCss('one[contenta] two[contenta] {}'); @@ -93,9 +104,8 @@ describe('ShadowCss', () => { 'div[contenta]:where(.one) {}', ); expect(shim('div:where() {}', 'contenta', 'hosta')).toEqualCss('div[contenta]:where() {}'); - // See `xit('should parse concatenated pseudo selectors'` expect(shim(':where(a):where(b) {}', 'contenta', 'hosta')).toEqualCss( - ':where(a)[contenta]:where(b) {}', + ':where(a[contenta]):where(b[contenta]) {}', ); expect(shim('*:where(.one) {}', 'contenta', 'hosta')).toEqualCss('*[contenta]:where(.one) {}'); expect(shim('*:where(.one) ::ng-deep .foo {}', 'contenta', 'hosta')).toEqualCss( @@ -103,15 +113,6 @@ describe('ShadowCss', () => { ); }); - xit('should parse concatenated pseudo selectors', () => { - // Current logic leads to a result with an outer scope - // It could be changed, to not increase specificity - // Requires a more complex parsing - expect(shim(':where(a):where(b) {}', 'contenta', 'hosta')).toEqualCss( - ':where(a[contenta]):where(b[contenta]) {}', - ); - }); - it('should handle pseudo functions correctly', () => { // :where() expect(shim(':where(.one) {}', 'contenta', 'hosta')).toEqualCss(':where(.one[contenta]) {}'); @@ -200,6 +201,18 @@ describe('ShadowCss', () => { ).toEqualCss( ':where(:where(a[contenta]:has(.foo), b[contenta]) :is(.one[contenta], .two[contenta]:where(.foo > .bar))) {}', ); + expect(shim(':where(.two):first-child {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:where(.two):first-child {}', + ); + expect(shim(':first-child:where(.two) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:first-child:where(.two) {}', + ); + expect(shim(':where(.two):nth-child(3) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:where(.two):nth-child(3) {}', + ); + expect(shim('table :where(td, th):hover { color: lime; }', 'contenta', 'hosta')).toEqualCss( + 'table[contenta] [contenta]:where(td, th):hover { color:lime;}', + ); // complex selectors expect(shim(':host:is([foo],[foo-2])>div.example-2 {}', 'contenta', 'a-host')).toEqualCss( @@ -229,6 +242,24 @@ describe('ShadowCss', () => { expect(shim(':has(a) :has(b) {}', 'contenta', 'hosta')).toEqualCss( '[contenta]:has(a) [contenta]:has(b) {}', ); + expect(shim(':has(a, b) {}', 'contenta', 'hosta')).toEqualCss('[contenta]:has(a, b) {}'); + expect(shim(':has(a, b:where(.foo), :is(.bar)) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:has(a, b:where(.foo), :is(.bar)) {}', + ); + expect( + shim(':has(a, b:where(.foo), :is(.bar):first-child):first-letter {}', 'contenta', 'hosta'), + ).toEqualCss('[contenta]:has(a, b:where(.foo), :is(.bar):first-child):first-letter {}'); + expect( + shim(':where(a, b:where(.foo), :has(.bar):first-child) {}', 'contenta', 'hosta'), + ).toEqualCss( + ':where(a[contenta], b[contenta]:where(.foo), [contenta]:has(.bar):first-child) {}', + ); + expect(shim(':has(.one :host, .two) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:has(.one [hosta], .two) {}', + ); + expect(shim(':has(.one, :host) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:has(.one, [hosta]) {}', + ); }); it('should handle :host inclusions inside pseudo-selectors selectors', () => { @@ -254,6 +285,18 @@ describe('ShadowCss', () => { expect(shim('.one :where(:host, .two) {}', 'contenta', 'hosta')).toEqualCss( '.one :where([hosta], .two[contenta]) {}', ); + expect(shim(':is(.foo):is(:host):is(.two) {}', 'contenta', 'hosta')).toEqualCss( + ':is(.foo):is([hosta]):is(.two[contenta]) {}', + ); + expect(shim(':where(.one, :host .two):first-letter {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:where(.one, [hosta] .two):first-letter {}', + ); + expect(shim(':first-child:where(.one, :host .two) {}', 'contenta', 'hosta')).toEqualCss( + '[contenta]:first-child:where(.one, [hosta] .two) {}', + ); + expect( + shim(':where(.one, :host .two):nth-child(3):is(.foo, a:where(.bar)) {}', 'contenta', 'hosta'), + ).toEqualCss('[contenta]:where(.one, [hosta] .two):nth-child(3):is(.foo, a:where(.bar)) {}'); }); it('should handle escaped selector with space (if followed by a hex char)', () => {