Files
siftlode/frontend/src/components/ui/dialogFocus.ts
T
peter 1f81684b6e fix(ui,plex): a dialog keeps the focus its child asked for, and a hopeless segment fails at once
Both found by UAT in a real browser, after the tests for them were already green — which is the
whole reason this repo settles focus and timing claims by running them.

- **The autoFocus fix worked, and then the dialog took the focus back.** React 18's StrictMode runs
  every effect mount→cleanup→mount in development: the cleanup handed focus back to the trigger, and
  the second mount, finding the focus outside the dialog, moved it to the first focusable — off the
  `autoFocus` field a beat after the browser had put it there. Chrome measured it: INPUT (commitMount)
  → BODY (cleanup) → ✕ (re-mount). The effect is idempotent now — it remembers what the dialog was
  focused on and restores THAT on a re-mount — and the close-time restore is conditional on the
  dialog still holding the focus, which it tracks as it changes rather than computing at cleanup,
  where the panel is already out of the document. A dialog no longer yanks focus back from wherever
  the user has since moved it.

- **Passing ffmpeg's handle to the segment wait only shortened the FINISHED case.** Measured against
  the running localdev API: a request for `seg_99999.ts` on a live session still took 20 195 ms,
  because that ffmpeg has not exited and the wait has no other reason to stop. ffmpeg writes segments
  in order, so a segment whose predecessor does not exist is not the next one and cannot arrive —
  and that is knowable at once. Same request now: 45 ms.

Verified on localdev against the real library (Westworld S01E06, remux, multi-audio): Mod+K opens the
command palette with the focus in its search field and typing reaches it; playback runs on the new
session-scoped HLS URL; a request naming a stale session id is a 404; and a viewer paused for longer
than the finished-session rule keeps their 2.4 GB session across two reaper passes — the progress
checkpoint touches it — and resumes cleanly.

vitest 1009, pytest 1127.
2026-09-12 04:31:51 +02:00

101 lines
5.5 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();
}
}
/** 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. */
export function useDialogChrome(scopeRef: RefObject<HTMLElement | null>): 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.
const heldRef = useRef(true);
useEffect(() => {
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 where this dialog was focused before — or, the first time, to the first control, so
// the dialog opens focused rather than stranded on the background.
const scope = scopeRef.current;
if (scope && !scope.contains(document.activeElement)) {
const back = focusedRef.current;
const target =
(back?.isConnected && scope.contains(back) ? back : null) ??
focusablesIn(scope)[0] ??
scope;
target.focus();
}
const onFocusIn = () => {
const inside = scopeRef.current?.contains(document.activeElement) ?? false;
heldRef.current = inside;
if (inside) focusedRef.current = document.activeElement as HTMLElement;
};
onFocusIn();
document.addEventListener("focusin", onFocusIn);
return () => {
document.removeEventListener("focusin", onFocusIn);
document.body.style.overflow = prev;
// 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.
if (heldRef.current && restoreTo && restoreTo.isConnected) restoreTo.focus();
};
}, [restoreTo, scopeRef]);
}