Files
siftlode/frontend/src/lib/fullscreen.ts
T
peter 41b652f8fd 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).
2026-09-12 03:41:37 +02:00

110 lines
5.2 KiB
TypeScript

import {
useEffect,
useInsertionEffect,
useLayoutEffect,
useState,
useSyncExternalStore,
} from "react";
// The browser's Fullscreen API, WebKit included.
//
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
// `fullscreenElement`, no `requestFullscreen` on a plain element. Reading only the unprefixed names
// there means the page believes it is NEVER fullscreen — which silently breaks whatever the
// fullscreen state drives (an overlay that must yield, a focus reclaim, an Esc branch that would
// otherwise close the whole player instead of just leaving fullscreen).
//
// Both video surfaces (PlayerModal's YouTube embed, PlexPlayer's <video>) go through here, so the
// prefix handling lives in ONE place — the previous shape had it in neither, then in one.
type WebkitDocument = Document & {
webkitFullscreenElement?: Element | null;
webkitExitFullscreen?: () => void;
};
type WebkitElement = HTMLElement & { webkitRequestFullscreen?: () => void };
/** The element currently holding the fullscreen lock, or null. */
export function fullscreenElement(): Element | null {
const d = document as WebkitDocument;
return d.fullscreenElement ?? d.webkitFullscreenElement ?? null;
}
/** Ask for fullscreen on `el`. Errors are swallowed on purpose: a refused request (no user
* activation, an iOS device that only fullscreens <video>) is not something a caller can fix. */
export function requestFullscreen(el: HTMLElement): void {
const e = el as WebkitElement;
if (e.requestFullscreen) void e.requestFullscreen().catch(() => {});
else e.webkitRequestFullscreen?.();
}
/** Leave fullscreen, whoever holds it. */
export function exitFullscreen(): void {
const d = document as WebkitDocument;
if (d.exitFullscreen) void d.exitFullscreen().catch(() => {});
else d.webkitExitFullscreen?.();
}
/** Subscribe to fullscreen transitions; returns the unsubscribe. Registers BOTH spellings — a
* browser that supports both never fires both for the same transition, so the handler still runs
* once. */
export function onFullscreenChange(handler: () => void): () => void {
document.addEventListener("fullscreenchange", handler);
document.addEventListener("webkitfullscreenchange", handler);
return () => {
document.removeEventListener("fullscreenchange", handler);
document.removeEventListener("webkitfullscreenchange", handler);
};
}
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
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 — 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;
}