diff --git a/.changeset/smooth-owners-compose.md b/.changeset/smooth-owners-compose.md new file mode 100644 index 0000000000..cb19edadf2 --- /dev/null +++ b/.changeset/smooth-owners-compose.md @@ -0,0 +1,22 @@ +--- +"@zag-js/core": patch +"@zag-js/dom-query": patch +"@zag-js/solid": patch +"@zag-js/svelte": patch +"@zag-js/accordion": patch +"@zag-js/dialog": patch +"@zag-js/drawer": patch +"@zag-js/hover-card": patch +"@zag-js/menu": patch +"@zag-js/navigation-menu": patch +"@zag-js/pin-input": patch +"@zag-js/popover": patch +"@zag-js/radio-group": patch +"@zag-js/scroll-area": patch +"@zag-js/splitter": patch +"@zag-js/tabs": patch +"@zag-js/toggle-group": patch +"@zag-js/tooltip": patch +--- + +Compose `data-ownedby` values when merging props, match owners as tokens in DOM queries, and expose `isOwnedBy` for membership checks. diff --git a/packages/core/src/merge-props.ts b/packages/core/src/merge-props.ts index c0742fa5e0..588a9fcab2 100644 --- a/packages/core/src/merge-props.ts +++ b/packages/core/src/merge-props.ts @@ -10,6 +10,15 @@ const clsx = (...args: (string | undefined)[]) => .filter(Boolean) .join(" ") +const ownedBy = (...args: (string | undefined)[]) => + Array.from( + new Set( + clsx(...args) + .split(/\s+/) + .filter(Boolean), + ), + ).join(" ") + const CSS_REGEX = /((?:--)?(?:\w+-?)+)\s*:\s*([^;]*)/g const serialize = (style: string): Record => { @@ -61,6 +70,11 @@ export function mergeProps(...args: Array): Unio continue } + if (key === "data-ownedby") { + result[key] = ownedBy(result[key], props[key]) + continue + } + result[key] = props[key] !== undefined ? props[key] : result[key] } diff --git a/packages/core/tests/merge-props.test.ts b/packages/core/tests/merge-props.test.ts new file mode 100644 index 0000000000..a601cd3e23 --- /dev/null +++ b/packages/core/tests/merge-props.test.ts @@ -0,0 +1,21 @@ +import { mergeProps } from "../src/merge-props" + +describe("mergeProps", () => { + test("combines data-ownedby tokens", () => { + const props = mergeProps({ "data-ownedby": "toggle-group" }, { "data-ownedby": "tooltip" }) + + expect(props["data-ownedby"]).toBe("toggle-group tooltip") + }) + + test("dedupes data-ownedby tokens", () => { + const props = mergeProps({ "data-ownedby": "toggle-group tooltip" }, { "data-ownedby": "tooltip toggle-group" }) + + expect(props["data-ownedby"]).toBe("toggle-group tooltip") + }) + + test("keeps normal override behavior for other data attributes", () => { + const props = mergeProps({ "data-state": "on" }, { "data-state": "open" }) + + expect(props["data-state"]).toBe("open") + }) +}) diff --git a/packages/frameworks/solid/src/merge-props.ts b/packages/frameworks/solid/src/merge-props.ts index 5c4f4696d0..13ff8e802b 100644 --- a/packages/frameworks/solid/src/merge-props.ts +++ b/packages/frameworks/solid/src/merge-props.ts @@ -28,7 +28,13 @@ export function mergeProps(...sources: any[]) { enumerable: true, get() { let e = {} - if (key === "style" || key === "class" || key === "className" || key.startsWith("on")) { + if ( + key === "style" || + key === "class" || + key === "className" || + key === "data-ownedby" || + key.startsWith("on") + ) { for (let i = 0; i < sources.length; i++) { let s = sources[i] if (typeof s === "function") s = s() diff --git a/packages/frameworks/solid/tests/merge-props.test.ts b/packages/frameworks/solid/tests/merge-props.test.ts index c882f39179..1b2579cd69 100644 --- a/packages/frameworks/solid/tests/merge-props.test.ts +++ b/packages/frameworks/solid/tests/merge-props.test.ts @@ -51,6 +51,16 @@ describe("mergeProps", () => { }) }) + it("combines data-ownedby tokens", () => { + createRoot((dispose) => { + const props = mergeProps({ "data-ownedby": "toggle-group tooltip" }, { "data-ownedby": "tooltip popover" }) + + expect(props["data-ownedby"]).toBe("toggle-group tooltip popover") + + dispose() + }) + }) + it("combines styles", () => createRoot((dispose) => { const stringStyles = ` diff --git a/packages/frameworks/svelte/tests/merge-props.test.ts b/packages/frameworks/svelte/tests/merge-props.test.ts index 8f6048de09..1ea2d295c0 100644 --- a/packages/frameworks/svelte/tests/merge-props.test.ts +++ b/packages/frameworks/svelte/tests/merge-props.test.ts @@ -38,6 +38,12 @@ describe("mergeProps for Svelte", () => { expect(clsx(props.class)).toBe("primary hover focus") }) + it("combines data-ownedby tokens", () => { + const props = mergeProps({ "data-ownedby": "toggle-group tooltip" }, { "data-ownedby": "tooltip popover" }) + + expect(props["data-ownedby"]).toBe("toggle-group tooltip popover") + }) + it("combines styles", () => { const apiStyles = 'margin:24px;padding:2;background-image:url("http://example.com/image.png");border:1px solid #123456;--x:123;' diff --git a/packages/machines/accordion/src/accordion.dom.ts b/packages/machines/accordion/src/accordion.dom.ts index c2868dfba7..355a18a717 100644 --- a/packages/machines/accordion/src/accordion.dom.ts +++ b/packages/machines/accordion/src/accordion.dom.ts @@ -1,4 +1,4 @@ -import { nextById, prevById, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, nextById, prevById, queryAll } from "@zag-js/dom-query" import { first, last } from "@zag-js/utils" import type { Scope } from "@zag-js/core" @@ -11,8 +11,7 @@ export const getItemTriggerId = (ctx: Scope, value: string) => export const getRootEl = (ctx: Scope) => ctx.getById(getRootId(ctx)) export const getTriggerEls = (ctx: Scope) => { - const ownerId = CSS.escape(getRootId(ctx)) - const selector = `[data-controls][data-ownedby='${ownerId}']:not([disabled])` + const selector = `[data-controls]${getByOwnerId(getRootId(ctx))}:not([disabled])` return queryAll(getRootEl(ctx), selector) } diff --git a/packages/machines/dialog/src/dialog.dom.ts b/packages/machines/dialog/src/dialog.dom.ts index 8eb83328aa..3a02150614 100644 --- a/packages/machines/dialog/src/dialog.dom.ts +++ b/packages/machines/dialog/src/dialog.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { queryAll } from "@zag-js/dom-query" +import { getByOwnerId, queryAll } from "@zag-js/dom-query" import { isFunction } from "@zag-js/utils" export const getPositionerId = (ctx: Scope) => ctx.ids?.positioner ?? `dialog:${ctx.id}:positioner` @@ -25,7 +25,7 @@ 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.getRootNode(), `[data-scope="dialog"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="dialog"][data-part="trigger"]${getByOwnerId(ctx.id)}`) export const getActiveTriggerEl = (ctx: Scope, value: string | null): HTMLElement | null => { if (value == null) { diff --git a/packages/machines/drawer/src/drawer.dom.ts b/packages/machines/drawer/src/drawer.dom.ts index 71c9f72b23..a7233840f2 100644 --- a/packages/machines/drawer/src/drawer.dom.ts +++ b/packages/machines/drawer/src/drawer.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { isHTMLElement, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, isHTMLElement, queryAll } from "@zag-js/dom-query" import { isFunction } from "@zag-js/utils" export const getContentId = (ctx: Scope) => ctx.ids?.content ?? `drawer:${ctx.id}:content` @@ -13,7 +13,7 @@ export const getTriggerId = (ctx: Scope, value?: string) => { } export const getTriggerEls = (ctx: Scope): HTMLElement[] => - queryAll(ctx.getRootNode(), `[data-scope="drawer"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="drawer"][data-part="trigger"]${getByOwnerId(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/hover-card/src/hover-card.dom.ts b/packages/machines/hover-card/src/hover-card.dom.ts index b0e6d325c5..e012e5c3f4 100644 --- a/packages/machines/hover-card/src/hover-card.dom.ts +++ b/packages/machines/hover-card/src/hover-card.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { queryAll } from "@zag-js/dom-query" +import { getByOwnerId, queryAll } from "@zag-js/dom-query" import { isFunction } from "@zag-js/utils" export const getTriggerId = (scope: Scope, value?: string) => { @@ -16,10 +16,7 @@ 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.getRootNode(), - `[data-scope="hover-card"][data-part="trigger"][data-ownedby="${scope.id}"]`, - ) + queryAll(scope.getRootNode(), `[data-scope="hover-card"][data-part="trigger"]${getByOwnerId(scope.id)}`) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { if (value == null) { diff --git a/packages/machines/menu/src/menu.dom.ts b/packages/machines/menu/src/menu.dom.ts index c8bff3e713..dd74afaed3 100644 --- a/packages/machines/menu/src/menu.dom.ts +++ b/packages/machines/menu/src/menu.dom.ts @@ -1,5 +1,13 @@ import type { Scope } from "@zag-js/core" -import { contains, getByTypeahead, getWindow, isHTMLElement, queryAll, type TypeaheadState } from "@zag-js/dom-query" +import { + contains, + getByOwnerId, + getByTypeahead, + getWindow, + isHTMLElement, + queryAll, + type TypeaheadState, +} from "@zag-js/dom-query" import { first, isFunction, last, next, prev } from "@zag-js/utils" import type { MenuService } from "./menu.types" @@ -33,10 +41,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.getRootNode(), `[data-scope="menu"][data-part="trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="menu"][data-part="trigger"]${getByOwnerId(ctx.id)}`) export const getContextTriggerEls = (ctx: Scope): HTMLElement[] => - queryAll(ctx.getRootNode(), `[data-scope="menu"][data-part="context-trigger"][data-ownedby="${ctx.id}"]`) + queryAll(ctx.getRootNode(), `[data-scope="menu"][data-part="context-trigger"]${getByOwnerId(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) @@ -48,8 +56,7 @@ export const getActiveTriggerEl = (ctx: Scope, value: string | null): HTMLElemen } export const getElements = (ctx: Scope) => { - const ownerId = CSS.escape(getContentId(ctx)) - const selector = `[role^="menuitem"][data-ownedby=${ownerId}]:not([data-disabled])` + const selector = `[role^="menuitem"]${getByOwnerId(getContentId(ctx))}:not([data-disabled])` return queryAll(getContentEl(ctx), selector) } diff --git a/packages/machines/navigation-menu/src/navigation-menu.dom.ts b/packages/machines/navigation-menu/src/navigation-menu.dom.ts index 4ebf1c298d..523c8167d0 100644 --- a/packages/machines/navigation-menu/src/navigation-menu.dom.ts +++ b/packages/machines/navigation-menu/src/navigation-menu.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { getTabbables, getWindow, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, getTabbables, getWindow, queryAll } from "@zag-js/dom-query" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `nav-menu:${ctx.id}` export const getTriggerId = (ctx: Scope, value: string) => @@ -40,7 +40,7 @@ export const getTabbableEls = (ctx: Scope, value: string) => { export const getTriggerEls = (ctx: Scope) => queryAll(getListEl(ctx), `[data-part=trigger][data-uid='${ctx.id}']`) export const getLinkEls = (ctx: Scope, value: string) => { const contentEl = getContentEl(ctx, value) - return queryAll(contentEl, `[data-part=link][data-ownedby="${getContentId(ctx, value)}"]`) + return queryAll(contentEl, `[data-part=link]${getByOwnerId(getContentId(ctx, value))}`) } export const getElements = (ctx: Scope) => { diff --git a/packages/machines/pin-input/src/pin-input.connect.ts b/packages/machines/pin-input/src/pin-input.connect.ts index 2282051aa8..52481263ca 100644 --- a/packages/machines/pin-input/src/pin-input.connect.ts +++ b/packages/machines/pin-input/src/pin-input.connect.ts @@ -6,8 +6,8 @@ import { getEventKey, getNativeEvent, isComposingEvent, - isHTMLElement, isModifierKey, + isOwnedBy, visuallyHiddenStyle, } from "@zag-js/dom-query" import type { EventKeyMap, NormalizeProps, PropTypes } from "@zag-js/types" @@ -258,7 +258,7 @@ export function connect( }, onBlur(event) { const target = event.relatedTarget as HTMLElement - if (isHTMLElement(target) && target.dataset.ownedby === dom.getRootId(scope)) return + if (isOwnedBy(target, dom.getRootId(scope))) return send({ type: "INPUT.BLUR", index }) }, }) diff --git a/packages/machines/pin-input/src/pin-input.dom.ts b/packages/machines/pin-input/src/pin-input.dom.ts index f086b369f4..fd72daf506 100644 --- a/packages/machines/pin-input/src/pin-input.dom.ts +++ b/packages/machines/pin-input/src/pin-input.dom.ts @@ -1,4 +1,4 @@ -import { queryAll } from "@zag-js/dom-query" +import { getByOwnerId, queryAll } from "@zag-js/dom-query" import type { Scope } from "@zag-js/core" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `pin-input:${ctx.id}` @@ -9,8 +9,7 @@ export const getControlId = (ctx: Scope) => ctx.ids?.control ?? `pin-input:${ctx export const getRootEl = (ctx: Scope) => ctx.getById(getRootId(ctx)) export const getInputEls = (ctx: Scope) => { - const ownerId = CSS.escape(getRootId(ctx)) - const selector = `input[data-ownedby=${ownerId}]` + const selector = `input${getByOwnerId(getRootId(ctx))}` return queryAll(getRootEl(ctx), selector) } export const getInputEl = (ctx: Scope, id: string) => ctx.getById(getInputId(ctx, id)) diff --git a/packages/machines/popover/src/popover.dom.ts b/packages/machines/popover/src/popover.dom.ts index 411ef450f1..c2a1cddbcf 100644 --- a/packages/machines/popover/src/popover.dom.ts +++ b/packages/machines/popover/src/popover.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { getFocusables, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, getFocusables, queryAll } from "@zag-js/dom-query" import { isFunction } from "@zag-js/utils" export const getAnchorId = (scope: Scope) => scope.ids?.anchor ?? `popover:${scope.id}:anchor` @@ -21,7 +21,7 @@ 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.getRootNode(), `[data-scope="popover"][data-part="trigger"][data-ownedby="${scope.id}"]`) + queryAll(scope.getRootNode(), `[data-scope="popover"][data-part="trigger"]${getByOwnerId(scope.id)}`) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { if (value == null) { diff --git a/packages/machines/radio-group/src/radio-group.dom.ts b/packages/machines/radio-group/src/radio-group.dom.ts index 09b455d2cd..e7ca8ffc99 100644 --- a/packages/machines/radio-group/src/radio-group.dom.ts +++ b/packages/machines/radio-group/src/radio-group.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { queryAll } from "@zag-js/dom-query" +import { getByOwnerId, queryAll } from "@zag-js/dom-query" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `radio-group:${ctx.id}` export const getLabelId = (ctx: Scope) => ctx.ids?.label ?? `radio-group:${ctx.id}:label` @@ -23,8 +23,7 @@ export const getFirstEnabledAndCheckedInputEl = (ctx: Scope) => getRootEl(ctx)?.querySelector("input:not(:disabled):checked") export const getInputEls = (ctx: Scope) => { - const ownerId = CSS.escape(getRootId(ctx)) - const selector = `input[type=radio][data-ownedby='${ownerId}']:not([disabled])` + const selector = `input[type=radio]${getByOwnerId(getRootId(ctx))}:not([disabled])` return queryAll(getRootEl(ctx), selector) } diff --git a/packages/machines/scroll-area/src/scroll-area.dom.ts b/packages/machines/scroll-area/src/scroll-area.dom.ts index 5ca7ae7536..bd4ee17ad6 100644 --- a/packages/machines/scroll-area/src/scroll-area.dom.ts +++ b/packages/machines/scroll-area/src/scroll-area.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { query } from "@zag-js/dom-query" +import { getByOwnerId, query } from "@zag-js/dom-query" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `scroll-area-${ctx.id}` export const getViewportId = (ctx: Scope) => ctx.ids?.viewport ?? `scroll-area-${ctx.id}:viewport` @@ -10,13 +10,13 @@ export const getViewportEl = (ctx: Scope) => ctx.getById(getViewportId(ctx)) export const getContentEl = (ctx: Scope) => ctx.getById(getContentId(ctx)) export const getScrollbarXEl = (ctx: Scope) => - query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=horizontal][data-ownedby="${getRootId(ctx)}"]`) + query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=horizontal]${getByOwnerId(getRootId(ctx))}`) export const getScrollbarYEl = (ctx: Scope) => - query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=vertical][data-ownedby="${getRootId(ctx)}"]`) + query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=vertical]${getByOwnerId(getRootId(ctx))}`) export const getThumbXEl = (ctx: Scope) => - query(getScrollbarXEl(ctx), `[data-part=thumb][data-orientation=horizontal][data-ownedby="${getRootId(ctx)}"]`) + query(getScrollbarXEl(ctx), `[data-part=thumb][data-orientation=horizontal]${getByOwnerId(getRootId(ctx))}`) export const getThumbYEl = (ctx: Scope) => - query(getScrollbarYEl(ctx), `[data-part=thumb][data-orientation=vertical][data-ownedby="${getRootId(ctx)}"]`) + query(getScrollbarYEl(ctx), `[data-part=thumb][data-orientation=vertical]${getByOwnerId(getRootId(ctx))}`) -export const getCornerEl = (ctx: Scope) => query(getRootEl(ctx), `[data-part=corner][data-ownedby="${getRootId(ctx)}"]`) +export const getCornerEl = (ctx: Scope) => query(getRootEl(ctx), `[data-part=corner]${getByOwnerId(getRootId(ctx))}`) diff --git a/packages/machines/splitter/src/splitter.dom.ts b/packages/machines/splitter/src/splitter.dom.ts index 399cee2586..29ea46a7bb 100644 --- a/packages/machines/splitter/src/splitter.dom.ts +++ b/packages/machines/splitter/src/splitter.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { isHTMLElement, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, isHTMLElement, queryAll } from "@zag-js/dom-query" import type { Style } from "@zag-js/types" import type { CursorState, ResizeTriggerId } from "./splitter.types" @@ -8,8 +8,7 @@ export const getResizeTriggerId = (ctx: Scope, id: string) => ctx.ids?.resizeTrigger?.(id) ?? `splitter:${ctx.id}:splitter:${id}` export const getLabelId = (ctx: Scope) => ctx.ids?.label ?? `splitter:${ctx.id}:label` export const getPanelId = (ctx: Scope, id: string | number) => ctx.ids?.panel?.(id) ?? `splitter:${ctx.id}:panel:${id}` -export const getPanelEls = (ctx: Scope) => - queryAll(getRootEl(ctx), `[data-part=panel][data-ownedby='${CSS.escape(getRootId(ctx))}']`) +export const getPanelEls = (ctx: Scope) => queryAll(getRootEl(ctx), `[data-part=panel]${getByOwnerId(getRootId(ctx))}`) export const getGlobalCursorId = (ctx: Scope) => `splitter:${ctx.id}:global-cursor` export const getRootEl = (ctx: Scope) => ctx.getById(getRootId(ctx)) @@ -58,7 +57,7 @@ export const getCursor = (state: CursorState, x: boolean) => { } export const getResizeTriggerEls = (ctx: Scope) => { - return queryAll(getRootEl(ctx), `[role=separator][data-ownedby='${CSS.escape(getRootId(ctx))}']`) + return queryAll(getRootEl(ctx), `[role=separator]${getByOwnerId(getRootId(ctx))}`) } export const getGlobalCursorEl = (ctx: Scope) => { diff --git a/packages/machines/tabs/src/tabs.dom.ts b/packages/machines/tabs/src/tabs.dom.ts index bb4a11d977..4cb8af2092 100644 --- a/packages/machines/tabs/src/tabs.dom.ts +++ b/packages/machines/tabs/src/tabs.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { itemById, nextById, prevById, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, itemById, nextById, prevById, queryAll } from "@zag-js/dom-query" import { first, last } from "@zag-js/utils" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `tabs:${ctx.id}` @@ -17,8 +17,7 @@ export const getTriggerEl = (ctx: Scope, value: string | null) => export const getIndicatorEl = (ctx: Scope) => ctx.getById(getIndicatorId(ctx)) export const getElements = (ctx: Scope) => { - const ownerId = CSS.escape(getListId(ctx)) - const selector = `[role=tab][data-ownedby='${ownerId}']:not([disabled])` + const selector = `[role=tab]${getByOwnerId(getListId(ctx))}:not([disabled])` return queryAll(getListEl(ctx), selector) } diff --git a/packages/machines/toggle-group/src/toggle-group.dom.ts b/packages/machines/toggle-group/src/toggle-group.dom.ts index 2063621673..6a0e3d0962 100644 --- a/packages/machines/toggle-group/src/toggle-group.dom.ts +++ b/packages/machines/toggle-group/src/toggle-group.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { nextById, prevById, queryAll } from "@zag-js/dom-query" +import { getByOwnerId, nextById, prevById, queryAll } from "@zag-js/dom-query" import { first, last } from "@zag-js/utils" export const getRootId = (ctx: Scope) => ctx.ids?.root ?? `toggle-group:${ctx.id}` @@ -7,8 +7,7 @@ export const getItemId = (ctx: Scope, value: string) => ctx.ids?.item?.(value) ? export const getRootEl = (ctx: Scope) => ctx.getById(getRootId(ctx)) export const getElements = (ctx: Scope) => { - const ownerId = CSS.escape(getRootId(ctx)) - const selector = `[data-ownedby='${ownerId}']:not([data-disabled])` + const selector = `${getByOwnerId(getRootId(ctx))}:not([data-disabled])` return queryAll(getRootEl(ctx), selector) } export const getFirstEl = (ctx: Scope) => first(getElements(ctx)) diff --git a/packages/machines/tooltip/src/tooltip.connect.ts b/packages/machines/tooltip/src/tooltip.connect.ts index 3ec44a7d9c..baa762eda0 100644 --- a/packages/machines/tooltip/src/tooltip.connect.ts +++ b/packages/machines/tooltip/src/tooltip.connect.ts @@ -1,5 +1,5 @@ import type { Service } from "@zag-js/core" -import { dataAttr, isLeftClick } from "@zag-js/dom-query" +import { dataAttr, getByOwnerId, isLeftClick } from "@zag-js/dom-query" import { isFocusVisible } from "@zag-js/focus-visible" import { getPlacementSide, getPlacementStyles } from "@zag-js/popper" import type { NormalizeProps, PropTypes } from "@zag-js/types" @@ -82,7 +82,7 @@ export function connect

( // Check if focus moved to another trigger in the same tooltip. // If so, don't close - the focus handler will handle the switch. const activeEl = event.relatedTarget ?? scope.getDoc().activeElement - const focusedAnotherTrigger = activeEl?.closest(`[data-ownedby="${scope.id}"]`) != null + const focusedAnotherTrigger = activeEl?.closest(getByOwnerId(scope.id)) != null if (!focusedAnotherTrigger) { send({ type: "close", src: "trigger.blur", value, triggerId }) } diff --git a/packages/machines/tooltip/src/tooltip.dom.ts b/packages/machines/tooltip/src/tooltip.dom.ts index d1c19e03d1..b891094a8d 100644 --- a/packages/machines/tooltip/src/tooltip.dom.ts +++ b/packages/machines/tooltip/src/tooltip.dom.ts @@ -1,5 +1,5 @@ import type { Scope } from "@zag-js/core" -import { queryAll } from "@zag-js/dom-query" +import { getByOwnerId, queryAll } from "@zag-js/dom-query" import { isFunction } from "@zag-js/utils" export const getTriggerId = (scope: Scope, value?: string) => { @@ -23,7 +23,7 @@ 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.getRootNode(), `[data-scope="tooltip"][data-part="trigger"][data-ownedby="${scope.id}"]`) + queryAll(scope.getRootNode(), `[data-scope="tooltip"][data-part="trigger"]${getByOwnerId(scope.id)}`) export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => { if (value == null) { diff --git a/packages/utilities/dom-query/src/index.ts b/packages/utilities/dom-query/src/index.ts index 0aa5eb0243..280f8645a6 100644 --- a/packages/utilities/dom-query/src/index.ts +++ b/packages/utilities/dom-query/src/index.ts @@ -23,7 +23,7 @@ export * from "./scope" export * from "./scroll" export * from "./searchable" export * from "./set" -export { ariaAttr, dataAttr, MAX_Z_INDEX } from "./shared" +export { ariaAttr, dataAttr, getByOwnerId, isOwnedBy, MAX_Z_INDEX } from "./shared" export * from "./tabbable" export * from "./text-selection" export * from "./typeahead" diff --git a/packages/utilities/dom-query/src/shared.ts b/packages/utilities/dom-query/src/shared.ts index be25cf097e..c98506a51e 100644 --- a/packages/utilities/dom-query/src/shared.ts +++ b/packages/utilities/dom-query/src/shared.ts @@ -21,6 +21,16 @@ export const dataAttr = (guard: boolean | undefined) => (guard ? "" : undefined) export const ariaAttr = (guard: boolean | undefined) => (guard ? "true" : undefined) +const BACKSLASH_RE = /\\/g +const DOUBLE_QUOTE_RE = /"/g + +const cssesc = (value: string) => + globalThis.CSS?.escape?.(value) ?? value.replace(BACKSLASH_RE, "\\\\").replace(DOUBLE_QUOTE_RE, '\\"') + +export const getByOwnerId = (id: string | undefined) => `[data-ownedby~="${cssesc(String(id))}"]` + +export const isOwnedBy = (el: Element | null | undefined, id: string | undefined) => !!el?.matches(getByOwnerId(id)) + export const sanitize = (str: string) => str .split("")