fix(ui): a dialog opens focused where it said it would, and the wizard is a dialog

Deep-review round 5, over round 4's own fixes.

- Round 4 gave `ui/Modal` a persistent portal host so a fullscreen transition would stop tearing
  the dialog down. The host was attached in a `useLayoutEffect` — one commit phase AFTER React
  runs `autoFocus` in `commitMount`, and `focus()` on a disconnected node is a no-op. Every
  autoFocus in every dialog was dead: Mod+K opened the command palette with the focus on the ✕,
  so typing searched nothing and Enter closed it; the same for the download rename field and
  every non-danger confirm. The host is attached in a `useInsertionEffect` now, a whole phase
  earlier, and the whole re-parent — including the focus save/restore across the move, which
  `appendChild` needs because it is remove-then-insert — lives in one `useFullscreenPortalHost`.
  `ui/AnchoredPopover` had a second copy of that re-parent WITHOUT the focus restore, and a
  comment claiming focus survived it; both now come from the hook.

- `ui/Toaster` classified a toast raised in the SAME commit as a host takeover among the ones it
  inherits, because it read the store live at the takeover render. A dialog that notifies and
  closes in one handler (UploadDialog) therefore put its toast up with no role, into a region
  still `aria-live="off"` — announced by neither path. "Inherited" is now what the outgoing host
  last had on screen, and a toast that is NOT inherited is announced even inside the unarmed
  window.

- The onboarding wizard renders its own shell, and Escape ordering was all of the dialog contract
  it had: no role, no label, no initial focus, no trap, no restore. A keyboard user tabbed out of
  the first-login wizard into the app behind the scrim. The focus half of the contract is now one
  shared `ui/dialogFocus` that both `ui/Modal` and the wizard use.

Every fix has a regression test, each mutation-checked (6 mutations, all red on the assertion
they are named after).
This commit is contained in:
2026-09-12 03:41:37 +02:00
parent 545996f807
commit 41b652f8fd
9 changed files with 366 additions and 99 deletions
+12 -67
View File
@@ -1,21 +1,14 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { useBackToClose } from "../../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
import { useFullscreenElement } from "../../lib/fullscreen";
import { useFullscreenPortalHost } from "../../lib/fullscreen";
import { trapTab, useDialogChrome } from "./dialogFocus";
import { Button } from "./Button";
import Toaster, { useOverlayOrder } from "./Toaster";
// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
// collapsed disclosure) so the trap never lands focus on something the user can't see.
const FOCUSABLE =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusablesIn = (root: HTMLElement): HTMLElement[] =>
[...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((el) => el.offsetParent !== null);
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
@@ -44,31 +37,13 @@ export default function Modal({
const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
// ONE container element for the dialog's whole life, MOVED between <body> and the fullscreen
// element rather than handed to `createPortal` as a changing container: React tears a portal down
// and builds it again when its container changes, and the dialog's contents went with it — a
// half-filled download form reset to its defaults, and focus fell to <body>, the moment the viewer
// left true fullscreen (deep-review round 4).
const [host] = useState(() => document.createElement("div"));
useLayoutEffect(() => {
const parent = fsRoot ?? document.body;
if (host.parentNode === parent) return;
// appendChild MOVES the node: the browser blurs whatever was focused inside it on the way out.
const focused = host.contains(document.activeElement)
? (document.activeElement as HTMLElement)
: null;
parent.appendChild(host);
focused?.focus();
}, [fsRoot, host]);
useEffect(() => () => host.remove(), [host]);
// ONE container element for the dialog's whole life, re-parented between <body> and the fullscreen
// element (deep-review round 4) — and attached before the children commit, so an `autoFocus` child
// still gets the focus (round 5). Both rules live in the hook; `ui/AnchoredPopover` shares it.
const host = useFullscreenPortalHost();
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
// Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
// dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
// it later (in the effect) would capture a now-inside element that unmounts on close, stranding
// focus on <body>. useState initializer runs exactly once, on first render.
const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
useDialogChrome(panelRef);
const overlayOrder = useOverlayOrder();
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
@@ -96,50 +71,20 @@ export default function Modal({
if (!panel) return;
// The whole dialog, not just the panel: its toast host sits beside the panel (see ui/Toaster),
// and a toast's Undo or dismiss must be reachable from the keyboard like everything else.
const scope = rootRef.current ?? panel;
const items = focusablesIn(scope);
const first = items[0];
const last = items[items.length - 1];
if (!first || !last) {
e.preventDefault();
return;
}
const active = document.activeElement;
if (!scope.contains(active) || active === panel) {
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
trapTab(e, rootRef.current ?? panel, panel);
}
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
// focus to the first control so the dialog opens focused, not stranded on the background.
const panel = panelRef.current;
if (panel && !panel.contains(document.activeElement)) {
(focusablesIn(panel)[0] ?? panel).focus();
}
return () => {
window.removeEventListener("keydown", onKey);
removeLayer(id);
document.body.style.overflow = prev;
// Restore focus to the trigger, if it's still in the document and focusable.
if (restoreTo && restoreTo.isConnected) restoreTo.focus();
};
}, [restoreTo]);
}, []);
const drawer = placement === "drawer";
const top = placement === "top";
// Only on a server render, where the hook has no `document` to build a host from.
if (!host) return null;
return createPortal(
<div
ref={rootRef}