The 12 react-hooks/exhaustive-deps warnings the gate let through: - ShareDialog and YearFilter built a fresh [] per render while their query loaded, so their memos recomputed on every render; they share one empty list now. - VideoEditor's drag effect called a seek/moveBoundary recreated each render; both are stable callbacks in its deps. - WizardProvider re-checks onboarding on exactly the fields it reads (not the me object, whose 60 s refetch would reopen a closed wizard). - Channels, Feed and PlexPlayer list the stable values they already used. - The player's keydown effect is bound once on purpose (its helpers read refs): said so, with a disable, as the codebase's other deliberate sites do. - dialogFocus and the YouTube player's cleanup read refs React may already have let go of; they use what they captured when the effect ran.
172 lines
10 KiB
TypeScript
172 lines
10 KiB
TypeScript
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
|
|
// The focus half of the dialog contract, in ONE place. `ui/Modal` owns it for every dialog that goes
|
|
// through the shell; the onboarding wizard renders its own shell (a bare `ui/Overlay`, because it is
|
|
// not a titled dialog with a header row) and used to have NONE of this — no trap, no initial focus,
|
|
// no restore — so a keyboard user tabbed straight out of the first-login wizard into the app behind
|
|
// the scrim, where the focus ring is invisible (deep-review round 5). A second hand-written copy is
|
|
// how two shells drift apart; this is the shared one.
|
|
|
|
// 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);
|
|
|
|
/** Keep Tab / Shift+Tab cycling inside `scope` instead of reaching the inert page behind the
|
|
* backdrop. Call from a keydown handler that has already decided this dialog is the topmost layer —
|
|
* a nested dialog must defer to its child. `container` is the scope's own tabIndex=-1 panel, if it
|
|
* has one: focus can land there by clicking dialog dead space, and `Node.contains(self)` is true, so
|
|
* without naming it a Shift+Tab from the panel would leak to the background. */
|
|
export function trapTab(
|
|
e: KeyboardEvent,
|
|
scope: HTMLElement,
|
|
container?: HTMLElement | null,
|
|
): void {
|
|
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) || (container != null && active === container)) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
// ONE body scroll lock for every open dialog, counted. Each dialog used to save and restore
|
|
// `overflow` on its own, so two unmounting out of order (the player's cleanup running before a Modal
|
|
// opened above it) put back a stale "hidden". Invisible today — the app scrolls inside <main>, not
|
|
// the document (measured, E11 S2) — but a count cannot be restored out of order, and PlayerModal's
|
|
// second copy of the lock is gone.
|
|
let scrollLocks = 0;
|
|
let unlockedOverflow = "";
|
|
function lockBodyScroll(): () => void {
|
|
if (scrollLocks++ === 0) {
|
|
unlockedOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
}
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
if (--scrollLocks === 0) document.body.style.overflow = unlockedOverflow;
|
|
};
|
|
}
|
|
|
|
/** The rest of the contract: lock the page behind, open focused, hand the focus back on close.
|
|
* `scopeRef` is the panel the focus should land in; `ownerRef`, when the dialog is bigger than its
|
|
* panel, is that whole dialog — it must be the SAME element `trapTab` is scoped to, or the two
|
|
* disagree about what counts as "inside" (see `heldRef`). `focusScope` opens the dialog focused on
|
|
* the scope element itself rather than its first control: the player keeps the focus on its card, so
|
|
* Space and the arrows reach its shortcuts instead of activating a button. */
|
|
export function useDialogChrome(
|
|
scopeRef: RefObject<HTMLElement | null>,
|
|
ownerRef?: RefObject<HTMLElement | null>,
|
|
{ focusScope = false }: { focusScope?: boolean } = {},
|
|
): void {
|
|
// 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);
|
|
// What the dialog is focused ON, so a RE-mount can put the focus back where it was rather than on
|
|
// the first control. React 18's StrictMode runs every effect mount→cleanup→mount in development:
|
|
// the cleanup hands focus back to the trigger, and the second mount then found the focus outside
|
|
// the dialog and moved it to the first focusable — taking it off the `autoFocus` field a beat
|
|
// after the browser had put it there. Measured in Chrome: the command palette opened with the
|
|
// focus on its ✕ in dev and on its search field in prod, from the same code (round 5).
|
|
const focusedRef = useRef<HTMLElement | null>(null);
|
|
// Whether the dialog still holds the focus. Read at cleanup rather than computed there: by the
|
|
// time a passive cleanup runs on a real unmount the panel is already out of the document and the
|
|
// focus has fallen to <body>, so "does the panel contain activeElement" answers false for both a
|
|
// close and a user who clicked away — and those two want opposite things.
|
|
//
|
|
// Measured against the DIALOG, not the panel. The two scopes must be the same one the Tab trap
|
|
// uses, and `ui/Modal`'s trap deliberately spans the dialog root so an in-dialog toast's Undo/✕
|
|
// stays reachable — so tabbing to that ✕ read as "the user left the dialog", and since removing
|
|
// the toast fires no `focusin` to re-arm, closing the dialog afterwards stranded the focus on
|
|
// <body> instead of handing it back to the trigger. Measured in jsdom, and a regression: before
|
|
// this hook existed the restore was unconditional (deep-review round 6).
|
|
const heldRef = useRef(true);
|
|
useEffect(() => {
|
|
const unlockScroll = lockBodyScroll();
|
|
// Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
|
|
// focus to where this dialog was focused before — or, the first time, to the first control (or
|
|
// the scope itself, with `focusScope`), so the dialog opens focused rather than stranded on the
|
|
// background.
|
|
const scope = scopeRef.current;
|
|
// The dialog's element as it is while open, for the restore in the CLEANUP — where a closing
|
|
// dialog's refs are already detached (refs detach in the commit, passive cleanups run after).
|
|
// Today that reads the same either way (a detached owner and a null one both fail every
|
|
// `contains` below), but the cleanup should not depend on a ref React has already let go of.
|
|
const ownerAtOpen = ownerRef?.current ?? scope;
|
|
if (scope && !scope.contains(document.activeElement)) {
|
|
const back = focusedRef.current;
|
|
const target =
|
|
(back?.isConnected && scope.contains(back) ? back : null) ??
|
|
(focusScope ? scope : (focusablesIn(scope)[0] ?? scope));
|
|
target.focus();
|
|
}
|
|
const onFocusIn = () => {
|
|
const owner = ownerRef?.current ?? scopeRef.current;
|
|
const active = document.activeElement;
|
|
// "Still ours" also means a dialog stacked ABOVE us. A nested `ui/Modal` portals into its own
|
|
// host on <body>, so it is outside our root BY CONSTRUCTION — widening the scope cannot see
|
|
// it — and while it held the focus we read that as the user walking away. Then both closing
|
|
// together left the focus on <body>: ours skipped the restore, and the child's own
|
|
// `restoreTo` was already detached (passive cleanups run parent-first). Measured in jsdom,
|
|
// and it is the ordinary case — an error dialog raised over a dialog (round 6).
|
|
heldRef.current =
|
|
(owner?.contains(active) ?? false) ||
|
|
(active instanceof Element && active.closest('[aria-modal="true"]') != null);
|
|
// Where to put the focus back on a re-mount, though: the PANEL's own last focus. A toast is
|
|
// gone by then, so restoring onto it would strand the focus exactly as before.
|
|
if (scopeRef.current?.contains(document.activeElement))
|
|
focusedRef.current = document.activeElement as HTMLElement;
|
|
};
|
|
onFocusIn();
|
|
document.addEventListener("focusin", onFocusIn);
|
|
return () => {
|
|
document.removeEventListener("focusin", onFocusIn);
|
|
unlockScroll();
|
|
// Restore focus to the trigger, if it's still in the document and focusable — and only if the
|
|
// dialog still held the focus. Once the user has moved it elsewhere, taking it back is the
|
|
// rudest thing a closing dialog can do.
|
|
//
|
|
// Not, though, when a dialog stacked above us is STILL STANDING. `heldRef` counts focus in any
|
|
// `aria-modal` subtree as ours (see above), which cannot tell a layer closing WITH us from one
|
|
// that stays open — and the layer stack cannot tell them apart either: at this instant the
|
|
// child's `removeLayer` has not run, because passive cleanups are parent-first and this hook
|
|
// is declared before that effect, so BOTH cases read "a layer is above me" (measured). What
|
|
// does separate them is the DOM: React detaches a deleted subtree before passive cleanups, so
|
|
// a layer closing with us is already disconnected while one that stays open is not. Without
|
|
// this, a dialog closing on its own (an in-flight mutation's `onSuccess`) under a modal raised
|
|
// above it pulled the focus out of that standing dialog onto our own trigger, behind its
|
|
// scrim — where Enter still activated it (deep-review round 7).
|
|
const owner = ownerAtOpen;
|
|
const active = document.activeElement;
|
|
const above = active instanceof Element ? active.closest('[aria-modal="true"]') : null;
|
|
const standingAbove =
|
|
above !== null &&
|
|
above.isConnected &&
|
|
!above.contains(owner as Node | null) &&
|
|
!(owner?.contains(above) ?? false);
|
|
if (heldRef.current && !standingAbove && restoreTo && restoreTo.isConnected)
|
|
restoreTo.focus();
|
|
};
|
|
}, [restoreTo, scopeRef, ownerRef, focusScope]);
|
|
}
|