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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user