diff --git a/.changeset/clear-queens-lose.md b/.changeset/clear-queens-lose.md new file mode 100644 index 0000000000..91b90bd189 --- /dev/null +++ b/.changeset/clear-queens-lose.md @@ -0,0 +1,5 @@ +--- +"@zag-js/core": patch +--- + +Change return type of Scope.getRootNode() from Node to Element to support querySelector / querySelectorAll. diff --git a/.changeset/custom-trigger-id-handling.md b/.changeset/custom-trigger-id-handling.md new file mode 100644 index 0000000000..61da93f4fa --- /dev/null +++ b/.changeset/custom-trigger-id-handling.md @@ -0,0 +1,9 @@ +--- +"@zag-js/popover": patch +"@zag-js/dialog": patch +"@zag-js/drawer": patch +"@zag-js/hover-card": patch +"@zag-js/tooltip": patch +--- + +Fix custom trigger elements (via `ids.trigger`) being ignored when shared across components (e.g. wrapping a `Popover.Trigger` in a `Tooltip` with the same id), causing broken positioning and a close-then-reopen cycle on trigger clicks. diff --git a/.changeset/dismissable-pointer-event-null-body.md b/.changeset/dismissable-pointer-event-null-body.md new file mode 100644 index 0000000000..3777bfe50a --- /dev/null +++ b/.changeset/dismissable-pointer-event-null-body.md @@ -0,0 +1,5 @@ +--- +"@zag-js/dismissable": patch +--- + +Fix crash (`Cannot read properties of null (reading 'style')`) when a pointer-blocking dialog or popover closes during SPA route teardown. diff --git a/.changeset/flat-taxes-clap.md b/.changeset/flat-taxes-clap.md new file mode 100644 index 0000000000..e9fb18fb00 --- /dev/null +++ b/.changeset/flat-taxes-clap.md @@ -0,0 +1,5 @@ +--- +"@zag-js/dom-query": patch +--- + +Accept ShadowRoot as argument for query and queryAll. diff --git a/.changeset/pretty-candies-hunt.md b/.changeset/pretty-candies-hunt.md new file mode 100644 index 0000000000..f731c1017e --- /dev/null +++ b/.changeset/pretty-candies-hunt.md @@ -0,0 +1,10 @@ +--- +"@zag-js/hover-card": patch +"@zag-js/popover": patch +"@zag-js/tooltip": patch +"@zag-js/dialog": patch +"@zag-js/drawer": patch +"@zag-js/menu": patch +--- + +Fix trigger element lookups in shadow root. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index eaca5aac58..cbf686d6ef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -78,7 +78,7 @@ export interface BindableFn { export interface Scope { id?: string | undefined ids?: Record | undefined - getRootNode: () => ShadowRoot | Document | Node + getRootNode: () => ShadowRoot | Document | Element getById: (id: string) => T | null getActiveElement: () => HTMLElement | null isActiveElement: (elem: HTMLElement | null) => boolean diff --git a/packages/machines/dialog/src/dialog.dom.ts b/packages/machines/dialog/src/dialog.dom.ts index b24ee2cc0c..8eb83328aa 100644 --- a/packages/machines/dialog/src/dialog.dom.ts +++ b/packages/machines/dialog/src/dialog.dom.ts @@ -25,8 +25,11 @@ export const getDescriptionEl = (ctx: Scope) => ctx.getById(getDescriptionId(ctx export const getCloseTriggerEl = (ctx: Scope) => ctx.getById(getCloseTriggerId(ctx)) export const getTriggerEls = (ctx: Scope) => - queryAll(ctx.getDoc(), `[data-scope="dialog"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="dialog"][data-part="trigger"][data-ownedby="${ctx.id}"]`) export const getActiveTriggerEl = (ctx: Scope, value: string | null): HTMLElement | null => { - return value == null ? getTriggerEls(ctx)[0] : ctx.getById(getTriggerId(ctx, value)) + if (value == null) { + return getTriggerEl(ctx) ?? getTriggerEls(ctx)[0] + } + return ctx.getById(getTriggerId(ctx, value)) } diff --git a/packages/machines/dialog/src/dialog.machine.ts b/packages/machines/dialog/src/dialog.machine.ts index c82bc3a7d6..79a5c7adec 100644 --- a/packages/machines/dialog/src/dialog.machine.ts +++ b/packages/machines/dialog/src/dialog.machine.ts @@ -133,7 +133,7 @@ export const machine = createMachine({ defer: true, pointerBlocking: prop("modal"), layerStyleTargets: [() => dom.getBackdropEl(scope), () => dom.getPositionerEl(scope)], - exclude: dom.getTriggerEls(scope), + exclude: [dom.getTriggerEl(scope), ...dom.getTriggerEls(scope)].filter(Boolean) as HTMLElement[], onInteractOutside(event) { prop("onInteractOutside")?.(event) if (!prop("closeOnInteractOutside")) { diff --git a/packages/machines/drawer/src/drawer.dom.ts b/packages/machines/drawer/src/drawer.dom.ts index 67c2983422..71c9f72b23 100644 --- a/packages/machines/drawer/src/drawer.dom.ts +++ b/packages/machines/drawer/src/drawer.dom.ts @@ -13,7 +13,7 @@ export const getTriggerId = (ctx: Scope, value?: string) => { } export const getTriggerEls = (ctx: Scope): HTMLElement[] => - queryAll(ctx.getDoc(), `[data-scope="drawer"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="drawer"][data-part="trigger"][data-ownedby="${ctx.id}"]`) export const getActiveTriggerEl = (ctx: Scope, value: string | null): HTMLElement | null => { if (value == null) return getTriggerEl(ctx) ?? getTriggerEls(ctx)[0] diff --git a/packages/machines/drawer/src/drawer.machine.ts b/packages/machines/drawer/src/drawer.machine.ts index 364cd07a07..88beaf4d97 100644 --- a/packages/machines/drawer/src/drawer.machine.ts +++ b/packages/machines/drawer/src/drawer.machine.ts @@ -689,7 +689,7 @@ export const machine = createMachine({ defer: true, pointerBlocking: prop("modal"), layerStyleTargets: [() => dom.getBackdropEl(scope), () => dom.getPositionerEl(scope)], - exclude: [dom.getTriggerEl(scope)], + exclude: [dom.getTriggerEl(scope), ...dom.getTriggerEls(scope)].filter(Boolean) as HTMLElement[], onInteractOutside(event) { prop("onInteractOutside")?.(event) if (!prop("closeOnInteractOutside")) { diff --git a/packages/machines/hover-card/src/hover-card.dom.ts b/packages/machines/hover-card/src/hover-card.dom.ts index 90f77e6020..b0e6d325c5 100644 --- a/packages/machines/hover-card/src/hover-card.dom.ts +++ b/packages/machines/hover-card/src/hover-card.dom.ts @@ -16,8 +16,14 @@ export const getContentEl = (scope: Scope) => scope.getById(getContentId(scope)) export const getPositionerEl = (scope: Scope) => scope.getById(getPositionerId(scope)) export const getTriggerEls = (scope: Scope): HTMLElement[] => - queryAll(scope.getDoc(), `[data-scope="hover-card"][data-part="trigger"][data-ownedby="${scope.id}"]`) + queryAll( + scope.getRootNode(), + `[data-scope="hover-card"][data-part="trigger"][data-ownedby="${scope.id}"]`, + ) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { - return value == null ? getTriggerEls(scope)[0] : scope.getById(getTriggerId(scope, value)) + if (value == null) { + return getTriggerEl(scope) ?? getTriggerEls(scope)[0] + } + return scope.getById(getTriggerId(scope, value)) } diff --git a/packages/machines/hover-card/src/hover-card.machine.ts b/packages/machines/hover-card/src/hover-card.machine.ts index 24be139b39..61f310709d 100644 --- a/packages/machines/hover-card/src/hover-card.machine.ts +++ b/packages/machines/hover-card/src/hover-card.machine.ts @@ -271,7 +271,7 @@ export const machine = createMachine({ return trackDismissableElement(getContentEl, { type: "popover", defer: true, - exclude: dom.getTriggerEls(scope), + exclude: [dom.getTriggerEl(scope), ...dom.getTriggerEls(scope)].filter(Boolean) as HTMLElement[], onDismiss() { send({ type: "CLOSE", src: "interact-outside" }) }, diff --git a/packages/machines/menu/src/menu.dom.ts b/packages/machines/menu/src/menu.dom.ts index 6964a2a1b1..c8bff3e713 100644 --- a/packages/machines/menu/src/menu.dom.ts +++ b/packages/machines/menu/src/menu.dom.ts @@ -33,10 +33,10 @@ export const getArrowEl = (ctx: Scope) => ctx.getById(getArrowId(ctx)) export const getContextTriggerEl = (ctx: Scope) => ctx.getById(getContextTriggerId(ctx)) export const getTriggerEls = (ctx: Scope): HTMLElement[] => - queryAll(ctx.getDoc(), `[data-scope="menu"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="menu"][data-part="trigger"][data-ownedby="${ctx.id}"]`) export const getContextTriggerEls = (ctx: Scope): HTMLElement[] => - queryAll(ctx.getDoc(), `[data-scope="menu"][data-part="context-trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="menu"][data-part="context-trigger"][data-ownedby="${ctx.id}"]`) export const getActiveTriggerEl = (ctx: Scope, value: string | null): HTMLElement | null => { // When value is null, use ID-based lookup (works for submenus with trigger-item) diff --git a/packages/machines/popover/src/popover.dom.ts b/packages/machines/popover/src/popover.dom.ts index 41a99bcebd..411ef450f1 100644 --- a/packages/machines/popover/src/popover.dom.ts +++ b/packages/machines/popover/src/popover.dom.ts @@ -18,11 +18,16 @@ export const getCloseTriggerId = (scope: Scope) => scope.ids?.closeTrigger ?? `p export const getAnchorEl = (scope: Scope) => scope.getById(getAnchorId(scope)) +export const getTriggerEl = (scope: Scope) => scope.getById(getTriggerId(scope)) + export const getTriggerEls = (scope: Scope): HTMLElement[] => - queryAll(scope.getDoc(), `[data-scope="popover"][data-part="trigger"][data-ownedby="${scope.id}"]`) + queryAll(scope.getRootNode(), `[data-scope="popover"][data-part="trigger"][data-ownedby="${scope.id}"]`) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { - return value == null ? getTriggerEls(scope)[0] : scope.getById(getTriggerId(scope, value)) + if (value == null) { + return getTriggerEl(scope) ?? getTriggerEls(scope)[0] + } + return scope.getById(getTriggerId(scope, value)) } export const getContentEl = (scope: Scope) => scope.getById(getContentId(scope)) export const getPositionerEl = (scope: Scope) => scope.getById(getPositionerId(scope)) diff --git a/packages/machines/popover/src/popover.machine.ts b/packages/machines/popover/src/popover.machine.ts index dbe5913b60..0b91fa4b80 100644 --- a/packages/machines/popover/src/popover.machine.ts +++ b/packages/machines/popover/src/popover.machine.ts @@ -169,7 +169,7 @@ export const machine = createMachine({ return trackDismissableElement(getContentEl, { type: "popover", pointerBlocking: prop("modal"), - exclude: dom.getTriggerEls(scope), + exclude: [dom.getTriggerEl(scope), ...dom.getTriggerEls(scope)].filter(Boolean) as HTMLElement[], defer: true, onEscapeKeyDown(event) { prop("onEscapeKeyDown")?.(event) diff --git a/packages/machines/tooltip/src/tooltip.dom.ts b/packages/machines/tooltip/src/tooltip.dom.ts index 7067f0b353..d1c19e03d1 100644 --- a/packages/machines/tooltip/src/tooltip.dom.ts +++ b/packages/machines/tooltip/src/tooltip.dom.ts @@ -23,8 +23,11 @@ export const getPositionerEl = (scope: Scope) => scope.getById(getPositionerId(s export const getArrowEl = (scope: Scope) => scope.getById(getArrowId(scope)) export const getTriggerEls = (scope: Scope): HTMLElement[] => - queryAll(scope.getDoc(), `[data-scope="tooltip"][data-part="trigger"][data-ownedby="${scope.id}"]`) + queryAll(scope.getRootNode(), `[data-scope="tooltip"][data-part="trigger"][data-ownedby="${scope.id}"]`) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { - return value == null ? getTriggerEls(scope)[0] : scope.getById(getTriggerId(scope, value)) + if (value == null) { + return getTriggerEl(scope) ?? getTriggerEls(scope)[0] + } + return scope.getById(getTriggerId(scope, value)) } diff --git a/packages/utilities/dismissable/src/pointer-event-outside.ts b/packages/utilities/dismissable/src/pointer-event-outside.ts index 161965d184..ea4d14bff6 100644 --- a/packages/utilities/dismissable/src/pointer-event-outside.ts +++ b/packages/utilities/dismissable/src/pointer-event-outside.ts @@ -19,10 +19,12 @@ export function disablePointerEventsOutside(node: HTMLElement, persistentElement const cleanups: VoidFunction[] = [] if (layerStack.hasPointerBlockingLayer() && !doc.body.hasAttribute("data-inert")) { - originalBodyPointerEvents = document.body.style.pointerEvents + originalBodyPointerEvents = doc.body.style.pointerEvents queueMicrotask(() => { - doc.body.style.pointerEvents = "none" - doc.body.setAttribute("data-inert", "") + const body = doc.body + if (!body) return + body.style.pointerEvents = "none" + body.setAttribute("data-inert", "") }) } @@ -41,9 +43,11 @@ export function disablePointerEventsOutside(node: HTMLElement, persistentElement return () => { if (layerStack.hasPointerBlockingLayer()) return queueMicrotask(() => { - doc.body.style.pointerEvents = originalBodyPointerEvents - doc.body.removeAttribute("data-inert") - if (doc.body.style.length === 0) doc.body.removeAttribute("style") + const body = doc.body + if (!body) return + body.style.pointerEvents = originalBodyPointerEvents + body.removeAttribute("data-inert") + if (body.style.length === 0) body.removeAttribute("style") }) cleanups.forEach((fn) => fn()) } diff --git a/packages/utilities/dom-query/src/query.ts b/packages/utilities/dom-query/src/query.ts index 2815b4c0e5..e38aa38798 100644 --- a/packages/utilities/dom-query/src/query.ts +++ b/packages/utilities/dom-query/src/query.ts @@ -1,4 +1,4 @@ -type Root = Document | Element | null | undefined +type Root = Document | ShadowRoot | Element | null | undefined export function queryAll(root: Root, selector: string) { return Array.from(root?.querySelectorAll(selector) ?? []) diff --git a/website/demos/image-cropper.tsx b/website/demos/image-cropper.tsx index ef27585662..5b1a10f31e 100644 --- a/website/demos/image-cropper.tsx +++ b/website/demos/image-cropper.tsx @@ -43,9 +43,8 @@ export function ImageCropper(props: ImageCropperProps) {