Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .changeset/smooth-owners-compose.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions packages/core/src/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> => {
Expand Down Expand Up @@ -61,6 +70,11 @@ export function mergeProps<T extends Props>(...args: Array<T | undefined>): Unio
continue
}

if (key === "data-ownedby") {
result[key] = ownedBy(result[key], props[key])
continue
}

result[key] = props[key] !== undefined ? props[key] : result[key]
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/tests/merge-props.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
8 changes: 7 additions & 1 deletion packages/frameworks/solid/src/merge-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions packages/frameworks/solid/tests/merge-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down
6 changes: 6 additions & 0 deletions packages/frameworks/svelte/tests/merge-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;'
Expand Down
5 changes: 2 additions & 3 deletions packages/machines/accordion/src/accordion.dom.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/machines/dialog/src/dialog.dom.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/machines/drawer/src/drawer.dom.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -13,7 +13,7 @@ export const getTriggerId = (ctx: Scope, value?: string) => {
}

export const getTriggerEls = (ctx: Scope): HTMLElement[] =>
queryAll<HTMLElement>(ctx.getRootNode(), `[data-scope="drawer"][data-part="trigger"][data-ownedby="${ctx.id}"]`)
queryAll<HTMLElement>(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]
Expand Down
7 changes: 2 additions & 5 deletions packages/machines/hover-card/src/hover-card.dom.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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<HTMLElement>(
scope.getRootNode(),
`[data-scope="hover-card"][data-part="trigger"][data-ownedby="${scope.id}"]`,
)
queryAll<HTMLElement>(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) {
Expand Down
17 changes: 12 additions & 5 deletions packages/machines/menu/src/menu.dom.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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<HTMLElement>(ctx.getRootNode(), `[data-scope="menu"][data-part="trigger"][data-ownedby="${ctx.id}"]`)
queryAll<HTMLElement>(ctx.getRootNode(), `[data-scope="menu"][data-part="trigger"]${getByOwnerId(ctx.id)}`)

export const getContextTriggerEls = (ctx: Scope): HTMLElement[] =>
queryAll<HTMLElement>(ctx.getRootNode(), `[data-scope="menu"][data-part="context-trigger"][data-ownedby="${ctx.id}"]`)
queryAll<HTMLElement>(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)
Expand All @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/machines/navigation-menu/src/navigation-menu.dom.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/machines/pin-input/src/pin-input.connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -258,7 +258,7 @@ export function connect<T extends PropTypes>(
},
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 })
},
})
Expand Down
5 changes: 2 additions & 3 deletions packages/machines/pin-input/src/pin-input.dom.ts
Original file line number Diff line number Diff line change
@@ -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}`
Expand All @@ -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<HTMLInputElement>(getRootEl(ctx), selector)
}
export const getInputEl = (ctx: Scope, id: string) => ctx.getById<HTMLInputElement>(getInputId(ctx, id))
Expand Down
4 changes: 2 additions & 2 deletions packages/machines/popover/src/popover.dom.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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<HTMLElement>(scope.getRootNode(), `[data-scope="popover"][data-part="trigger"][data-ownedby="${scope.id}"]`)
queryAll<HTMLElement>(scope.getRootNode(), `[data-scope="popover"][data-part="trigger"]${getByOwnerId(scope.id)}`)

export const getActiveTriggerEl = (scope: Scope, value: string | null): HTMLElement | null => {
if (value == null) {
Expand Down
5 changes: 2 additions & 3 deletions packages/machines/radio-group/src/radio-group.dom.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -23,8 +23,7 @@ export const getFirstEnabledAndCheckedInputEl = (ctx: Scope) =>
getRootEl(ctx)?.querySelector<HTMLInputElement>("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<HTMLInputElement>(getRootEl(ctx), selector)
}

Expand Down
12 changes: 6 additions & 6 deletions packages/machines/scroll-area/src/scroll-area.dom.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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))}`)
7 changes: 3 additions & 4 deletions packages/machines/splitter/src/splitter.dom.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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))
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 2 additions & 3 deletions packages/machines/tabs/src/tabs.dom.ts
Original file line number Diff line number Diff line change
@@ -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}`
Expand All @@ -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)
}

Expand Down
5 changes: 2 additions & 3 deletions packages/machines/toggle-group/src/toggle-group.dom.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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}`
export const getItemId = (ctx: Scope, value: string) => ctx.ids?.item?.(value) ?? `toggle-group:${ctx.id}:${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))
Expand Down
Loading
Loading