fix(ui): a dialog keeps the focus it was given, and hands it back where it took it from
Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus contract, then introduced four ways to lose the focus anyway — all four measured in jsdom against the real components. **Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked over the panel alone. Tabbing to that ✕ therefore read as "the user left the dialog", and since removing a toast fires no `focusin` to re-arm, closing afterwards skipped the restore and left the focus on <body>. Before this hook existed the restore was unconditional, so it was a regression. **A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its own host, so it is outside the outer dialog's root however wide that scope is drawn — widening alone could not fix it. Both closing in one commit left the focus on <body>: the outer skipped its restore, and the child's own target (a control inside the outer panel) was already detached, because passive cleanups run parent-first. Ownership now means "inside my dialog, or inside a modal layer above it", which is what the rule always intended. **The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`, with neither the dialog-root scope (so its own toast host was outside the cycle — the connection notice never auto-dismisses, and its ✕ was unreachable from the keyboard) nor the `container` argument the JSDoc exists for: `panel.contains( panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and a Shift+Tab walked into the app behind the scrim. Both call sites now pass the same three arguments. **The toast handover was a render-phase read-modify-write.** A `useMemo` read a module global that the next line overwrote; React re-invokes a component body and every `useMemo` factory in it on a StrictMode double render, and the LAST invocation commits — so the second pass re-read what the first had just written, the takeover host classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed on `isTop`, so a repeated invocation of one render is a no-op. And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally swept into the same contract: it declared itself a modal dialog while leaving the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the landing page underneath. The headline fix of the previous commit — StrictMode's re-mount taking the focus off an `autoFocus` field — had no test at all; deleting it left all 1009 green. It has one now. 9 tests, mutation-checked against 8 mutations, each red on the test that names it.
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
|
||||
// the card probes /auth/config on mount and must not reach the network.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -134,3 +134,48 @@ describe("the verification outcome handed down from App", () => {
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the screenshot lightbox", () => {
|
||||
// The fourth `aria-modal` surface in the app, and the one the round-5 focus extraction did not
|
||||
// sweep in: it declared itself a modal dialog while leaving focus on the thumbnail behind an
|
||||
// 80%-black scrim and letting Tab walk the landing page underneath (round 6).
|
||||
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(() => {
|
||||
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
|
||||
});
|
||||
|
||||
const openIt = async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
const shot = (await screen.findAllByLabelText("welcome.preview.feed"))[0]!;
|
||||
shot.focus();
|
||||
fireEvent.click(shot);
|
||||
return { shot, panel: await screen.findByRole("dialog") };
|
||||
};
|
||||
|
||||
it("opens focused inside itself and holds the Tab instead of letting it walk the page", async () => {
|
||||
const { panel } = await openIt();
|
||||
expect(panel.getAttribute("aria-modal")).toBe("true");
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
// Assert the trap ACTED (the keydown was cancelled), not where focus ended up: jsdom never
|
||||
// performs the browser's default Tab traversal, so "focus is still inside" is true with no trap
|
||||
// at all — a green assertion about nothing (the mutation check caught exactly that).
|
||||
screen.getByLabelText("common.close").focus();
|
||||
const notCancelled = fireEvent.keyDown(window, { key: "Tab", cancelable: true });
|
||||
expect(notCancelled).toBe(false);
|
||||
});
|
||||
|
||||
it("hands the focus back to the thumbnail that opened it", async () => {
|
||||
const { shot } = await openIt();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(shot));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user