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

refactor: improve shadow dom support #2152

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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: 63 additions & 0 deletions examples/solid-ts/src/routes/select-shadow-dom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as select from "@zag-js/select"
import { selectData } from "@zag-js/shared"
import styles from "@zag-js/shared/src/style.css?inline"
import { normalizeProps, useMachine } from "@zag-js/solid"
import { Index, createMemo, createSignal, createUniqueId, splitProps } from "solid-js"
import { Portal } from "solid-js/web"

function Select(props: Partial<select.Context> & { portalRef?: HTMLElement }) {
const [portalProps, machineProps] = splitProps(props, ["portalRef"])

const [state, send] = useMachine(
select.machine({
...machineProps,
collection: select.collection({ items: selectData }),
id: createUniqueId(),
}),
)

const api = createMemo(() => select.connect(state, send, normalizeProps))

return (
<div {...api().getRootProps()}>
<div {...api().getControlProps()}>
<button {...api().getTriggerProps()}>
{api().valueAsString || "Select option"}
<span {...api().getIndicatorProps()}>▼</span>
</button>
</div>
<Portal mount={portalProps.portalRef}>
<div {...api().getPositionerProps()}>
<ul {...api().getContentProps()}>
<Index each={selectData}>
{(item) => (
<li {...api().getItemProps({ item: item() })}>
<span {...api().getItemTextProps({ item: item() })}>{item().label}</span>
<span {...api().getItemIndicatorProps({ item: item() })}>✓</span>
</li>
)}
</Index>
</ul>
</div>
</Portal>
</div>
)
}

export default function Page() {
let mountRef!: HTMLElement
const [shadowRef, setShadowRef] = createSignal<HTMLDivElement | null>(null)
const getRootNode = () => shadowRef()?.shadowRoot!

return (
<main class="select" ref={mountRef}>
<Select />
<div style={{ height: "50vh" }} />
<Portal ref={setShadowRef} useShadow mount={mountRef}>
<style>{styles}</style>
<Select getRootNode={getRootNode} portalRef={getRootNode()?.getElementById("portal-root")!} />
<div id="portal-root" />
</Portal>
</main>
)
}
33 changes: 33 additions & 0 deletions examples/solid-ts/src/routes/tooltip-shadow-dom.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { normalizeProps, useMachine } from "@zag-js/solid"
import * as tooltip from "@zag-js/tooltip"
import styles from "@zag-js/shared/src/style.css?inline"
import { createMemo, createSignal, Show } from "solid-js"
import { Portal } from "solid-js/web"

export default function Page() {
let mountRef!: HTMLElement
const [shadowRef, setShadowRef] = createSignal<HTMLDivElement | null>(null)

const getRootNode = () => shadowRef()?.shadowRoot!

const [state, send] = useMachine(tooltip.machine({ id: "1", getRootNode }))
const api = createMemo(() => tooltip.connect(state, send, normalizeProps))

return (
<main ref={mountRef}>
<p>Testing</p>
<Portal ref={setShadowRef} useShadow mount={mountRef}>
<style>{styles}</style>
<button {...api().getTriggerProps()}>Hover me</button>
<Show when={api().open}>
<Portal mount={getRootNode()?.getElementById("portal-root")!}>
<div {...api().getPositionerProps()}>
<div {...api().getContentProps()}>Tooltip</div>
</div>
</Portal>
</Show>
<div id="portal-root" />
</Portal>
</main>
)
}
5 changes: 4 additions & 1 deletion packages/utilities/dismissable/src/dismissable-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ function trackDismissableElementImpl(node: MaybeElement, options: DismissableEle
const _containers = Array.isArray(containers) ? containers : [containers]
const persistentElements = options.persistentElements?.map((fn) => fn()).filter(isHTMLElement)
if (persistentElements) _containers.push(...persistentElements)
return _containers.some((node) => contains(node, target)) || layerStack.isInNestedLayer(node, target)
return (
_containers.filter(isHTMLElement).some((node) => contains(node, target)) ||
layerStack.isInNestedLayer(node, target)
)
}

const cleanups = [
Expand Down
16 changes: 14 additions & 2 deletions packages/utilities/dom-query/src/contains.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import { isHTMLElement } from "./is"
import { isHTMLElement, isShadowRoot } from "./is"

type Target = HTMLElement | EventTarget | null | undefined

export function contains(parent: Target, child: Target) {
if (!parent || !child) return false
if (!isHTMLElement(parent) || !isHTMLElement(child)) return false
return parent === child || parent.contains(child)

const rootNode = child.getRootNode?.({ composed: true })
if (parent.contains(child)) return true

if (rootNode && isShadowRoot(rootNode)) {
let next: any = child
while (next) {
if (parent === next) return true
next = next.parentNode || next.host
}
}

return false
}
5 changes: 5 additions & 0 deletions packages/utilities/dom-query/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ export function getActiveElement(rootNode: Document | ShadowRoot): HTMLElement |

return activeElement
}

export function getRootNode(el: Element | ShadowRoot): DocumentFragment {
if (isShadowRoot(el)) return el
return el.getRootNode({ composed: true }) as DocumentFragment
}
32 changes: 24 additions & 8 deletions packages/utilities/interact-outside/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { addDomEvent, fireCustomEvent, isContextMenuEvent } from "@zag-js/dom-event"
import {
contains,
getDocument,
getRootNode,
getEventTarget,
getNearestOverflowAncestor,
getWindow,
isFocusable,
isHTMLElement,
raf,
getDocument,
isShadowRoot,
} from "@zag-js/dom-query"
import { callAll } from "@zag-js/utils"
import { getParentWindow, getWindowFrames } from "./frame-utils"
Expand Down Expand Up @@ -108,15 +110,18 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp

if (!node) return

const doc = getDocument(node)
const win = getWindow(node)
const rootNode = getRootNode(node)
const win = getWindow(rootNode)
const frames = getWindowFrames(win)
const parentWin = getParentWindow(win)

function isEventOutside(event: Event): boolean {
const target = getEventTarget(event)
if (!isHTMLElement(target)) return false

// Custom exclude function
if (exclude?.(target)) return false

// ignore disconnected nodes (removed from DOM)
if (!target.isConnected) return false

Expand All @@ -127,7 +132,7 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp
if (isEventPointWithin(node, event)) return false

// Ex: page content that is scrollable
const triggerEl = doc.querySelector(`[aria-controls="${node!.id}"]`)
const triggerEl = rootNode.querySelector(`[aria-controls="${node!.id}"]`)
if (triggerEl) {
const triggerAncestor = getNearestOverflowAncestor(triggerEl)
if (isEventWithinScrollbar(event, triggerAncestor)) return false
Expand All @@ -137,8 +142,8 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp
const nodeAncestor = getNearestOverflowAncestor(node!)
if (isEventWithinScrollbar(event, nodeAncestor)) return false

// Custom exclude function
return !exclude?.(target)
// Final fallback
return true
}

const pointerdownCleanups: Set<VoidFunction> = new Set()
Expand All @@ -148,6 +153,12 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp
function handler() {
const func = defer ? raf : (v: any) => v()
const composedPath = event.composedPath?.() ?? [event.target]

const target = getEventTarget<HTMLElement>(event)

// skip shadow root event since it's handled by the host
if (target && isShadowRoot(target.getRootNode())) return

func(() => {
if (!node || !isEventOutside(event)) return

Expand All @@ -172,16 +183,21 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp
// flush any pending pointerup events
pointerdownCleanups.forEach((fn) => fn())
// add a pointerup event listener to the document and all frame documents
pointerdownCleanups.add(addDomEvent(doc, "click", handler, { once: true }))
pointerdownCleanups.add(addDomEvent(rootNode, "click", handler, { once: true }))
pointerdownCleanups.add(parentWin.addEventListener("click", handler, { once: true }))
pointerdownCleanups.add(frames.addEventListener("click", handler, { once: true }))
} else {
handler()
}
}

const cleanups = new Set<VoidFunction>()
const doc = getDocument(rootNode)

const timer = setTimeout(() => {
if (isShadowRoot(node.getRootNode())) {
cleanups.add(addDomEvent((rootNode as ShadowRoot).host, "pointerdown", onPointerDown, true))
}
cleanups.add(addDomEvent(doc, "pointerdown", onPointerDown, true))
cleanups.add(parentWin.addEventListener("pointerdown", onPointerDown, true))
cleanups.add(frames.addEventListener("pointerdown", onPointerDown, true))
Expand Down Expand Up @@ -210,7 +226,7 @@ function trackInteractOutsideImpl(node: MaybeElement, options: InteractOutsideOp
})
}

cleanups.add(addDomEvent(doc, "focusin", onFocusin, true))
cleanups.add(addDomEvent(rootNode, "focusin", onFocusin, true))
cleanups.add(parentWin.addEventListener("focusin", onFocusin, true))
cleanups.add(frames.addEventListener("focusin", onFocusin, true))

Expand Down
Loading