From fa8bdf83a887933af40b42f16b0c37d8a6f67def Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 05:12:33 +0200 Subject: [PATCH] test(e4): Modal focus-trap coverage (FE-9 deferred gap) Backfills the E4 deep-review's deferred Modal focus-management test. jsdom returns null for every offsetParent (no layout), so focusablesIn() would see zero controls; the test stubs HTMLElement.prototype.offsetParent to report "visible" for connected nodes (what a real browser reports for the shown dialog), and a second describe documents the raw-jsdom empty-guard path. Covers: initial focus on open, Tab/Shift+Tab edge-wrap, pull-back on escaped focus, the active===panel leak guard, no-trap in the interior, Escape closes the topmost only, and restore-to-trigger on unmount. useBackToClose is mocked (its history stack is covered by history.dom.test.ts). 10 tests; full FE gate green (tsc/eslint/prettier/knip, 257 vitest). --- frontend/src/components/Modal.dom.test.tsx | 191 +++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 frontend/src/components/Modal.dom.test.tsx diff --git a/frontend/src/components/Modal.dom.test.tsx b/frontend/src/components/Modal.dom.test.tsx new file mode 100644 index 00000000..d622314c --- /dev/null +++ b/frontend/src/components/Modal.dom.test.tsx @@ -0,0 +1,191 @@ +// @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()); + }); +});