diff --git a/.changeset/focus-trap-single-tab-stop.md b/.changeset/focus-trap-single-tab-stop.md new file mode 100644 index 0000000000..da8d27df8f --- /dev/null +++ b/.changeset/focus-trap-single-tab-stop.md @@ -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. diff --git a/.changeset/interact-outside-safari-focus.md b/.changeset/interact-outside-safari-focus.md new file mode 100644 index 0000000000..aaa158858a --- /dev/null +++ b/.changeset/interact-outside-safari-focus.md @@ -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 diff --git a/.github/composite-actions/install/action.yml b/.github/composite-actions/install/action.yml index c6d2bddb8e..d8fae351cd 100644 --- a/.github/composite-actions/install/action.yml +++ b/.github/composite-actions/install/action.yml @@ -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 diff --git a/e2e/popover.e2e.ts b/e2e/popover.e2e.ts index 18ffb603ea..29fa7cd69b 100644 --- a/e2e/popover.e2e.ts +++ b/e2e/popover.e2e.ts @@ -1,4 +1,4 @@ -import { test } from "@playwright/test" +import { expect, test } from "@playwright/test" import { PopoverModel } from "./models/popover.model" let I: PopoverModel @@ -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() diff --git a/examples/next-ts/pages/color-picker/in-dialog.tsx b/examples/next-ts/pages/color-picker/in-dialog.tsx index a0cf5e25d3..86854d6cba 100644 --- a/examples/next-ts/pages/color-picker/in-dialog.tsx +++ b/examples/next-ts/pages/color-picker/in-dialog.tsx @@ -26,29 +26,31 @@ const ColorPicker = () => { - -
-
-
-
-
-
-
+ {api.open && ( + +
+
+
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
-
- + + )}
) } @@ -109,21 +111,6 @@ export default function Page() {
- -
- -
-

First color picker:

- -
- -
- - - -
-
-
) } diff --git a/examples/next-ts/pages/popover/single-tab-stop.tsx b/examples/next-ts/pages/popover/single-tab-stop.tsx new file mode 100644 index 0000000000..ea4a17afb8 --- /dev/null +++ b/examples/next-ts/pages/popover/single-tab-stop.tsx @@ -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 ( +
+
+ + + + + +
+ +
+ + + +
+
+
+
+ + +
+
+ ) +} diff --git a/examples/next-ts/pages/select/in-popover.tsx b/examples/next-ts/pages/select/in-popover.tsx new file mode 100644 index 0000000000..b92a4979b3 --- /dev/null +++ b/examples/next-ts/pages/select/in-popover.tsx @@ -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 ( +
+ +
+ + +
+ {api.open && ( + +
+
    + {selectData.map((item) => ( +
  • + {item.label} + +
  • + ))} +
+
+
+ )} +
+ ) +} + +export default function Page() { + const service = useMachine(popover.machine, { + id: useId(), + portalled: true, + modal: false, + }) + const api = popover.connect(service, normalizeProps) + + return ( +
+ + {api.open && ( + +
+
+
Select in Popover
+
+