// @vitest-environment jsdom // // Modal: focus management (FE-9). This was DEFERRED from the E4 deep-review because it's // jsdom-untestable as written — `focusablesIn` filters on `el.offsetParent !== null`, and jsdom // returns null for EVERY element (it never lays out), so the trap would see zero focusables and do // nothing. We stub `HTMLElement.prototype.offsetParent` to report "visible" (non-null) for any // connected node, which is exactly what a real browser reports for the shown dialog controls; the // separate no-stub describe below documents the raw-jsdom reality (empty → the guard no-ops). // // The overlay/history machinery (useBackToClose) is exercised in history.dom.test.ts, so it's mocked // out here to isolate the focus-trap behaviour from its module-global history side-effects. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { useState } from "react"; import { render, screen, act, cleanup } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; }); // Isolate the focus trap from the Back-to-close history stack (tested in history.dom.test.ts). vi.mock("../../lib/history", () => ({ useBackToClose: () => {} })); import Modal from "./Modal"; import { clearAll, notify } from "../../lib/notifications"; import { pushLayer, removeLayer, useLayer } from "../../lib/layerStack"; import { flushSync } from "react-dom"; // Fire a keydown on window (where Modal installs its handler), inside act. const press = (key: string, shiftKey = false) => act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }), ); }); const panelEl = () => document.querySelector('[tabindex="-1"]')!; const closeBtn = () => screen.getByRole("button", { name: "common.close" }); // A modal with three extra focusable controls; DOM order inside the panel is [close, A, input, B]. function Body() { return ( <> ); } describe("Modal focus trap (offsetParent stubbed 'visible')", () => { let origDesc: PropertyDescriptor | undefined; beforeEach(() => { origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent"); Object.defineProperty(HTMLElement.prototype, "offsetParent", { configurable: true, get(this: HTMLElement) { return this.isConnected ? document.body : null; }, }); }); afterEach(() => { cleanup(); if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc); else delete (HTMLElement.prototype as unknown as Record).offsetParent; }); it("moves initial focus to the first control on open", () => { render( {}}> , ); expect(document.activeElement).toBe(closeBtn()); }); it("Tab from the last control wraps to the first", () => { render( {}}> , ); screen.getByText("beta").focus(); // last press("Tab"); expect(document.activeElement).toBe(closeBtn()); }); it("Shift+Tab from the first control wraps to the last", () => { render( {}}> , ); closeBtn().focus(); // first press("Tab", true); expect(document.activeElement).toBe(screen.getByText("beta")); }); // A tooltip or hover card open under a resting pointer is a (passive) layer above the dialog. It // takes Escape first, but deferring the Tab trap to it let Shift+Tab walk out behind the scrim. it("keeps trapping while only a hover card is open above it", () => { render( {}}> , ); const hover = pushLayer({ passive: true }); try { screen.getByText("beta").focus(); // last press("Tab"); expect(document.activeElement).toBe(closeBtn()); } finally { removeLayer(hover); } }); it("Tab reaches a toast shown inside the dialog, and wraps from it", () => { // The dialog's toast host sits beside the panel; trapped to the panel, a toast's Undo or dismiss // could not be reached from the keyboard at all. render( {}}> , ); act(() => void notify({ level: "info", message: "Saved" })); try { const dismissToast = screen.getByTitle("notifications.dismiss"); // The panel's last control is no longer the trap's edge: Tab carries on (natively) to the toast. screen.getByText("beta").focus(); press("Tab"); expect(document.activeElement).toBe(screen.getByText("beta")); // not wrapped back to the top // The toast is the edge now: past it, Tab wraps to the first control, and Shift+Tab reaches it. dismissToast.focus(); press("Tab"); expect(document.activeElement).toBe(closeBtn()); press("Tab", true); expect(document.activeElement).toBe(dismissToast); } finally { act(() => clearAll()); } }); it("Tab pulls focus back inside when it has escaped to the page behind", () => { const outside = document.createElement("button"); document.body.appendChild(outside); render( {}}> , ); outside.focus(); // focus escaped the dialog expect(document.activeElement).toBe(outside); press("Tab"); expect(document.activeElement).toBe(closeBtn()); document.body.removeChild(outside); }); it("Shift+Tab from the panel container itself goes to the last control (no leak to the background)", () => { render( {}}> , ); panelEl().focus(); // active === panel (tabIndex=-1, reached by clicking dead space) press("Tab", true); expect(document.activeElement).toBe(screen.getByText("beta")); }); it("does not trap a Tab in the middle — only the edges wrap", () => { render( {}}> , ); const mid = screen.getByLabelText("field"); mid.focus(); press("Tab"); // interior: native Tab handles it, the trap must not force-move expect(document.activeElement).toBe(mid); }); it("Escape calls onClose", () => { const onClose = vi.fn(); render( , ); press("Escape"); expect(onClose).toHaveBeenCalledTimes(1); }); it("Escape closes only the topmost modal", () => { const outerClose = vi.fn(); const innerClose = vi.fn(); render( , ); render( , ); press("Escape"); expect(innerClose).toHaveBeenCalledTimes(1); expect(outerClose).not.toHaveBeenCalled(); }); it("restores focus to the trigger on unmount", () => { const trigger = document.createElement("button"); document.body.appendChild(trigger); trigger.focus(); // captured as restoreTo during Modal's first render const { unmount } = render( {}}> , ); expect(document.activeElement).not.toBe(trigger); // focus moved into the dialog unmount(); expect(document.activeElement).toBe(trigger); document.body.removeChild(trigger); }); it("still restores the trigger after the focus visited the dialog's own toast", () => { // The Tab trap deliberately spans the dialog ROOT so a toast's Undo/✕ is reachable; ownership // was tracked over the PANEL, so touching that ✕ read as "the user left the dialog" and the // restore was skipped — and removing the toast fires no focusin to re-arm it. Measured landing // on (deep-review round 6). const trigger = document.createElement("button"); document.body.appendChild(trigger); trigger.focus(); const { unmount } = render( {}}> , ); act(() => void notify({ level: "error", message: "Nope" })); const dismissToast = screen.getByTitle("notifications.dismiss"); dismissToast.focus(); expect(document.activeElement).toBe(dismissToast); // the scenario is actually being driven act(() => clearAll()); // the toast goes away under the focus — no focusin follows expect(document.activeElement).toBe(document.body); unmount(); expect(document.activeElement).toBe(trigger); document.body.removeChild(trigger); }); it("restores the trigger when a dialog and the dialog stacked on it close together", () => { // A nested Modal portals into its OWN host, so it is outside the outer dialog's root however // wide that scope is drawn — reading that as "the user walked away" left the focus on . // The child cannot cover for it: passive cleanups run parent-first, and by then its own restore // target (a control INSIDE the outer panel, captured when it opened) is already detached. const trigger = document.createElement("button"); document.body.appendChild(trigger); trigger.focus(); function Nested() { const [open, setOpen] = useState(false); return ( {}}> {open && ( {}}> )} ); } const { unmount } = render(); act(() => void screen.getByText("open inner").click()); screen.getByText("inner-body").focus(); unmount(); expect(document.activeElement).toBe(trigger); document.body.removeChild(trigger); }); it("leaves the focus alone when a dialog above it is still standing", () => { // The other side of the clause above. Counting focus in ANY `aria-modal` subtree as ours cannot // tell a layer closing WITH us from one that stays open, and the layer stack cannot either: at // the moment this cleanup runs the child's `removeLayer` has not fired, so both cases read "a // layer is above me" (measured). So a dialog that closes on its own — an in-flight mutation's // `onSuccess`, not a click, since a scrim covers it — under a modal raised above it yanked the // focus out of that standing dialog onto its own trigger, behind the scrim, where Enter still // activated it (deep-review round 7). Sibling dialogs, not nested: that is the arrangement // `ConfirmProvider` and the app-wide `ErrorDialog` actually use. const trigger = document.createElement("button"); document.body.appendChild(trigger); trigger.focus(); function Siblings() { const [lower, setLower] = useState(true); return ( <> {lower && ( {}}> )} {}}> ); } render(); const upper = screen.getByText("upper-body"); upper.focus(); // The lower dialog closes by itself while the upper one holds the focus. act(() => void screen.getByText("close lower").click()); expect(screen.queryByText("close lower")).toBeNull(); // it really did unmount expect(document.activeElement).toBe(upper); document.body.removeChild(trigger); }); it("leaves the focus alone when the user moved it out of the dialog themselves", () => { // The other half of the same rule, and the branch nothing drove: taking focus back from where // the user deliberately put it is the rudest thing a closing dialog can do. const trigger = document.createElement("button"); const elsewhere = document.createElement("button"); document.body.append(trigger, elsewhere); trigger.focus(); const { unmount } = render( {}}> , ); elsewhere.focus(); unmount(); expect(document.activeElement).toBe(elsewhere); document.body.removeChild(trigger); document.body.removeChild(elsewhere); }); }); describe("Modal focus trap (raw jsdom — every offsetParent null)", () => { afterEach(cleanup); it("with no visible focusables: initial focus falls to the panel and Tab is a safe no-op", () => { render( {}}> , ); // focusablesIn() returns [] in raw jsdom, so the initializer focuses the panel itself. expect(document.activeElement).toBe(panelEl()); expect(() => press("Tab")).not.toThrow(); expect(document.activeElement).toBe(panelEl()); }); }); describe("Modal — toasts raised while it is open", () => { afterEach(() => { cleanup(); act(() => clearAll()); }); it("shows them inside the dialog, above its scrim", () => { // The app's Toaster is in a stacking context under this body-level portal: a failed share or // rename was drawn under the scrim, and clicking it landed on the backdrop and closed the dialog. render( {}}> , ); act(() => void notify({ level: "error", message: "Couldn't share it." })); expect(screen.getByRole("dialog").textContent).toContain("Couldn't share it."); }); }); // E28: one Escape in a dialog's open list closed the list AND the dialog (the share dialog's user // picker). The list's `document` listener closes it with a synchronous flush, which pops its layer // before the dialog's `window` listener runs — so "am I topmost?" reads yes. The dialog must honour // the list's stamp, as the players already do (UX-8). describe("Modal — an Escape a layer above it already took", () => { // Opened by a click after the dialog is up, as a user opens it: a child's effect runs before its // parent's, so a list open from the first render would sit BELOW the dialog on the stack. function List({ onClosed }: { onClosed: () => void }) { const [open, setOpen] = useState(false); useLayer(open, () => { flushSync(() => setOpen(false)); onClosed(); }); return open ? (
) : ( ); } it("closes the list only; the next Escape closes the dialog", () => { const onClose = vi.fn(); const listClosed = vi.fn(); render( , ); act(() => screen.getByRole("button", { name: "open list" }).click()); act(() => { document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); }); expect(listClosed).toHaveBeenCalledTimes(1); expect(screen.queryByTestId("list")).toBeNull(); expect(onClose).not.toHaveBeenCalled(); press("Escape"); expect(onClose).toHaveBeenCalledTimes(1); }); });