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
7 changes: 7 additions & 0 deletions .changeset/focus-trap-single-tab-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@zag-js/dom-query": patch
"@zag-js/focus-trap": patch
---

- Fix focus trapping when the content has a single effective tab stop, such as a native radio group.
- Handle disconnected `initialFocus` nodes more safely.
6 changes: 6 additions & 0 deletions .changeset/interact-outside-safari-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@zag-js/interact-outside": patch
---

Fix issue where nested popovers and select within popovers didn't toggle correctly in Safari due to `focusin` events
racing with pointer interactions
2 changes: 1 addition & 1 deletion .github/composite-actions/install/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Sets up Node.js and runs install"
runs:
using: composite
steps:
- uses: pnpm/action-setup@v4
- uses: pnpm/action-setup@v5
name: Install pnpm

- name: Setup Node.js
Expand Down
21 changes: 20 additions & 1 deletion e2e/popover.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test } from "@playwright/test"
import { expect, test } from "@playwright/test"
import { PopoverModel } from "./models/popover.model"

let I: PopoverModel
Expand Down Expand Up @@ -55,6 +55,25 @@ test.describe("popover", () => {
await I.seeLinkIsFocused()
})

test("[keyboard / modal] should trap focus when content has a single effective tab stop", async ({ page }) => {
await page.goto("/popover/single-tab-stop")

const trigger = page.getByTestId("popover-trigger")
const checkedRadio = page.getByTestId("radio-name-asc")

await trigger.focus()
await expect(trigger).toBeFocused()

await page.keyboard.press("Enter")
await expect(checkedRadio).toBeFocused()

await page.keyboard.press("Tab")
await expect(checkedRadio).toBeFocused()

await page.keyboard.press("Shift+Tab")
await expect(checkedRadio).toBeFocused()
})

test("[keyboard / non-modal] on tab outside: should move focus to next tabbable element after button", async () => {
await I.focusTrigger()
await I.seeTriggerIsFocused()
Expand Down
53 changes: 20 additions & 33 deletions examples/next-ts/pages/color-picker/in-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,31 @@ const ColorPicker = () => {
</button>
</div>

<Portal>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div className="content__inner">
<div {...api.getAreaProps()}>
<div {...api.getAreaBackgroundProps()} />
<div {...api.getAreaThumbProps()} />
</div>
{api.open && (
<Portal>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div className="content__inner">
<div {...api.getAreaProps()}>
<div {...api.getAreaBackgroundProps()} />
<div {...api.getAreaThumbProps()} />
</div>

<div {...api.getChannelSliderProps({ channel: "hue" })}>
<div {...api.getChannelSliderTrackProps({ channel: "hue" })} />
<div {...api.getChannelSliderThumbProps({ channel: "hue" })} />
</div>
<div {...api.getChannelSliderProps({ channel: "hue" })}>
<div {...api.getChannelSliderTrackProps({ channel: "hue" })} />
<div {...api.getChannelSliderThumbProps({ channel: "hue" })} />
</div>

<div {...api.getChannelSliderProps({ channel: "alpha" })}>
<div {...api.getTransparencyGridProps({ size: "12px" })} />
<div {...api.getChannelSliderTrackProps({ channel: "alpha" })} />
<div {...api.getChannelSliderThumbProps({ channel: "alpha" })} />
<div {...api.getChannelSliderProps({ channel: "alpha" })}>
<div {...api.getTransparencyGridProps({ size: "12px" })} />
<div {...api.getChannelSliderTrackProps({ channel: "alpha" })} />
<div {...api.getChannelSliderThumbProps({ channel: "alpha" })} />
</div>
</div>
</div>
</div>
</div>
</Portal>
</Portal>
)}
</div>
)
}
Expand Down Expand Up @@ -109,21 +111,6 @@ export default function Page() {
</div>
</Dialog>
</div>

<div style={{ marginTop: "20px" }}>
<Dialog title="Nested Dialog Test">
<div style={{ marginBottom: "16px" }}>
<p>First color picker:</p>
<ColorPicker />
</div>

<div style={{ marginTop: "16px" }}>
<Dialog title="Nested Color Picker">
<ColorPicker />
</Dialog>
</div>
</Dialog>
</div>
</main>
)
}
46 changes: 46 additions & 0 deletions examples/next-ts/pages/popover/single-tab-stop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as popover from "@zag-js/popover"
import { normalizeProps, Portal, useMachine } from "@zag-js/react"
import { useId } from "react"
import { Presence } from "../../components/presence"

export default function Page() {
const service = useMachine(popover.machine, {
id: useId(),
modal: true,
})

const api = popover.connect(service, normalizeProps)

return (
<main className="popover">
<div data-part="root">
<button data-testid="button-before">Button :before</button>

<button data-testid="popover-trigger" {...api.getTriggerProps()}>
Sort by
</button>

<Portal>
<div {...api.getPositionerProps()}>
<Presence data-testid="popover-content" className="popover-content" {...api.getContentProps()}>
<fieldset style={{ border: "none", padding: 0 }}>
<label>
<input data-testid="radio-name-asc" type="radio" name="sort" value="name-asc" defaultChecked /> Name
(A to Z)
</label>
<label>
<input data-testid="radio-name-desc" type="radio" name="sort" value="name-desc" /> Name (Z to A)
</label>
<label>
<input data-testid="radio-hours" type="radio" name="sort" value="hours" /> Hours
</label>
</fieldset>
</Presence>
</div>
</Portal>

<button data-testid="button-after">Button :after</button>
</div>
</main>
)
}
74 changes: 74 additions & 0 deletions examples/next-ts/pages/select/in-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as popover from "@zag-js/popover"
import { mergeProps, normalizeProps, Portal, useMachine } from "@zag-js/react"
import * as select from "@zag-js/select"
import { selectData } from "@zag-js/shared"
import { useId } from "react"

function Select() {
const service = useMachine(select.machine, {
collection: select.collection({ items: selectData }),
id: useId(),
name: "country",
})

const api = select.connect(service, normalizeProps)

return (
<div {...api.getRootProps()}>
<label {...api.getLabelProps()}>Label</label>
<div {...api.getControlProps()}>
<button
{...mergeProps(api.getTriggerProps(), {
"aria-controls": !api.open ? null : undefined,
})}
>
<span>{api.valueAsString || "Select option"}</span>
<span>▼</span>
</button>
<button {...api.getClearTriggerProps()}>X</button>
</div>
{api.open && (
<Portal>
<div {...api.getPositionerProps()}>
<ul {...api.getContentProps()}>
{selectData.map((item) => (
<li key={item.value} {...api.getItemProps({ item })}>
<span {...api.getItemTextProps({ item })}>{item.label}</span>
<span {...api.getItemIndicatorProps({ item })}>✓</span>
</li>
))}
</ul>
</div>
</Portal>
)}
</div>
)
}

export default function Page() {
const service = useMachine(popover.machine, {
id: useId(),
portalled: true,
modal: false,
})
const api = popover.connect(service, normalizeProps)

return (
<div style={{ padding: "20px" }}>
<button {...api.getTriggerProps()}>Open Popover</button>
{api.open && (
<Portal>
<div {...api.getPositionerProps()}>
<div {...api.getContentProps()}>
<div {...api.getTitleProps()}>Select in Popover</div>
<div>
<Select />
</div>
<button {...api.getCloseTriggerProps()}>X</button>
</div>
</div>
</Portal>
)}
</div>
)
}
21 changes: 18 additions & 3 deletions packages/core/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ function isExplicitAbsoluteStatePath(value: string) {
return value.startsWith(ABSOLUTE_PREFIX)
}

function isChildTarget(value: string) {
return value.startsWith(STATE_DELIMITER)
}

function stripAbsolutePrefix(value: string) {
return isExplicitAbsoluteStatePath(value) ? value.slice(ABSOLUTE_PREFIX.length) : value
}
Expand Down Expand Up @@ -148,15 +152,26 @@ export function resolveStateValue<T extends MachineSchema>(
return resolveAbsoluteStateValue(machine, statePath)
}

// Relative sibling target (e.g. "editing") is resolved from the state node
// where the transition is declared, not from the current leaf state.
// Dot-prefixed child target (e.g. ".idle" from source "open" → "open.idle")
if (isChildTarget(stateValue) && source) {
const childPath = appendStatePath(source, stateValue.slice(1))
return resolveAbsoluteStateValue(machine, childPath)
}

// Bare name = sibling resolution (aligned with XState/SCXML semantics).
// Siblings are checked from parent scope upward, never children of the source.
if (!isAbsoluteStatePath(stateValue) && source) {
const sourceSegments = toSegments(source)
for (let index = sourceSegments.length; index >= 1; index--) {

// Check siblings (parent scope upward)
for (let index = sourceSegments.length - 1; index >= 1; index--) {
const base = sourceSegments.slice(0, index).join(STATE_DELIMITER)
const candidate = appendStatePath(base, stateValue)
if (hasStatePath(machine, candidate)) return resolveAbsoluteStateValue(machine, candidate)
}

// Check root-level siblings
if (hasStatePath(machine, stateValue)) return resolveAbsoluteStateValue(machine, stateValue)
}

return resolveAbsoluteStateValue(machine, stateValue)
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,22 @@ type ChildStateKey<S extends string, Parent extends string> = S extends `${Paren

type ParentPath<S extends string> = S extends `${infer Parent}.${string}` ? Parent : never
type AncestorPaths<S extends string> = S | (ParentPath<S> extends never ? never : AncestorPaths<ParentPath<S>>)
type RelativeStateTarget<S extends string, Source extends string> = ChildStateKey<S, AncestorPaths<Source>>
type StateIdTarget = `#${string}`

// Bare name targets resolve to siblings (children of the source's parent, or root-level states)
type SiblingStateTarget<S extends string, Source extends string> =
| TopLevelState<S>
| ChildStateKey<S, Exclude<AncestorPaths<Source>, Source>>

// Dot-prefixed targets resolve to children of the source (e.g. ".idle" from "open" → "open.idle")
type ChildStateTarget<S extends string, Source extends string> = `.${ChildStateKey<S, Source>}`

export interface Transition<T extends Dict, Source extends string | undefined = string | undefined> {
target?:
| T["state"]
| StateIdTarget
| (Source extends string ? RelativeStateTarget<T["state"], Source> : never)
| (Source extends string ? SiblingStateTarget<T["state"], Source> : never)
| (Source extends string ? ChildStateTarget<T["state"], Source> : never)
| undefined
actions?: T["action"][] | undefined
guard?: T["guard"] | GuardFn<T> | undefined
Expand Down
Loading
Loading