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:
@@ -3,7 +3,6 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { useFullscreenElement } from "../../lib/fullscreen";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
@@ -528,19 +527,14 @@ function PopoverSurfaceInner({
|
||||
|
||||
// A node portaled to <body> is painted UNDERNEATH whatever holds the fullscreen lock, so a popover
|
||||
// opened from a player's control bar in fullscreen would be invisible; it must portal INTO the
|
||||
// fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen
|
||||
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
|
||||
// focus, caret, scroll offset and animation state survive a fullscreen transition, and an element
|
||||
// SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to
|
||||
// remount instead would rebuild the subtree and lose all of that.)
|
||||
const fsRoot = useFullscreenElement();
|
||||
const [container] = useState<HTMLDivElement | null>(() =>
|
||||
typeof document === "undefined" ? null : document.createElement("div"),
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (container) (fsRoot ?? document.body).appendChild(container);
|
||||
}, [container, fsRoot]);
|
||||
useEffect(() => () => container?.remove(), [container]);
|
||||
// fullscreen element. One persistent container div is re-parented (body ↔ fullscreen element)
|
||||
// rather than handed to the portal as a changing container, so the open panel's caret, scroll
|
||||
// offset and animation state survive the transition, and an element SWAP (A→B without exiting) is
|
||||
// handled by the element-identity dependency. (Keying the portal to remount instead would rebuild
|
||||
// the subtree and lose all of that.) Focus does NOT survive on its own — `appendChild` on an
|
||||
// already-parented node is remove-then-insert, which blurs to <body>; the hook saves and restores
|
||||
// it. This used to be a second copy of Modal's re-parent that lacked exactly that (round 5).
|
||||
const container = useFullscreenPortalHost();
|
||||
|
||||
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
||||
style?: CSSProperties;
|
||||
|
||||
Reference in New Issue
Block a user