// @vitest-environment jsdom // // Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired // through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It // mounts the two production listener shapes as React components: // - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real // `document` keydown listener that stamps the event and closes on topmost; // - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on // `escapeHandled(e)` first, then on `anyAbove()`. // The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in // `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY // between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read // "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop // the player from also closing. If the stamp regressed, test 1 would see the player close on the first // Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed. import { describe, it, expect, vi, afterEach } from "vitest"; import { useEffect, useState } from "react"; import { flushSync } from "react-dom"; import { render, act, cleanup } from "@testing-library/react"; import { useLayer, escapeHandled } from "./layerStack"; afterEach(cleanup); // The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that // mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove). function Player({ onClose }: { onClose: () => void }) { const { anyAbove } = useLayer(true); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (escapeHandled(e)) return; // the UX-8 fix if (anyAbove()) return; // the old, race-prone guard onClose(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [anyAbove, onClose]); return
; } // The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run // synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive. function Popover({ onClosed }: { onClosed: () => void }) { const [open, setOpen] = useState(true); useLayer(open, () => { flushSync(() => setOpen(false)); onClosed(); }); return open ?
: null; } function dispatchEscape(): KeyboardEvent { const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true }); act(() => { document.dispatchEvent(e); }); return e; } describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => { it("one Escape closes only the topmost popover; a second closes the player", () => { const playerClose = vi.fn(); const popoverClosed = vi.fn(); const { queryByTestId } = render( <> , ); // Player registers first, popover second → popover is topmost. expect(queryByTestId("popover")).not.toBeNull(); // First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player // defers on the stamp even though the stack now shows nothing above it. const first = dispatchEscape(); expect(escapeHandled(first)).toBe(true); expect(popoverClosed).toHaveBeenCalledOnce(); expect(queryByTestId("popover")).toBeNull(); expect(playerClose).not.toHaveBeenCalled(); // Second Escape: nothing above the player, unstamped → the player closes. const second = dispatchEscape(); expect(escapeHandled(second)).toBe(false); expect(playerClose).toHaveBeenCalledOnce(); }); it("with no layer above, a lone player closes on the first Escape", () => { const playerClose = vi.fn(); render(); dispatchEscape(); expect(playerClose).toHaveBeenCalledOnce(); }); });