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 | 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 // 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 }, }; }