Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add proper support for :not() #115

Merged
merged 1 commit into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 48 additions & 15 deletions src/preview/rewriteStyleSheet.test.ts
Original file line number Diff line number Diff line change
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 }")
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
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 }")
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
})

it("override correct rules with media query present", () => {
Expand Down
110 changes: 81 additions & 29 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)
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
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
m-akinc marked this conversation as resolved.
Show resolved Hide resolved
): 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
Original file line number Diff line number Diff line change
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
Loading