// @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 { 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"; // 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")); }); 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); }); }); 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()); }); });