Files
siftlode/frontend/src/lib/useHoverFocusWithin.ts
T
peter b4aea7fa5b fix(ui): dialogs, menus and busy buttons keep the keyboard focus where it belongs
Measured in Chrome before each change:

- A dialog opened from a menu lost its focus: the menu row ran its action
  and closed in one batch, and floating-ui's focus return (a microtask)
  moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after
  it opened, where Enter activated the row. MenuItemList now closes first
  and runs the action a task later; the dialog also captures the row, not
  a dying menu item, as its restore target.
- A side panel opened as a flyout collapsed the moment a dialog opened
  from inside it, unmounting the row the dialog hands the focus back to.
  useHoverFocusWithin holds while the focus is inside an aria-modal dialog.
- ui/Button's loading state set native disabled; Chrome moved the focus of
  the pressed button to <body> and never gave it back. Loading is now
  aria-disabled with a click guard that also cancels implicit submission.
- Escape/Enter in the playlist rail's rename field dropped the focus on
  <body>. The row refocuses from its ref callback when it re-attaches (a
  post-commit focus sticks; the earlier attempt ran before the button
  existed).
- PlayerModal was the one aria-modal shell without the dialog contract:
  it is now named by its title, traps Tab, restores the focus to its
  opener and shares one counted body scroll lock with every dialog.
2026-09-13 01:04:11 +02:00

81 lines
3.7 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type FocusEvent } from "react";
/**
* "Peek open on hover or keyboard focus" for a floating panel — the shared brain behind the side
* panels, which sit slim until you point at them or tab into them.
*
* Returns `active` plus THREE handler groups so a consumer can scope hover and focus independently:
* - `hoverHandlers` — pointer enter/leave, with a short GRACE period on leave so the pointer can
* cross a gap between two separate peek regions (e.g. the docked tab and the expanded card, which
* are separate absolutely-positioned elements) without the panel collapsing mid-move. Put these on
* ONLY the visible affordances (the tab handle + the card), so approaching empty gutter next to the
* panel does not pop it open.
* - `focusHandlers` — focus/blur, tracked through the REACT tree so a portaled child menu still
* counts as "inside". Put these on the outer container so keyboard focus anywhere within holds it.
* - `handlers` — both groups merged, for the simple single-element case.
*
* The one focus case that misses: a portaled menu unmounts while focused — browsers fire NO blur when
* a focused node is removed, so focus silently falls to the body and would latch `focused` on forever.
* Hence the document-level safety net: when focus has landed nowhere, clear it.
*/
export function useHoverFocusWithin() {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const enter = useCallback(() => {
clearTimeout(leaveTimer.current);
setHovered(true);
}, []);
const leaveSoon = useCallback(() => {
clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => setHovered(false), 120);
}, []);
useEffect(() => () => clearTimeout(leaveTimer.current), []);
useEffect(() => {
let timer: number | undefined;
const clearIfFocusLandedNowhere = () => {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) setFocused(false);
};
// Deferred a tick so activeElement has settled, and rescheduled rather than queued — only the
// latest state matters.
const soon = () => {
window.clearTimeout(timer);
timer = window.setTimeout(clearIfFocusLandedNowhere, 0);
};
document.addEventListener("focusout", soon);
document.addEventListener("click", soon);
return () => {
document.removeEventListener("focusout", soon);
document.removeEventListener("click", soon);
window.clearTimeout(timer);
};
}, []);
const hoverHandlers = { onMouseEnter: enter, onMouseLeave: leaveSoon };
const focusHandlers = {
onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => {
const to = e.relatedTarget as Element | null;
if (e.currentTarget.contains(to)) return;
// A dialog opened FROM inside the panel (the rail row's "Delete?" confirm) renders at the app
// root, so neither the DOM nor the React tree sees it as inside — and collapsing the panel under
// it unmounted the very row the dialog hands the focus back to, leaving a keyboard user on
// <body> after Cancel (measured in Chrome, E11 S2). Hold while the focus is in a dialog: when it
// closes, the focus either comes back in here (onFocus) or lands nowhere, which the
// document-level net below clears.
if (to?.closest('[aria-modal="true"]')) return;
setFocused(false);
},
};
return {
active: hovered || focused,
hoverHandlers,
focusHandlers,
handlers: { ...hoverHandlers, ...focusHandlers },
};
}