-
Notifications
You must be signed in to change notification settings - Fork 32
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 support for targeting specific elements #25
Conversation
cc @ghengeveld |
@ghengeveld hi, any estimation on when this will get reviewed? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work! Really looking forward to making the addon more flexible.
I have some concerns about web components, so we should do some proper QA before merging.
Thanks 👍 |
I typically test web components manually, using the stories in the project's Storybook ( |
@ghengeveld I've browsed these stories and it looks fine to me. |
Hi @ghengeveld m8, what are the next action items to push this PR forward? |
@sag1v Would be nice to have a little documentation in the README for it. I'm playing around with it now and running into an issue (toolbar toggles don't seem to work anymore), but once that's resolved and we have docs, I'm happy to merge. By the way what you you think about changing |
Something weird is going on with the toggles when using your branch. I'll have to take a closer look. |
@ghengeveld I've checked your last commit, unfortunately it still a mess. I think i have a breakthrough but still have issues with the docs. I'm not sure why we have this check inside
But removing this check helps solve some of the issues. I moved some code around and made some changes, its mostly working in canvas (beside 1 issue that the default story "remembers" the previous state untill we play with the toggles). Docs tab on the other hand is broken and i don't know why. I think we have an issue with the way we handle the storybook events, i'm not an expert with this field and i lack some knowledge regarding the life cycle of these events so i would love to get some help. withPseudoState.js /* eslint-env browser */
import { addons, useEffect } from "@storybook/addons"
import {
DOCS_RENDERED,
STORY_CHANGED,
STORY_RENDERED,
UPDATE_GLOBALS,
} from "@storybook/core-events"
import { PSEUDO_STATES, REWRITE_STYLESHEET } from "./constants"
import { splitSelectors } from "./splitSelectors"
import { useCallback } from "react"
const pseudoStates = Object.values(PSEUDO_STATES)
const matchOne = new RegExp(`:(${pseudoStates.join("|")})`)
const matchAll = new RegExp(`:(${pseudoStates.join("|")})`, "g")
// Drops any existing pseudo state classnames that carried over from a previously viewed story
// before adding the new classnames. We do this the old-fashioned way, for IE compatibility.
const applyClasses = (element, classnames) => {
element.className = element.className
.split(" ")
.filter((classname) => classname && classname.indexOf("pseudo-") !== 0)
.concat(...classnames)
.join(" ")
}
const applyParameter = (rootElement, parameter) =>
Object.entries(parameter || {})
.reduce((acc, [state, value]) => {
const set = (target, state) => acc.set(target, new Set([...(acc.get(target) || []), state]))
if (typeof value === "boolean") {
// default API - applying pseudo class to root element.
set(rootElement, value && state)
} else if (typeof value === "string") {
// explicit selectors API - applying pseudo class to a specific element
rootElement.querySelectorAll(value).forEach((el) => set(el, state))
} else if (Array.isArray(value)) {
// explicit selectors API - we have an array (of strings) recursively handle each one
value.forEach((sel) => rootElement.querySelectorAll(sel).forEach((el) => set(el, state)))
}
return acc
}, new Map())
.forEach((states, target) => {
const classnames = []
states.forEach((key) => PSEUDO_STATES[key] && classnames.push(`pseudo-${PSEUDO_STATES[key]}`))
applyClasses(target, classnames)
})
// Traverses ancestry to collect relevant pseudo classnames, and applies them to the shadow host.
// Shadow DOM can only access classes on its host. Traversing is needed to mimic the CSS cascade.
const updateShadowHost = (shadowHost) => {
const classnames = new Set()
for (let element = shadowHost.parentElement; element; element = element.parentElement) {
if (!element.className) continue
element.className
.split(" ")
.filter((classname) => classname.indexOf("pseudo-") === 0)
.forEach((classname) => classnames.add(classname))
}
applyClasses(shadowHost, classnames)
}
// Keep track of attached shadow host elements for the current story
const shadowHosts = new Set()
addons.getChannel().on(STORY_CHANGED, () => shadowHosts.clear())
// Global decorator that rewrites stylesheets and applies classnames to render pseudo styles
export const withPseudoState = (StoryFn, { viewMode, parameters, id, globals: globalsArgs }) => {
const { pseudo: parameter } = parameters
const { pseudo: globals } = globalsArgs
const channel = addons.getChannel()
channel.emit(REWRITE_STYLESHEET)
const subScriber = useCallback(() => {
// we combine parameter with globals though it will also work without globals for some reason
const combinedParams = { ...parameter, ...globals }
rewriteStyleSheets(null, combinedParams)
}, [parameter, globals])
useEffect(() => {
const events = [STORY_RENDERED, DOCS_RENDERED, REWRITE_STYLESHEET]
events.forEach((event) => channel.on(event, subScriber))
return () => {
events.forEach((event) => channel.removeListener(event, subScriber))
}
}, [subScriber])
// Sync parameter to globals, used by the toolbar (only in canvas as this
// doesn't make sense for docs because many stories are displayed at once)
useEffect(() => {
if (parameter !== globals && viewMode === "story") {
channel.emit(UPDATE_GLOBALS, {
globals: { pseudo: parameter },
})
}
}, [parameter, viewMode])
// Convert selected states to classnames and apply them to the story root element.
// Then update each shadow host to redetermine its own pseudo classnames.
useEffect(() => {
const timeout = setTimeout(() => {
const element = document.getElementById(viewMode === "docs" ? `story--${id}` : `root`)
applyParameter(element, globals)
shadowHosts.forEach(updateShadowHost)
}, 0)
return () => clearTimeout(timeout)
}, [globals, viewMode])
return StoryFn()
}
const warnings = new Set()
const warnOnce = (message) => {
if (warnings.has(message)) return
// eslint-disable-next-line no-console
console.warn(message)
warnings.add(message)
}
// Rewrite CSS rules for pseudo-states on all stylesheets to add an alternative selector
function rewriteStyleSheets(shadowRoot, options = {}) {
const { useExplicitSelectors } = options
let styleSheets = shadowRoot ? shadowRoot.styleSheets : document.styleSheets
if (shadowRoot?.adoptedStyleSheets?.length) styleSheets = shadowRoot.adoptedStyleSheets
console.log("rewriteStyleSheets", options)
for (const sheet of styleSheets) {
if (sheet._pseudoStatesRewritten) {
//continue
} else {
//sheet._pseudoStatesRewritten = true
}
try {
let index = 0
for (const { cssText, selectorText } of sheet.cssRules) {
if (matchOne.test(selectorText)) {
const selectors = splitSelectors(selectorText)
const newRule = cssText.replace(
selectorText,
selectors
.flatMap((selector) => {
if (selector.includes(".pseudo-")) return []
const states = []
const plainSelector = selector.replace(matchAll, (_, state) => {
if (useExplicitSelectors) {
return `.pseudo-${state}`
}
states.push(state)
return ""
})
let stateSelector
if (!matchOne.test(selector)) {
return [selector]
}
if (selector.startsWith(":host(") || selector.startsWith("::slotted(")) {
stateSelector = states.reduce(
(acc, state) => acc.replaceAll(`:${state}`, `.pseudo-${state}`),
selector
)
} else if (shadowRoot) {
stateSelector = `:host(${states
.map((s) => `.pseudo-${s}`)
.join("")}) ${plainSelector}`
} else {
if (useExplicitSelectors) {
// Replace the :pseudo selector with .class selector on the element directly, rather than an ancestor element.
// For example, instead of rewriting `button:hover` to `.pseudo-hover button`, rewrite it to `button.pseudo-hover`.
stateSelector = plainSelector
} else {
stateSelector = `${states.map((s) => `.pseudo-${s}`).join("")} ${plainSelector}`
}
}
return [selector, stateSelector]
})
.join(", ")
)
sheet.deleteRule(index)
sheet.insertRule(newRule, index)
if (shadowRoot) shadowHosts.add(shadowRoot.host)
}
index++
if (index > 1000) {
warnOnce("Reached maximum of 1000 pseudo selectors per sheet, skipping the rest.")
break
}
}
} catch (e) {
if (e.toString().includes("cssRules")) {
warnOnce(`Can't access cssRules, likely due to CORS restrictions: ${sheet.href}`)
} else {
// eslint-disable-next-line no-console
console.error(e, sheet.href)
}
}
}
}
// Reinitialize CSS enhancements every time the story changes
//addons.getChannel().on(STORY_RENDERED, () => rewriteStyleSheets())
// Reinitialize CSS enhancements every time a docs page is rendered
// addons.getChannel().on(DOCS_RENDERED, () => rewriteStyleSheets())
// addons.getChannel().on(REWRITE_STYLESHEET, (...args) => rewriteStyleSheets(null, ...args))
// IE doesn't support shadow DOM
if (Element.prototype.attachShadow) {
// Monkeypatch the attachShadow method so we can handle pseudo styles inside shadow DOM
Element.prototype._attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function attachShadow(init) {
// Force "open" mode, so we can access the shadowRoot
const shadowRoot = this._attachShadow({ ...init, mode: "open" })
// Wait for it to render and apply its styles before rewriting them
requestAnimationFrame(() => {
rewriteStyleSheets(shadowRoot)
updateShadowHost(shadowRoot.host)
})
return shadowRoot
}
} |
@sag1v Fixed it now :) The rationale for that The docs tab has never worked. Don't worry about it, it's beyond the scope of this PR to fix it. |
@sag1v Can you check out my latest commit and verify if you think it looks good / works well? |
@ghengeveld I'm on it :) |
I'll write up a README update to add this new string/array config option. |
@ghengeveld Canvas works great! |
I think that in .something:not() instead of .something:not(:focus-visible) though i'm not sure why it happens only in Docs mode and not in canvas |
Ok I fixed the |
@sag1v Do we need anything else or should I merge and release this thing? |
@ghengeveld LGTM lets do it! 🥳 |
🚀 PR was released in |
I wouldn't have known about |
resolve #4 and resolve #5
Adds support for specific selector (or array of selectors).
For example:
Warning
Currently this PR breaks the current API and doesn't support passing booleans (and modifying the root element), meaning consumers must pass a selector.I didn't handle/change any code related to shadow DOM, so it might be broken.
This is a WIP so let's discuss 🙂