diff --git a/.changeset/dirty-turtles-attack.md b/.changeset/dirty-turtles-attack.md new file mode 100644 index 0000000000..d89e6c4968 --- /dev/null +++ b/.changeset/dirty-turtles-attack.md @@ -0,0 +1,5 @@ +--- +"@zag-js/presence": patch +--- + +Fix a bug where elements get stuck in unmountSuspended state during rapid hovering diff --git a/e2e/models/navigation-menu.model.ts b/e2e/models/navigation-menu.model.ts index 195e874476..91e3bd023a 100644 --- a/e2e/models/navigation-menu.model.ts +++ b/e2e/models/navigation-menu.model.ts @@ -7,7 +7,7 @@ export class NavigationMenuModel extends Model { super(page) } - goto(id?: "viewport" | "nested") { + goto(id?: "viewport") { return this.page.goto(`/navigation-menu${id ? `-${id}` : ""}`) } @@ -66,4 +66,8 @@ export class NavigationMenuModel extends Model { async seeLinkIsFocused(value: string) { await expect(this.getLink(value)).toBeFocused() } + + async wait(ms: number) { + await this.page.waitForTimeout(ms) + } } diff --git a/e2e/navigation-menu.e2e.ts b/e2e/navigation-menu.e2e.ts index 50f0f0144a..360d7265fe 100644 --- a/e2e/navigation-menu.e2e.ts +++ b/e2e/navigation-menu.e2e.ts @@ -3,7 +3,7 @@ import { NavigationMenuModel } from "./models/navigation-menu.model" let I: NavigationMenuModel -test.describe.skip("navigation-menu", () => { +test.describe("navigation-menu", () => { test.beforeEach(async ({ page }) => { I = new NavigationMenuModel(page) await I.goto() @@ -69,6 +69,57 @@ test.describe.skip("navigation-menu", () => { await I.dontSeeContent("products") }) + test("hover, click to close, hover out and back in", async () => { + // hover to open + await I.hoverTrigger("products") + await I.seeContent("products") + + // click to close + await I.clickTrigger("products") + await I.dontSeeContent("products") + + // keep hovering (should not re-open) + await I.hoverTrigger("products") + await I.dontSeeContent("products") + + // hover out + await I.hoverTrigger("company") + await I.dontSeeContent("products") + + // hover back in (should open now) + await I.hoverTrigger("products") + await I.seeContent("products") + }) + + test("keyboard open, mouse hover, escape close, then continue", async () => { + // open with keyboard + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.seeContent("products") + + // continue interaction with mouse by hovering + await I.hoverTrigger("company") + await I.seeContent("company") + await I.dontSeeContent("products") + + // close with escape key + await I.pressKey("Escape") + await I.dontSeeContent("company") + + // continue interaction with mouse + await I.hoverTrigger("products") + await I.seeContent("products") + + // close with escape again + await I.pressKey("Escape") + await I.dontSeeContent("products") + + // continue interaction with keyboard + await I.focusTrigger("company") + await I.pressKey("Enter") + await I.seeContent("company") + }) + test("focus link on tab", async () => { await I.focusTrigger("products") await I.pressKey("Enter") @@ -86,7 +137,7 @@ test.describe.skip("navigation-menu", () => { await I.seeContent("company") await I.dontSeeContent("products") - await I.pressKey("Tab", 5) + await I.pressKey("Tab", 6) await I.seeLinkIsFocused("pricing") // focus outside @@ -94,4 +145,311 @@ test.describe.skip("navigation-menu", () => { await I.dontSeeContent("company") await I.dontSeeContent("products") }) + + test("focus restoration after escape", async () => { + // open with keyboard and tab into content + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.seeContent("products") + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // close with escape - focus should return to trigger + await I.pressKey("Escape") + await I.dontSeeContent("products") + await I.seeTriggerIsFocused("products") + }) + + test("shift+tab navigation (backwards)", async () => { + // navigate forward to pricing link + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab", 7) + await I.seeTriggerIsFocused("company") + + // navigate backwards with Shift+Tab + await I.pressKey("Shift+Tab", 6) + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // continue backwards + await I.pressKey("Shift+Tab") + await I.seeTriggerIsFocused("products") + }) + + test("arrow key navigation between triggers", async () => { + await I.focusTrigger("products") + await I.seeTriggerIsFocused("products") + + // arrow right to next trigger + await I.pressKey("ArrowRight") + await I.seeTriggerIsFocused("company") + + // arrow right to next trigger + await I.pressKey("ArrowRight", 2) + await I.seeLinkIsFocused("pricing") + + // arrow left to previous trigger + await I.pressKey("ArrowLeft") + await I.seeTriggerIsFocused("developers") + + // arrow left to previous trigger + await I.pressKey("ArrowLeft") + await I.seeTriggerIsFocused("company") + }) + + test("Home and End key navigation on triggers", async () => { + // start at company trigger (middle) + await I.focusTrigger("company") + await I.seeTriggerIsFocused("company") + + // press Home to jump to first trigger + await I.pressKey("Home") + await I.seeTriggerIsFocused("products") + + // press End to jump to last element + await I.pressKey("End") + await I.seeLinkIsFocused("pricing") + + // press Home again to return to first + await I.pressKey("Home") + await I.seeTriggerIsFocused("products") + }) + + test("arrow down navigation within content", async () => { + // open content and tab to first link + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // navigate down to next link + await I.pressKey("ArrowDown") + await I.seeContentLinkIsFocused("products", "Customer Engagement") + + // navigate down to next link + await I.pressKey("ArrowDown") + await I.seeContentLinkIsFocused("products", "Marketing Automation") + }) + + test("arrow up navigation within content", async () => { + // open content and tab to first link, then move down + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.pressKey("ArrowDown", 2) + await I.seeContentLinkIsFocused("products", "Marketing Automation") + + // navigate up to previous link + await I.pressKey("ArrowUp") + await I.seeContentLinkIsFocused("products", "Customer Engagement") + + // navigate up to previous link + await I.pressKey("ArrowUp") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + }) + + test("Home and End key navigation in content", async () => { + // open content and tab to first link, then move to middle + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.pressKey("ArrowDown", 2) + await I.seeContentLinkIsFocused("products", "Marketing Automation") + + // press Home to jump to first link + await I.pressKey("Home") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // press End to jump to last link + await I.pressKey("End") + await I.seeContentLinkIsFocused("products", "API Documentation") + + // press Home again to return to first + await I.pressKey("Home") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + }) + + test("arrow navigation does not loop in content", async () => { + // open content and tab to first link + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // try to navigate up from first link - should stay on first + await I.pressKey("ArrowUp") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // navigate to last link + await I.pressKey("ArrowDown", 5) + await I.seeContentLinkIsFocused("products", "API Documentation") + + // try to navigate down from last link - should stay on last + await I.pressKey("ArrowDown") + await I.seeContentLinkIsFocused("products", "API Documentation") + }) + + test("opening with enter vs space key", async () => { + // open with Enter + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.seeContent("products") + await I.pressKey("Escape") + + // open with Space + await I.focusTrigger("products") + await I.pressKey(" ") + await I.seeContent("products") + }) + + test("switching menus is instant when one is open", async () => { + // open first menu with hover + await I.hoverTrigger("products") + await I.wait(250) + await I.seeContent("products") + + // hover to second trigger - should switch instantly (no delay) + await I.hoverTrigger("company") + + // should immediately see new content (no need to wait for open delay) + await I.seeContent("company") + await I.dontSeeContent("products") + }) + + test("focus moves from content to trigger does not close", async () => { + // open content and tab to link + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // shift+tab back to trigger + await I.pressKey("Shift+Tab") + await I.seeTriggerIsFocused("products") + + // content should still be visible + await I.seeContent("products") + }) + + test("focus moves from trigger to content does not close", async () => { + // open content with keyboard + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.seeContent("products") + + // tab to content + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // content should still be visible + await I.seeContent("products") + }) + + test("escape from different positions in content restores focus", async () => { + // open and navigate to third link + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.pressKey("Tab") + await I.pressKey("ArrowDown", 2) + await I.seeContentLinkIsFocused("products", "Marketing Automation") + + // escape should close and focus trigger + await I.pressKey("Escape") + await I.dontSeeContent("products") + await I.seeTriggerIsFocused("products") + }) + + test("opening multiple menus maintains proper tab order", async () => { + // open and close products + await I.focusTrigger("products") + await I.pressKey("Enter") + await I.seeContent("products") + await I.pressKey("Escape") + + // open company + await I.focusTrigger("company") + await I.pressKey("Enter") + await I.seeContent("company") + + // tab through content + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("company", "About Us") + + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("company", "Leadership Team") + }) + + test("hover open and keyboard close restores proper state", async () => { + // open with hover + await I.hoverTrigger("products") + await I.wait(250) + await I.seeContent("products") + + // tab into content + await I.focusTrigger("products") + await I.pressKey("Tab") + await I.seeContentLinkIsFocused("products", "Analytics Platform") + + // close with escape + await I.pressKey("Escape") + await I.dontSeeContent("products") + await I.seeTriggerIsFocused("products") + + // should be able to open again + await I.pressKey("Enter") + await I.seeContent("products") + }) + + test("hover to open, click same trigger twice rapidly", async () => { + // hover to open + await I.hoverTrigger("products") + await I.wait(250) + await I.seeContent("products") + + // click once to close + await I.clickTrigger("products") + await I.dontSeeContent("products") + + // immediately click again - should not reopen + await I.clickTrigger("products") + await I.seeContent("products") + }) + + test("escape during close delay cancels the close", async () => { + // open with click + await I.clickTrigger("products") + await I.seeContent("products") + + // hover away to start close delay + await I.clickOutside() + + // press escape before close delay completes + await I.wait(100) + await I.pressKey("Escape") + + // should close immediately + await I.dontSeeContent("products") + }) + + test("multiple menus rapid switching", async () => { + // open first + await I.hoverTrigger("products") + await I.wait(250) + await I.seeContent("products") + + // quickly switch multiple times + await I.hoverTrigger("company") + await I.seeContent("company") + + await I.hoverTrigger("developers") + await I.seeContent("developers") + + await I.hoverTrigger("products") + await I.seeContent("products") + + // verify final state is correct + await I.seeContent("products") + await I.dontSeeContent("company") + await I.dontSeeContent("developers") + }) }) diff --git a/examples/next-ts/pages/navigation-menu-nested.tsx b/examples/next-ts/pages/navigation-menu-nested.tsx deleted file mode 100644 index 45f34bc48c..0000000000 --- a/examples/next-ts/pages/navigation-menu-nested.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import * as navigationMenu from "@zag-js/navigation-menu" -import { normalizeProps, useMachine } from "@zag-js/react" -import { ChevronDown } from "lucide-react" -import { useId } from "react" -import { Presence } from "../components/presence" -import { StateVisualizer } from "../components/state-visualizer" -import { Toolbar } from "../components/toolbar" -import { useEffectOnce } from "../hooks/use-effect-once" - -export default function Page() { - const rootService = useMachine(navigationMenu.machine, { id: useId() }) - const rootMenu = navigationMenu.connect(rootService, normalizeProps) - - const productService = useMachine(navigationMenu.machine, { id: useId(), defaultValue: "extensibility" }) - const productSubmenu = navigationMenu.connect(productService, normalizeProps) - - const companyService = useMachine(navigationMenu.machine, { id: useId(), defaultValue: "customers" }) - const companySubmenu = navigationMenu.connect(companyService, normalizeProps) - - const renderLinks = (menu: typeof rootMenu, opts: { value: string; items: string[] }) => { - const { value, items } = opts - return items.map((item, index) => ( - - {item} - - )) - } - - useEffectOnce(() => { - productSubmenu.setParent(rootService) - rootMenu.setChild(productService) - }) - - useEffectOnce(() => { - companySubmenu.setParent(rootService) - rootMenu.setChild(companyService) - }) - - return ( - <> -
- -
-
-
- -
- -
- -
- -
- -
- -
- - Pricing - -
-
- - - -
-
-
-
- -
- -
- -
- -
- -
-
-
-
- - - - {renderLinks(productSubmenu, { - value: "extensibility", - items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"], - })} - {renderLinks(productSubmenu, { - value: "extensibility", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - {renderLinks(productSubmenu, { - value: "extensibility", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - - - - {renderLinks(productSubmenu, { - value: "security", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque", "Vestibulum"], - })} - {renderLinks(productSubmenu, { - value: "security", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - {renderLinks(productSubmenu, { - value: "security", - items: ["Fusce pellentesque", "Aliquam porttitor"], - })} - - - - {renderLinks(productSubmenu, { - value: "authentication", - items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"], - })} - {renderLinks(productSubmenu, { - value: "authentication", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - {renderLinks(productSubmenu, { - value: "authentication", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - - -
- - - -
-
-
-
- -
- -
- -
- -
- -
-
-
-
- - - - {renderLinks(companySubmenu, { - value: "customers", - items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"], - })} - {renderLinks(companySubmenu, { - value: "customers", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - - - - {renderLinks(companySubmenu, { - value: "partners", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque", "Vestibulum"], - })} - {renderLinks(companySubmenu, { - value: "partners", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - - - - {renderLinks(companySubmenu, { - value: "enterprise", - items: ["Donec quis dui", "Vestibulum", "Nunc dignissim"], - })} - {renderLinks(companySubmenu, { - value: "enterprise", - items: ["Fusce pellentesque", "Aliquam porttitor", "Pellentesque"], - })} - - -
- - - - {renderLinks(rootMenu, { - value: "developers", - items: ["Donec quis dui", "Vestibulum", "Fusce pellentesque", "Aliquam porttitor"], - })} - {renderLinks(rootMenu, { - value: "developers", - items: ["Fusce pellentesque", "Aliquam porttitor"], - })} - - -
- -
- - - - - - - - ) -} - -const Navbar = ({ children }: { children: React.ReactNode }) => { - return ( -
- - {children} - -
- ) -} diff --git a/examples/next-ts/pages/navigation-menu-viewport.tsx b/examples/next-ts/pages/navigation-menu-viewport.tsx index 237345e85f..082bb373c8 100644 --- a/examples/next-ts/pages/navigation-menu-viewport.tsx +++ b/examples/next-ts/pages/navigation-menu-viewport.tsx @@ -30,7 +30,20 @@ export default function Page() { return ( <>
- +
+
@@ -39,6 +52,8 @@ export default function Page() { Products + +
@@ -46,6 +61,8 @@ export default function Page() { Company + +
@@ -53,6 +70,8 @@ export default function Page() { Developers + +
@@ -138,7 +157,17 @@ export default function Page() {
- + +
+ +
+

Heading

+

Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quos.

+
+ + Learn More +
+
@@ -147,25 +176,3 @@ export default function Page() { ) } - -const Navbar = ({ children }: { children: React.ReactNode }) => { - return ( -
- - {children} - -
- ) -} diff --git a/examples/nuxt-ts/app/pages/navigation-menu-viewport.vue b/examples/nuxt-ts/app/pages/navigation-menu-viewport.vue new file mode 100644 index 0000000000..19b44c5215 --- /dev/null +++ b/examples/nuxt-ts/app/pages/navigation-menu-viewport.vue @@ -0,0 +1,196 @@ + + + diff --git a/examples/nuxt-ts/app/pages/navigation-menu.vue b/examples/nuxt-ts/app/pages/navigation-menu.vue new file mode 100644 index 0000000000..fa40d3845e --- /dev/null +++ b/examples/nuxt-ts/app/pages/navigation-menu.vue @@ -0,0 +1,115 @@ + + + \ No newline at end of file diff --git a/examples/nuxt-ts/app/pages/popover.vue b/examples/nuxt-ts/app/pages/popover.vue index 235a90fe94..fdacdff4f1 100644 --- a/examples/nuxt-ts/app/pages/popover.vue +++ b/examples/nuxt-ts/app/pages/popover.vue @@ -25,7 +25,7 @@ const api = computed(() => popover.connect(service, normalizeProps))
-
+
@@ -36,7 +36,7 @@ const api = computed(() => popover.connect(service, normalizeProps))
-
+
I am just text diff --git a/examples/preact-ts/src/components/presence.tsx b/examples/preact-ts/src/components/presence.tsx new file mode 100644 index 0000000000..1283def478 --- /dev/null +++ b/examples/preact-ts/src/components/presence.tsx @@ -0,0 +1,41 @@ +import * as presence from "@zag-js/presence" +import { normalizeProps, useMachine } from "@zag-js/preact" +import { forwardRef, Ref } from "preact/compat" +import { useEffect, useRef } from "preact/hooks" +import { JSX } from "preact" + +interface PresenceProps extends JSX.HTMLAttributes {} + +export const Presence = forwardRef(function Presence(props, ref) { + const { hidden, ...rest } = props + + const present = !hidden + const service = useMachine(presence.machine, { present }) + const api = presence.connect(service, normalizeProps) + + const internalRef = useRef(null) + const mergedRef: Ref = (node) => { + internalRef.current = node + if (typeof ref === "function") { + ref(node) + } else if (ref) { + ref.current = node + } + } + + useEffect(() => { + if (internalRef.current) { + api.setNode(internalRef.current) + } + }, []) + + return ( +