Skip to content

Commit

Permalink
Make :not work in conjunction with :host
Browse files Browse the repository at this point in the history
  • Loading branch information
m-akinc committed Apr 15, 2024
1 parent 9f35165 commit 92e40f3
Show file tree
Hide file tree
Showing 2 changed files with 80 additions and 41 deletions.
18 changes: 18 additions & 0 deletions src/preview/rewriteStyleSheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,24 @@ describe("rewriteStyleSheet", () => {
expect(selectors).toContain(":host(.pseudo-focus-all) foo:not(:host(.pseudo-hover-all) *, :host(.pseudo-active-all) .bar) .baz")
})

it('supports ":not" inside ":host"', () => {
const sheet = new Sheet(":host(.foo:not(:hover)) .baz:active { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain(":host(.foo:not(:hover)) .baz:active")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover)) .baz.pseudo-active")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover-all).pseudo-active-all) .baz")
})

it('supports ":not" inside and outside of ":host"', () => {
const sheet = new Sheet(":host(.foo:not(:hover)) .baz:not(:active) { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain(":host(.foo:not(:hover)) .baz:not(:active)")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover)) .baz:not(.pseudo-active)")
expect(selectors).toContain(":host(.foo:not(.pseudo-hover-all)) .baz:not(:host(.pseudo-active-all) *)")
})

it('supports ":has"', () => {
const sheet = new Sheet(":has(:hover) { color: red }")
rewriteStyleSheet(sheet as any)
Expand Down
103 changes: 62 additions & 41 deletions src/preview/rewriteStyleSheet.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { PSEUDO_STATES, EXCLUDED_PSEUDO_ELEMENT_PATTERNS } from "../constants"
import { splitSelectors } from "./splitSelectors"

const pseudoStateRegExp = (global: boolean, pseudoStates: string[]) =>
new RegExp(`(?<!(?:${EXCLUDED_PSEUDO_ELEMENT_PATTERNS.join("|")})\\S*):(${pseudoStates.join("|")})`, global ? "g" : undefined)
const pseudoStates = Object.values(PSEUDO_STATES)
const matchOne = new RegExp(`:(${pseudoStates.join("|")})`)
const matchAll = new RegExp(`:(${pseudoStates.join("|")})`, "g")
const matchOne = pseudoStateRegExp(false, pseudoStates)
const matchAll = pseudoStateRegExp(true, pseudoStates)
const replacementRegExp = (pseudoState: string) => pseudoStateRegExp(true, [pseudoState])

const warnings = new Set()
const warnOnce = (message: string) => {
Expand All @@ -13,27 +16,57 @@ const warnOnce = (message: string) => {
warnings.add(message)
}

const replacementRegExp = (pseudoState: string) =>
new RegExp(`(?<!(${EXCLUDED_PSEUDO_ELEMENT_PATTERNS.join("|")})\\S*):${pseudoState}`, "g")
const replacePseudoStates = (selector: string, allClass?: boolean) => {
return pseudoStates.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}${allClass ? "-all" : ""}`), selector)
}

// Does not handle :host() or :not() containing pseudo-states. Need to call replaceNotSelectors on the input first.
const replacePseudoStatesWithAncestorSelector = (selector: string, forShadowDOM: boolean, additionalHostSelectors?: string) => {
const { states, withoutPseudoStates } = extractPseudoStates(selector)
const classes = states.map((s) => `.pseudo-${s}-all`).join("")
return states.length === 0 && !additionalHostSelectors
? selector
: forShadowDOM
? `:host(${additionalHostSelectors ?? ""}${classes}) ${withoutPseudoStates}`
: `${classes} ${withoutPseudoStates}`
}

const replacePseudoStatesWithDescendantSelector = (selector: string, forShadowDOM = false) => {
const states: string[] = []
const extractPseudoStates = (selector: string) => {
const states = new Set()
const withoutPseudoStates = selector
.replace(matchAll, (_, state) => {
states.push(state)
states.add(state)
return ""
})
// If removing pseudo-state selectors from inside a functional selector left it empty (thus invalid), must fix it by adding '*'.
.replaceAll("()", "(*)")
// If a selector list was left with blank items (e.g. ", foo, , bar, "), remove the extra commas/spaces.
.replace(/(?<=[\s(]),\s+|(,\s+)+(?=\))/g, "") || "*"

if (states.length === 0) return selector
return {
states: Array.from(states),
withoutPseudoStates
}
}

const rewriteNotSelectors = (selector: string, forShadowDOM: boolean) => {
return [...selector.matchAll(/:not\(([^)]+)\)/g)].reduce((acc, match) => {
const originalNot = match[0]
const selectorList = match[1]
const rewrittenNot = rewriteNotSelector(selectorList, forShadowDOM)
return acc.replace(originalNot, rewrittenNot)
}, selector)
}

const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
return forShadowDOM
? `:host(${statesAllClassSelectors}) ${withoutPseudoStates}`
: `${statesAllClassSelectors} ${withoutPseudoStates}`
const rewriteNotSelector = (negatedSelectorList: string, forShadowDOM: boolean) => {
const rewrittenSelectors: string[] = []
// For each negated selector
for (const negatedSelector of negatedSelectorList.split(/,\s*/)) {
// :not cannot be nested and cannot contain pseudo-elements, so no need to worry about that.
// Also, there's no compelling use case for :host() inside :not(), so we don't handle that.
rewrittenSelectors.push(replacePseudoStatesWithAncestorSelector(negatedSelector, forShadowDOM))
}
return `:not(${rewrittenSelectors.join(", ")})`
}

const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, forShadowDOM: boolean) => {
Expand All @@ -48,44 +81,32 @@ const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, forShadowDOM: bool
return [selector]
}

const states: string[] = []
selector.replace(matchAll, (_, state) => {
states.push(state)
return ""
})
const classSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}`), selector)

const classSelector = replacePseudoStates(selector)
let ancestorSelector = ""

if (selector.startsWith(":host(")) {
const matches = selector.match(/^:host\((\S+)\)\s+(.+)$/)
if (matches && matchOne.test(matches[2])) {
// If there are pseudo-state selectors outside of :host(), then simple replacement won't work.
// E.g. :host(.foo#bar) .baz:hover:active -> :host(.foo#bar.pseudo-hover-all.pseudo-active-all) .baz
// Simple replacement won't work on pseudo-state selectors outside of :host().
// E.g. :host(.foo) .bar:hover -> :host(.foo.pseudo-hover-all) .bar
// E.g. :host(.foo:focus) .bar:hover -> :host(.foo.pseudo-focus-all.pseudo-hover-all) .bar
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")
ancestorSelector = `:host(${matches[1].replace(matchAll, "")}${statesAllClassSelectors}) ${matches[2].replace(matchAll, "")}`
let hostInnerSelector = matches[1]
let descendantSelector = matches[2]
// Simple replacement is fine for pseudo-state selectors inside :host() (even if inside :not()).
hostInnerSelector = replacePseudoStates(hostInnerSelector, true)
// Rewrite any :not selectors in the descendant selector.
descendantSelector = rewriteNotSelectors(descendantSelector, true)
// Any remaining pseudo-states in the descendant selector need to be moved into the host selector.
ancestorSelector = replacePseudoStatesWithAncestorSelector(descendantSelector, true, hostInnerSelector)
} else {
ancestorSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}-all`), selector)
// Don't need to specially handle :not() because:
// - if inside :host(), simple replacement is sufficient
// - if outside :host(), didn't match any pseudo-states
ancestorSelector = replacePseudoStates(selector, true)
}
} else {
ancestorSelector = selector
const matches = [...selector.matchAll(/:not\(([^)]+)\)/g)]
if (matches.length) {
// For each :not(...)
for (const match of matches) {
const selectorList = match[1]
const rewrittenSelectors: string[] = []
// For each negated selector
for (const negatedSelector of selectorList.split(/,\s*/)) {
// :not cannot be nested and cannot contain pseudo-elements, so no need to worry about that.
rewrittenSelectors.push(replacePseudoStatesWithDescendantSelector(negatedSelector, forShadowDOM))
}
const rewrittenNot = `:not(${rewrittenSelectors.join(", ")})`
ancestorSelector = ancestorSelector.replace(match[0], rewrittenNot)
}
}
ancestorSelector = replacePseudoStatesWithDescendantSelector(ancestorSelector, forShadowDOM)
const withNotsReplaced = rewriteNotSelectors(selector, forShadowDOM)
ancestorSelector = replacePseudoStatesWithAncestorSelector(withNotsReplaced, forShadowDOM)
}

return [selector, classSelector, ancestorSelector]
Expand Down

0 comments on commit 92e40f3

Please sign in to comment.