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:
2026-09-12 05:49:58 +02:00
parent 7f17b17c13
commit f8a31d48cf
10 changed files with 378 additions and 24 deletions
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
//
// The toast handover under React 18 StrictMode — its own file, because `lastTopIds` is module-level
// state that leaks between tests.
//
// Round 5 made a taking-over host inherit "what the PREVIOUS top host last had on screen" by reading
// a module global in a `useMemo` and overwriting it in the same render pass. React re-invokes a
// component body — and every `useMemo` factory in it — on a StrictMode double render, and the LAST
// invocation is what commits: so the second pass re-read what the first had just written, the host
// classified its OWN new toast as inherited, and the dev build behaved exactly like the code round 5
// replaced. Measured in Chrome and in jsdom: role "status" without StrictMode, null with it.
//
// The named case is UploadDialog's — notify and close the overlay in ONE handler, so the toast is
// raised in the very commit that hands the app's host the top spot.
import { describe, it, expect, afterEach, vi } from "vitest";
import { StrictMode, useState } from "react";
import { render, screen, cleanup, fireEvent } 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 }) };
});
import Toaster, { useOverlayOrder } from "./Toaster";
import { clearAll, notify } from "../../lib/notifications";
afterEach(() => {
cleanup();
clearAll();
});
/** An overlay with its own toast host, plus the app's — the shape every dialog produces. */
function Harness() {
const [open, setOpen] = useState(true);
return (
<>
<button
onClick={() => {
notify({ level: "success", message: "Uploaded" });
setOpen(false); // …in the SAME handler: one batch, one commit
}}
>
finish
</button>
{open && <Overlay />}
<Toaster />
</>
);
}
function Overlay() {
const order = useOverlayOrder();
return <Toaster overlay order={order} />;
}
describe("the toast handover", () => {
it("announces a toast raised in the takeover commit, even under StrictMode", () => {
render(
<StrictMode>
<Harness />
</StrictMode>,
);
fireEvent.click(screen.getByText("finish"));
// Not inherited → it carries its own role, because the live region is not armed yet.
expect(screen.getByText("Uploaded").closest("[role]")?.getAttribute("role")).toBe("status");
});
});