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.
408 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|