Skip to content

Commit

Permalink
Merge pull request #115 from m-akinc/improve-support-for-not
Browse files Browse the repository at this point in the history
Add proper support for :not()
  • Loading branch information
ghengeveld committed May 1, 2024
2 parents 6a645d0 + 9bf3316 commit 64a04cb
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 45 deletions.
63 changes: 48 additions & 15 deletions src/preview/rewriteStyleSheet.test.ts
Expand Up @@ -243,41 +243,74 @@ describe("rewriteStyleSheet", () => {

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

it('supports "::slotted" with classes', () => {
const sheet = new Sheet("::slotted(.a:hover, .b) .c { color: red }")
rewriteStyleSheet(sheet as any)
const sheet = new Sheet(".a > slot::slotted(.b:hover) { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain("::slotted(.a:hover, .b) .c")
expect(selectors).toContain("::slotted(.a.pseudo-hover, .b) .c")
expect(selectors).toContain(":host(.pseudo-hover-all) ::slotted(.a, .b) .c")
expect(selectors).toContain(".a > slot::slotted(.b:hover)")
expect(selectors).toContain(".a > slot::slotted(.b.pseudo-hover)")
expect(selectors).toContain(":host(.pseudo-hover-all) .a > slot::slotted(.b)")
})

it('supports "::slotted" with state selectors in descendant selector', () => {
const sheet = new Sheet("::slotted(.a) .b:hover { color: red }")
it('supports ":not"', () => {
const sheet = new Sheet(":not(:hover) { color: red }")
rewriteStyleSheet(sheet as any)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain("::slotted(.a) .b:hover")
expect(selectors).toContain("::slotted(.a) .b.pseudo-hover")
expect(selectors).toContain(":host(.pseudo-hover-all) ::slotted(.a) .b")
expect(sheet.cssRules[0].selectorText).toEqual(":not(:hover), :not(.pseudo-hover), :not(.pseudo-hover-all *)")
})

it('supports ":not"', () => {
it('supports ":not" in shadow DOM', () => {
const sheet = new Sheet(":not(:hover) { color: red }")
rewriteStyleSheet(sheet as any, true)
expect(sheet.cssRules[0].selectorText).toEqual(":not(:hover), :not(.pseudo-hover), :not(:host(.pseudo-hover-all) *)")
})

it('supports complex use of ":not"', () => {
const sheet = new Sheet("foo:focus:not(:hover, .bar:active) .baz { color: red }")
rewriteStyleSheet(sheet as any)
expect(sheet.cssRules[0].selectorText).toEqual(":not(:hover), :not(.pseudo-hover)")
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain("foo:focus:not(:hover, .bar:active) .baz")
expect(selectors).toContain("foo.pseudo-focus:not(.pseudo-hover, .bar.pseudo-active) .baz")
expect(selectors).toContain(".pseudo-focus-all foo:not(.pseudo-hover-all *, .pseudo-active-all .bar) .baz")
})

it('supports complex use of ":not" in shadow DOM', () => {
const sheet = new Sheet("foo:focus:not(:hover, .bar:active) .baz { color: red }")
rewriteStyleSheet(sheet as any, true)
const selectors = sheet.cssRules[0].getSelectors()
expect(selectors).toContain("foo:focus:not(:hover, .bar:active) .baz")
expect(selectors).toContain("foo.pseudo-focus:not(.pseudo-hover, .bar.pseudo-active) .baz")
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)
expect(sheet.cssRules[0].cssText).toEqual(":has(:hover), :has(.pseudo-hover) { color: red }")
expect(sheet.cssRules[0].cssText).toEqual(":has(:hover), :has(.pseudo-hover), .pseudo-hover-all :has(*) { color: red }")
})

it("override correct rules with media query present", () => {
Expand Down
110 changes: 81 additions & 29 deletions src/preview/rewriteStyleSheet.ts
@@ -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,10 +16,60 @@ 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 extractPseudoStates = (selector: string) => {
const states = new Set()
const withoutPseudoStates = selector
.replace(matchAll, (_, 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, "") || "*"

return {
states: Array.from(states),
withoutPseudoStates
}
}

const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, shadowRoot?: ShadowRoot) => {
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 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) => {
return cssText.replace(
selectorText,
splitSelectors(selectorText)
Expand All @@ -28,36 +81,35 @@ const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, shadowRoot?: Shado
return [selector]
}

const states: string[] = []
let plainSelector = 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 = ""
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")

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
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 if (selector.startsWith("::slotted(") || shadowRoot) {
// If removing pseudo-state selectors from inside ::slotted left it empty (thus invalid), must fix it by adding '*'.
ancestorSelector = `:host(${statesAllClassSelectors}) ${plainSelector.replace("::slotted()", "::slotted(*)")}`
} else {
ancestorSelector = `${statesAllClassSelectors} ${plainSelector}`
const withNotsReplaced = rewriteNotSelectors(selector, forShadowDOM)
ancestorSelector = replacePseudoStatesWithAncestorSelector(withNotsReplaced, forShadowDOM)
}

return [selector, classSelector, ancestorSelector].filter(
(selector) => selector && !selector.includes(":not()") && !selector.includes(":has()")
)
return [selector, classSelector, ancestorSelector]
})
.join(", ")
)
Expand All @@ -67,11 +119,11 @@ const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, shadowRoot?: Shado
// A sheet can only be rewritten once, and may carry over between stories.
export const rewriteStyleSheet = (
sheet: CSSStyleSheet,
shadowRoot?: ShadowRoot
forShadowDOM = false
): boolean => {
try {
const maximumRulesToRewrite = 1000
const count = rewriteRuleContainer(sheet, maximumRulesToRewrite, shadowRoot);
const count = rewriteRuleContainer(sheet, maximumRulesToRewrite, forShadowDOM);

if (count >= maximumRulesToRewrite) {
warnOnce("Reached maximum of 1000 pseudo selectors per sheet, skipping the rest.")
Expand All @@ -92,7 +144,7 @@ export const rewriteStyleSheet = (
const rewriteRuleContainer = (
ruleContainer: CSSStyleSheet | CSSGroupingRule,
rewriteLimit: number,
shadowRoot?: ShadowRoot
forShadowDOM: boolean
): number => {
let count = 0
let index = -1
Expand All @@ -106,12 +158,12 @@ const rewriteRuleContainer = (
numRewritten = cssRule.__pseudoStatesRewrittenCount
} else {
if ("cssRules" in cssRule && (cssRule.cssRules as CSSRuleList).length) {
numRewritten = rewriteRuleContainer(cssRule as CSSGroupingRule, rewriteLimit - count, shadowRoot)
numRewritten = rewriteRuleContainer(cssRule as CSSGroupingRule, rewriteLimit - count, forShadowDOM)
} else {
if (!("selectorText" in cssRule)) continue
const styleRule = cssRule as CSSStyleRule
if (matchOne.test(styleRule.selectorText)) {
const newRule = rewriteRule(styleRule, shadowRoot)
const newRule = rewriteRule(styleRule, forShadowDOM)
ruleContainer.deleteRule(index)
ruleContainer.insertRule(newRule, index)
numRewritten = 1
Expand Down
2 changes: 1 addition & 1 deletion src/preview/withPseudoState.ts
Expand Up @@ -175,7 +175,7 @@ export const withPseudoState: DecoratorFunction = (
const rewriteStyleSheets = (shadowRoot?: ShadowRoot) => {
let styleSheets = Array.from(shadowRoot ? shadowRoot.styleSheets : document.styleSheets)
if (shadowRoot?.adoptedStyleSheets?.length) styleSheets = shadowRoot.adoptedStyleSheets
styleSheets.forEach((sheet) => rewriteStyleSheet(sheet, shadowRoot))
styleSheets.forEach((sheet) => rewriteStyleSheet(sheet, !!shadowRoot))
if (shadowRoot && shadowHosts) shadowHosts.add(shadowRoot.host)
}

Expand Down

0 comments on commit 64a04cb

Please sign in to comment.