Skip to content

Commit

Permalink
Merge pull request #113 from m-akinc/avoid-invalid-part-selector
Browse files Browse the repository at this point in the history
Avoid invalid `::part` selector
  • Loading branch information
ghengeveld authored Apr 9, 2024
2 parents a73ca62 + 045efb6 commit e3202ee
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 25 deletions.
4 changes: 2 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ export const ADDON_ID = "storybook/pseudo-states"
export const TOOL_ID = `${ADDON_ID}/tool`
export const PARAM_KEY = "pseudo"

// Pseudo-elements which are not allowed to have classes applied on them
// Regex patterns for pseudo-elements which are not allowed to have classes applied on them
// E.g. ::-webkit-scrollbar-thumb.pseudo-hover is not a valid selector
export const EXCLUDED_PSEUDO_ELEMENTS = ["::-webkit-scrollbar-thumb", "::-webkit-slider-thumb"]
export const EXCLUDED_PSEUDO_ELEMENT_PATTERNS = ["::-webkit-scrollbar-thumb", "::-webkit-slider-thumb", "::part\\([^)]+\\)"]

// Dynamic pseudo-classes
// @see https://www.w3.org/TR/2018/REC-selectors-3-20181106/#dynamic-pseudos
Expand Down
20 changes: 19 additions & 1 deletion src/preview/rewriteStyleSheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,30 @@ describe("rewriteStyleSheet", () => {
].includes(x))).toEqual([])
})

it("does not add .pseudo-<class> to pseudo-class, which does not support classes", () => {
it("does not add .pseudo-<class> to pseudo-class/element which does not support classes", () => {
const sheet = new Sheet("::-webkit-scrollbar-thumb:hover { border-color: transparent; }")
rewriteStyleSheet(sheet as any)
expect(sheet.cssRules[0].getSelectors()).not.toContain("::-webkit-scrollbar-thumb.pseudo-hover")
})

it("adds alternative selector when ::-webkit-scrollbar-thumb follows :hover", () => {
const sheet = new Sheet("div:hover::-webkit-scrollbar-thumb { border-color: transparent; }")
rewriteStyleSheet(sheet as any)
expect(sheet.cssRules[0].getSelectors()).toContain("div.pseudo-hover::-webkit-scrollbar-thumb")
})

it("does not add .pseudo-<class> to pseudo-class/element (with arguments) which does not support classes", () => {
const sheet = new Sheet("::part(foo bar):hover { border-color: transparent; }")
rewriteStyleSheet(sheet as any)
expect(sheet.cssRules[0].getSelectors()).not.toContain("::part(foo bar).pseudo-hover")
})

it("adds alternative selector when ::part() follows :hover", () => {
const sheet = new Sheet("custom-elt:hover::part(foo bar) { border-color: transparent; }")
rewriteStyleSheet(sheet as any)
expect(sheet.cssRules[0].getSelectors()).toContain("custom-elt.pseudo-hover::part(foo bar)")
})

it("adds alternative selector for each pseudo selector", () => {
const sheet = new Sheet("a:hover, a:focus { color: red }")
rewriteStyleSheet(sheet as any)
Expand Down
27 changes: 11 additions & 16 deletions src/preview/rewriteStyleSheet.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PSEUDO_STATES, EXCLUDED_PSEUDO_ELEMENTS } from "../constants"
import { PSEUDO_STATES, EXCLUDED_PSEUDO_ELEMENT_PATTERNS } from "../constants"
import { splitSelectors } from "./splitSelectors"

const pseudoStates = Object.values(PSEUDO_STATES)
Expand All @@ -13,8 +13,8 @@ const warnOnce = (message: string) => {
warnings.add(message)
}

const isExcludedPseudoElement = (selector: string, pseudoState: string) =>
EXCLUDED_PSEUDO_ELEMENTS.some((element) => selector.endsWith(`${element}:${pseudoState}`))
const replacementRegExp = (pseudoState: string) =>
new RegExp(`(?<!(${EXCLUDED_PSEUDO_ELEMENT_PATTERNS.join("|")})\\S*):${pseudoState}`, "g")

const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, shadowRoot?: ShadowRoot) => {
return cssText.replace(
Expand All @@ -33,30 +33,25 @@ const rewriteRule = ({ cssText, selectorText }: CSSStyleRule, shadowRoot?: Shado
states.push(state)
return ""
})
const classSelector = states.reduce((acc, state) => {
if (isExcludedPseudoElement(selector, state)) return ""
return acc.replace(new RegExp(`:${state}`, "g"), `.pseudo-${state}`)
}, selector)
const classSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}`), selector)

let ancestorSelector = ""
const statesAllClassSelectors = states.map((s) => `.pseudo-${s}-all`).join("")

if (selector.startsWith(":host(")) {
const matches = selector.match(/^:host\(([^ ]+)\) /)
const matches = selector.match(/^:host\((\S+)\) /)
if (matches && !matchOne.test(matches[1])) {
// If :host() did not contain states, then simple replacement won't work.
// E.g. :host(.foo#bar) .baz:hover:active -> :host(.foo#bar.pseudo-hover-all.pseudo-active-all) .baz
ancestorSelector = `:host(${matches[1]}${statesAllClassSelectors}) ${plainSelector.replace(matches[0], "")}`
} else {
ancestorSelector = states.reduce((acc, state) => {
if (isExcludedPseudoElement(selector, state)) return ""
return acc.replace(new RegExp(`:${state}`, "g"), `.pseudo-${state}-all`)
}, selector)
ancestorSelector = states.reduce((acc, state) => acc.replace(replacementRegExp(state), `.pseudo-${state}-all`), selector)
// NOTE: Selectors with pseudo states on both :host and a descendant are not properly supported.
// E.g. :host(.foo:focus) .bar:hover -> :host(.foo.pseudo-focus-all.pseudo-hover-all) .bar
}
} else if (selector.startsWith("::slotted(") || shadowRoot) {
if (plainSelector.startsWith("::slotted()")) {
plainSelector = plainSelector.replace("::slotted()", "::slotted(*)")
}
ancestorSelector = `:host(${statesAllClassSelectors}) ${plainSelector}`
// 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}`
}
Expand Down
11 changes: 5 additions & 6 deletions src/preview/withPseudoState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ const applyParameter = (rootElement: Element, parameter: PseudoStateConfig = {})
// Shadow DOM can only access classes on its host. Traversing is needed to mimic the CSS cascade.
const updateShadowHost = (shadowHost: Element) => {
const classnames = new Set<string>()
// Keep any existing "pseudo-*" classes
// Keep any existing "pseudo-*" classes (but not "pseudo-*-all").
// "pseudo-*-all" classes may be stale and will be re-added as needed.
shadowHost.className
.split(" ")
.filter((classname) => classname.startsWith("pseudo-"))
.filter((classname) => classname.match(/^pseudo-(.(?!-all))+$/))
.forEach((classname) => classnames.add(classname))
// Adopt "pseudo-*-all" classes from ancestors (across shadow boundaries)
for (let node = shadowHost.parentNode; node;) {
Expand Down Expand Up @@ -174,10 +175,8 @@ export const withPseudoState: DecoratorFunction = (
const rewriteStyleSheets = (shadowRoot?: ShadowRoot) => {
let styleSheets = Array.from(shadowRoot ? shadowRoot.styleSheets : document.styleSheets)
if (shadowRoot?.adoptedStyleSheets?.length) styleSheets = shadowRoot.adoptedStyleSheets
const rewroteStyles = styleSheets
.map((sheet) => rewriteStyleSheet(sheet, shadowRoot))
.some(Boolean)
if (rewroteStyles && shadowRoot && shadowHosts) shadowHosts.add(shadowRoot.host)
styleSheets.forEach((sheet) => rewriteStyleSheet(sheet, shadowRoot))
if (shadowRoot && shadowHosts) shadowHosts.add(shadowRoot.host)
}

// Only track shadow hosts for the current story
Expand Down
42 changes: 42 additions & 0 deletions stories/ShadowRootWithPart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from "react"

export const ShadowRoot = ({ label = "Hello from shadow DOM" }) => {
const ref = React.useRef()

React.useEffect(() => {
if (!ref.current.attachShadow) return
ref.current.attachShadow({ mode: "closed" })
ref.current.shadowRoot.innerHTML = `
<button part="foo">${label}</button>
`
ref.current.innerHTML = `
<style>
::part(foo) {
font-family: "Nunito Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
color: white;
background-color: tomato;
font-size: 14px;
padding: 11px 20px;
}
::part(foo):hover {
text-decoration: underline;
}
::part(foo):focus {
box-shadow: inset 0 0 0 2px maroon;
outline: 0;
}
::part(foo):active {
background-color: firebrick;
}
</style>
`
}, [])

return <div ref={ref} />
}
51 changes: 51 additions & 0 deletions stories/ShadowRootWithPart.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from "react"

import { ShadowRoot } from "./ShadowRootWithPart"
import "./grid.css"

export default {
title: "Example/ShadowRootWithPart",
component: ShadowRoot,
}

const Template = () => <ShadowRoot />

export const All = () => (
<div className="story-grid">
<div>
<ShadowRoot label="Normal" />
</div>
<div className="pseudo-hover-all">
<ShadowRoot label="Hover" />
</div>
<div className="pseudo-focus-all">
<ShadowRoot label="Focus" />
</div>
<div className="pseudo-active-all">
<ShadowRoot label="Active" />
</div>
<div className="pseudo-hover-all pseudo-focus-all">
<ShadowRoot label="Hover Focus" />
</div>
<div className="pseudo-hover-all pseudo-active-all">
<ShadowRoot label="Hover Active" />
</div>
<div className="pseudo-focus-all pseudo-active-all">
<ShadowRoot label="Focus Active" />
</div>
<div className="pseudo-hover-all pseudo-focus-all pseudo-active-all">
<ShadowRoot label="Hover Focus Active" />
</div>
</div>
)

export const Default = Template.bind()

export const Hover = Template.bind()
Hover.parameters = { pseudo: { hover: true } }

export const Focus = Template.bind()
Focus.parameters = { pseudo: { focus: true } }

export const Active = Template.bind()
Active.parameters = { pseudo: { active: true } }

0 comments on commit e3202ee

Please sign in to comment.