/** * Keeps focus inside an overlay-hosted modal. * * The overlay can lose focus to mpv or to the compositor while a modal is up, * which leaves the modal visible but inert. Recovery is debounced (and guarded * against re-entry) so a focus fight with the window manager cannot spin. */ export type ModalFocusGuardDeps = { isOpen: () => boolean; /** Modal root; focus inside it counts as "still in the modal". */ getModalRoot: () => Element; /** Preferred focus targets in order; the first rendered one wins. */ getPreferredFocusTargets: () => HTMLElement[]; /** Used when no preferred target is rendered, e.g. the close button. */ getFallbackFocusTarget: () => Element | null; /** Modal-layer windows own their focus; other layers ask the main window. */ isModalLayer: boolean; }; const FOCUS_RECOVERY_DEBOUNCE_MS = 120; export function createModalFocusGuard(deps: ModalFocusGuardDeps) { let focusinGuard: ((event: FocusEvent) => void) | null = null; let windowFocusGuard: (() => void) | null = null; let pointerFocusGuard: ((event: Event) => void) | null = null; let pointerFocusRoot: Element | null = null; let isRecovering = false; let lastRecoveryAt = 0; // Browser setTimeout id; typed loosely because @types/node widens the global. let recoveryTimer: number | null = null; function isModalFocusTarget(target: EventTarget | null): boolean { return target instanceof Element && deps.getModalRoot().contains(target); } function requestOverlayFocus(): void { if (!deps.isModalLayer) { // Best-effort: a rejected focus request must not surface as an unhandled // rejection, since this runs from blur/focus handlers. void Promise.resolve(window.electronAPI.focusMainWindow()).catch(() => {}); } } function focusFallbackTarget(): boolean { requestOverlayFocus(); // getClientRects() rather than offsetParent: the latter is null for // position:fixed elements, which would skip a perfectly visible target. // Rendered is not the same as focusable, so keep trying until one sticks // instead of giving up on the first candidate that refuses focus. for (const target of deps.getPreferredFocusTargets()) { if (target.getClientRects().length === 0) continue; target.focus({ preventScroll: true }); if (document.activeElement === target) return true; } const fallback = deps.getFallbackFocusTarget(); if (fallback instanceof HTMLElement) { fallback.focus({ preventScroll: true }); return document.activeElement === fallback; } window.focus(); return false; } function enforceModalFocus(): void { if (!deps.isOpen()) return; if (isModalFocusTarget(document.activeElement)) return; if (isRecovering) return; const now = Date.now(); if (now - lastRecoveryAt < FOCUS_RECOVERY_DEBOUNCE_MS) return; isRecovering = true; lastRecoveryAt = now; focusFallbackTarget(); recoveryTimer = window.setTimeout(() => { recoveryTimer = null; isRecovering = false; }, FOCUS_RECOVERY_DEBOUNCE_MS); } /** Idempotent; safe to call on every open. */ function attach(): void { if (focusinGuard === null) { // focusin is not cancelable, so there is nothing to preventDefault here; // focus is taken back afterwards instead. focusinGuard = (event: FocusEvent) => { if (!deps.isOpen()) return; if (!isModalFocusTarget(event.target)) { enforceModalFocus(); } }; document.addEventListener('focusin', focusinGuard); } if (pointerFocusGuard === null) { pointerFocusGuard = () => { requestOverlayFocus(); enforceModalFocus(); }; // Remember the root we bound to: resolving it again on detach could // return a different element and leak the listeners on the old one. pointerFocusRoot = deps.getModalRoot(); pointerFocusRoot.addEventListener('pointerdown', pointerFocusGuard); pointerFocusRoot.addEventListener('click', pointerFocusGuard); } if (windowFocusGuard === null) { windowFocusGuard = () => { requestOverlayFocus(); enforceModalFocus(); }; window.addEventListener('blur', windowFocusGuard); window.addEventListener('focus', windowFocusGuard); } } function detach(): void { if (focusinGuard) { document.removeEventListener('focusin', focusinGuard); focusinGuard = null; } if (pointerFocusGuard) { pointerFocusRoot?.removeEventListener('pointerdown', pointerFocusGuard); pointerFocusRoot?.removeEventListener('click', pointerFocusGuard); pointerFocusGuard = null; pointerFocusRoot = null; } if (windowFocusGuard) { window.removeEventListener('blur', windowFocusGuard); window.removeEventListener('focus', windowFocusGuard); windowFocusGuard = null; } // Closing mid-recovery must not leave the debounce armed: a modal reopened // straight away would otherwise get no focus recovery for the next 120 ms. if (recoveryTimer !== null) { window.clearTimeout(recoveryTimer); recoveryTimer = null; } isRecovering = false; lastRecoveryAt = 0; } return { attach, detach, enforceModalFocus, focusFallbackTarget, isModalFocusTarget, requestOverlayFocus, }; }