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,4 +1,10 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useInsertionEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
// The browser's Fullscreen API, WebKit included.
|
||||
//
|
||||
@@ -56,9 +62,48 @@ const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
|
||||
const fsServerSnapshot = () => null;
|
||||
|
||||
/** The fullscreen element as reactive state, for code that must RENDER differently while something
|
||||
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
|
||||
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
|
||||
* merely react to the transition should keep using `onFullscreenChange` directly. */
|
||||
export function useFullscreenElement(): HTMLElement | null {
|
||||
* holds the lock — today only `useFullscreenPortalHost` below, which has to portal INTO that element
|
||||
* (a node portaled to <body> is painted underneath the fullscreen element, i.e. invisible).
|
||||
* Components that merely react to the transition should keep using `onFullscreenChange` directly. */
|
||||
function useFullscreenElement(): HTMLElement | null {
|
||||
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
|
||||
}
|
||||
|
||||
/** ONE container div for an overlay's whole life, re-parented between `<body>` and the fullscreen
|
||||
* element instead of handed to `createPortal` as a changing container: React tears a portal down
|
||||
* and builds it again when its container changes, and the overlay's contents go with it (a
|
||||
* half-filled form reset to its defaults the moment the viewer left true fullscreen).
|
||||
*
|
||||
* Two rules this owns, which the two hand-written copies it replaced disagreed about (deep-review
|
||||
* round 5):
|
||||
*
|
||||
* 1. **It is attached BEFORE the portal's children commit.** `useInsertionEffect` runs in the
|
||||
* mutation phase, and React handles `autoFocus` in `commitMount` during the LAYOUT phase — one
|
||||
* whole phase later. Attaching in a `useLayoutEffect` is too late: layout effects commit
|
||||
* children-before-parent, so every `autoFocus` descendant called `.focus()` on a node that was
|
||||
* still disconnected, where `focus()` is a no-op. Every autoFocus in every dialog was dead.
|
||||
* 2. **`appendChild` MOVES the node, it does not teleport it.** Per the DOM spec an already-parented
|
||||
* node is removed and re-inserted, and removing a focused element's ancestor resets focus to
|
||||
* `<body>` — so the focused element is saved and re-focused across the move.
|
||||
*/
|
||||
export function useFullscreenPortalHost(): HTMLElement | null {
|
||||
const fsRoot = useFullscreenElement();
|
||||
const [host] = useState<HTMLElement | null>(() =>
|
||||
typeof document === "undefined" ? null : document.createElement("div"),
|
||||
);
|
||||
useInsertionEffect(() => {
|
||||
if (host && !host.parentNode) document.body.appendChild(host);
|
||||
}, [host]);
|
||||
useLayoutEffect(() => {
|
||||
if (!host) return;
|
||||
const parent = fsRoot ?? document.body;
|
||||
if (host.parentNode === parent) return;
|
||||
const focused = host.contains(document.activeElement)
|
||||
? (document.activeElement as HTMLElement)
|
||||
: null;
|
||||
parent.appendChild(host);
|
||||
focused?.focus();
|
||||
}, [fsRoot, host]);
|
||||
useEffect(() => () => host?.remove(), [host]);
|
||||
return host;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user