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:
@@ -10,6 +10,7 @@
|
||||
// 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) => {
|
||||
@@ -199,6 +200,78 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
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 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 title="outer" onClose={() => {}}>
|
||||
<button onClick={() => setOpen(true)}>open inner</button>
|
||||
{open && (
|
||||
<Modal 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 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 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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user