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:
@@ -254,6 +254,43 @@ describe("Modal focus trap (offsetParent stubbed 'visible')", () => {
|
||||
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 title="lower" onClose={() => {}}>
|
||||
<button onClick={() => setLower(false)}>close lower</button>
|
||||
</Modal>
|
||||
)}
|
||||
<Modal 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.
|
||||
|
||||
@@ -118,7 +118,27 @@ export function useDialogChrome(
|
||||
// Restore focus to the trigger, if it's still in the document and focusable — and only if the
|
||||
// dialog still held the focus. Once the user has moved it elsewhere, taking it back is the
|
||||
// rudest thing a closing dialog can do.
|
||||
if (heldRef.current && restoreTo && restoreTo.isConnected) restoreTo.focus();
|
||||
//
|
||||
// Not, though, when a dialog stacked above us is STILL STANDING. `heldRef` counts focus in any
|
||||
// `aria-modal` subtree as ours (see above), which cannot tell a layer closing WITH us from one
|
||||
// that stays open — and the layer stack cannot tell them apart either: at this instant the
|
||||
// child's `removeLayer` has not run, because passive cleanups are parent-first and this hook
|
||||
// is declared before that effect, so BOTH cases read "a layer is above me" (measured). What
|
||||
// does separate 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. Without
|
||||
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
||||
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
||||
// scrim — where Enter still activated it (deep-review round 7).
|
||||
const owner = ownerRef?.current ?? scopeRef.current;
|
||||
const active = document.activeElement;
|
||||
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
||||
const standingAbove =
|
||||
above !== null &&
|
||||
above.isConnected &&
|
||||
!above.contains(owner as Node | null) &&
|
||||
!(owner?.contains(above) ?? false);
|
||||
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
||||
restoreTo.focus();
|
||||
};
|
||||
}, [restoreTo, scopeRef, ownerRef]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user