fix(ui): a closing dialog leaves the focus to the dialog standing above it
Round 6 widened dialog focus ownership to count focus inside ANY `aria-modal` subtree as ours, so a dialog and the dialog stacked on it closing together would still hand the focus back. That clause has no way to tell a layer closing WITH us from one that stays open, so a dialog closing on its own — an in-flight mutation's `onSuccess`, not a click, since a scrim covers it — under a modal raised above it pulled the focus onto its own pre-open trigger, behind that scrim, where Enter still activated it. Three verifiers confirmed the mechanism and two corrected the severity down: it self-heals on the next Tab, which is why this is a containment violation rather than a broken end state. `lib/layerStack` cannot refine it. Measured against the real hook: at the instant the lower dialog's focus cleanup runs, the both-closing case and the sibling-stays-open case give identical readings — `anyLayerAbove` true, the upper layer topmost — because passive cleanups run parent-first and `useDialogChrome` is declared before the effect that calls `removeLayer`. Gating on the stack turns round 6's own test red. What separates them is the DOM: React detaches a deleted subtree before passive cleanups, so a layer closing with us is already disconnected while one that stays open is not. Both tests are green, and each was mutation-checked against the other. Verified in Chrome on localdev: opening the cheat sheet from a sidebar button and closing it restores that button; with the command palette stacked over the cheat sheet, closing the top one leaves the focus neither on the background trigger nor on <body>, and closing both returns it to the trigger. Welcome's Lightbox got the trap and the restore in round 6 but not the layer membership the other shells have, so its Escape and Tab fired from a raw window listener whatever was on top — the shape the wizard's comment records being bitten by, unreachable today only by a mount ordering nothing enforces. It joins the stack. Its comment also read as though the four-shell sweep were finished: `PlayerModal` is still an `aria-modal` surface with no name, no trap and no restore, which is on ROADMAP.md now rather than implied to be done.
This commit is contained in:
@@ -28,6 +28,7 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import Welcome from "./Welcome";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
@@ -178,4 +179,28 @@ describe("the screenshot lightbox", () => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(shot));
|
||||
});
|
||||
|
||||
it("defers its Escape and its Tab to a layer opened above it", async () => {
|
||||
// Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other
|
||||
// three have, so both fired from a raw window listener no matter what was on top. That is the
|
||||
// shape the wizard's own comment records being bitten by ("one Esc closed the error AND ended
|
||||
// onboarding"), and it was unreachable here only because `App` renders `<Welcome/>` before it
|
||||
// mounts `ErrorDialog` and the Toaster — an ordering nothing enforces (round 7).
|
||||
const { panel } = await openIt();
|
||||
const above = pushLayer(); // whatever the landing page grows next
|
||||
try {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
// Still open: the layer above owns that Escape.
|
||||
expect(screen.queryByRole("dialog")).toBe(panel);
|
||||
// And it must not fight the layer above for the Tab either — same reasoning as the test
|
||||
// above, so assert the keydown was NOT cancelled rather than where the focus went.
|
||||
screen.getByLabelText("common.close").focus();
|
||||
expect(fireEvent.keyDown(window, { key: "Tab", cancelable: true })).toBe(true);
|
||||
} finally {
|
||||
removeLayer(above);
|
||||
}
|
||||
// With the layer above gone it is topmost again and closes normally.
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user