Files
siftlode/frontend/src/components/ui/Modal.dom.test.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

408 lines
16 KiB
TypeScript

// @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<typeof import("react-i18next")>();
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<HTMLElement>('[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 (
<>
<button>alpha</button>
<input aria-label="field" />
<button>beta</button>
</>
);
}
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<string, unknown>).offsetParent;
});
it("moves initial focus to the first control on open", () => {
render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
expect(document.activeElement).toBe(closeBtn());
});
it("Tab from the last control wraps to the first", () => {
render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
screen.getByText("beta").focus(); // last
press("Tab");
expect(document.activeElement).toBe(closeBtn());
});
it("Shift+Tab from the first control wraps to the last", () => {
render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={onClose}>
<Body />
</Modal>,
);
press("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("Escape closes only the topmost modal", () => {
const outerClose = vi.fn();
const innerClose = vi.fn();
render(
<Modal initialFocus="close" title="outer" onClose={outerClose}>
<button>outer-body</button>
</Modal>,
);
render(
<Modal initialFocus="close" title="inner" onClose={innerClose}>
<button>inner-body</button>
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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 <body> (deep-review round 6).
const trigger = document.createElement("button");
document.body.appendChild(trigger);
trigger.focus();
const { unmount } = render(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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 <body>.
// 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 (
<Modal initialFocus="close" title="outer" onClose={() => {}}>
<button onClick={() => setOpen(true)}>open inner</button>
{open && (
<Modal initialFocus="close" title="inner" onClose={() => {}}>
<button>inner-body</button>
</Modal>
)}
</Modal>
);
}
const { unmount } = render(<Nested />);
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 && (
<Modal initialFocus="close" title="lower" onClose={() => {}}>
<button onClick={() => setLower(false)}>close lower</button>
</Modal>
)}
<Modal initialFocus="close" title="upper" onClose={() => {}}>
<button>upper-body</button>
</Modal>
</>
);
}
render(<Siblings />);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
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(
<Modal initialFocus="close" title="t" onClose={() => {}}>
<Body />
</Modal>,
);
// 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(
<Modal initialFocus="close" title="Share" onClose={() => {}}>
<Body />
</Modal>,
);
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 ? (
<div data-testid="list" />
) : (
<button onClick={() => setOpen(true)}>open list</button>
);
}
it("closes the list only; the next Escape closes the dialog", () => {
const onClose = vi.fn();
const listClosed = vi.fn();
render(
<Modal initialFocus="close" title="Share" onClose={onClose}>
<List onClosed={listClosed} />
</Modal>,
);
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);
});
});