fix(ui): a closing dialog's focus restore reaches the dialog left standing above it, and survives a menu

A dialog closing on its own under a standing modal skipped its restore, and the standing modal's
own target was a control inside the closed one: closing it left the focus on <body>. The skipped
target is now handed to the standing dialog, which falls back to it.

Focus on a row of one of the dialog's own menus (the popover engine portals them out of the dialog
root) read as the user walking away, so Back with a menu open left the focus on <body>. A focus in
a popover portal now counts as held, and one still standing is not taken from.
This commit is contained in:
2026-10-28 19:05:24 +01:00
parent 24190e4c16
commit 7e5d4a7d93
2 changed files with 98 additions and 5 deletions
+33 -4
View File
@@ -75,6 +75,16 @@ export function lockBodyScroll(): () => void {
};
}
// A popover engine surface (`ui/AnchoredPopover`): portalled out of the dialog's root and carrying no
// aria-modal, so a focus on one of a dialog's menu rows is inside the dialog all the same.
const FLOATING = "[data-floating-ui-portal]";
// Restore targets handed to a dialog left standing above one that closed (see the cleanup below):
// that dialog's own target was captured while the focus was in the one that closed, and is gone.
// Keyed by the standing dialog's aria-modal element; a WeakMap, so a dialog that never closes
// through this hook leaves nothing behind.
const handedRestore = new WeakMap<Element, HTMLElement>();
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
@@ -128,6 +138,11 @@ export function useDialogChrome(
// Today that reads the same either way (a detached owner and a null one both fail every
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
const ownerAtOpen = ownerRef?.current ?? scope;
// This dialog's aria-modal element, the key a closing dialog under it hands its target by.
const modalAtOpen =
ownerAtOpen?.closest('[aria-modal="true"]') ??
ownerAtOpen?.querySelector('[aria-modal="true"]') ??
null;
if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current;
const target =
@@ -144,10 +159,12 @@ export function useDialogChrome(
// it — and while it held the focus we read that as the user walking away. Then both closing
// together left the focus on <body>: ours skipped the restore, and the child's own
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
// and it is the ordinary case — an error dialog raised over a dialog.
// and it is the ordinary case — an error dialog raised over a dialog. And one of its own
// menus (see FLOATING): Back with a menu open closed the dialog with the focus on a menu row.
heldRef.current =
(owner?.contains(active) ?? false) ||
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
(active instanceof Element &&
(active.closest('[aria-modal="true"]') != null || active.closest(FLOATING) != null));
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
// gone by then, so restoring onto it would strand the focus exactly as before.
if (scopeRef.current?.contains(document.activeElement))
@@ -172,16 +189,28 @@ export function useDialogChrome(
// 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.
//
// Skipped, the trigger is handed to that standing dialog instead: ITS target was captured
// while the focus was in ours, so it is gone now, and it falls back to the one handed to it.
// A popover still standing (a menu of a dialog that stays open) is not ours to take from
// either, though there is no dialog to hand the trigger to.
const owner = ownerAtOpen;
const active = document.activeElement;
const target = restoreTo?.isConnected
? restoreTo
: modalAtOpen
? handedRestore.get(modalAtOpen)
: undefined;
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();
const standingMenu = active instanceof Element && active.closest(FLOATING)?.isConnected;
if (!heldRef.current || !target?.isConnected) return;
if (standingAbove) handedRestore.set(above, target);
else if (!standingMenu) target.focus();
};
}, [restoreTo, scopeRef, ownerRef, focusScope]);
}