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.
81 lines
3.7 KiB
TypeScript
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 },
|
|
};
|
|
}
|