diff --git a/packages/compiler/src/shadow_css.ts b/packages/compiler/src/shadow_css.ts index fe1bb4acc61..24304a767d5 100644 --- a/packages/compiler/src/shadow_css.ts +++ b/packages/compiler/src/shadow_css.ts @@ -800,30 +800,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; - // 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; + while (index < selectorPart.length) { + const currentSymbol = selectorPart[index]; + index++; + if (currentSymbol === '(') { + openedBrackets++; + continue; + } + if (currentSymbol === ')') { + openedBrackets--; + if (openedBrackets === 0) { + break; + } + continue; + } } - const scopedInnerPart = this._scopeSelector({ - selector: selectorToScope, - scopeSelector, - hostSelector, - }); + pseudoSelectorParts.push( + `${pseudoSelectorMatch[0]}${selectorPart.slice(_cssPrefixWithPseudoSelectorFunction.lastIndex, index)}`, + ); + _cssPrefixWithPseudoSelectorFunction.lastIndex = index; + } - // Put the result back into the pseudo selector function. - scopedPart = `${cssPseudoSelectorFunction}${scopedInnerPart})`; + // 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, + }); + + // Put the result back into the pseudo selector function. + return `${cssPseudoSelectorFunction}${scopedInnerPart})`; + }) + .join(''); } else { this._shouldScopeIndicator = this._shouldScopeIndicator || selectorPart.includes(_polyfillHostNoCombinator); @@ -962,7 +999,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; diff --git a/packages/compiler/test/shadow_css/shadow_css_spec.ts b/packages/compiler/test/shadow_css/shadow_css_spec.ts index 8eb90a4c0ef..7e869cccc67 100644 --- a/packages/compiler/test/shadow_css/shadow_css_spec.ts +++ b/packages/compiler/test/shadow_css/shadow_css_spec.ts @@ -93,9 +93,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 +102,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 +190,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( @@ -272,6 +274,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)', () => {