The rail's hover/focus-to-peek logic — including the portal-proof focus tracking (React-tree onFocus/onBlur plus a safety net for portals that unmount while focused) — is about to be needed verbatim by the side panels. Lift it to useHoverFocusWithin now that a second consumer exists. No behaviour change.
61 lines
2.2 KiB
TypeScript
61 lines
2.2 KiB
TypeScript
import { useEffect, useState, type FocusEvent } from "react";
|
|
|
|
/**
|
|
* "Peek open on hover or keyboard focus" for a container — the shared brain behind the nav rail
|
|
* and the floating side panels, which sit slim until you point at them or tab into them.
|
|
*
|
|
* Spread `handlers` on the container; `active` is true while the pointer is over it (or any
|
|
* descendant) or focus is inside it.
|
|
*
|
|
* Focus is tracked through the REACT tree, so a portaled menu rendered by a child (the language
|
|
* switcher, the account popover) still counts as "inside" and holds the container open while you
|
|
* use it. The one case that misses: such a portal unmounts while focused, and browsers fire NO blur
|
|
* when a focused node is removed — focus silently falls to the body and `active` would latch on
|
|
* forever. Hence the document-level safety net: when focus has landed nowhere, clear it.
|
|
*/
|
|
export function useHoverFocusWithin(): {
|
|
active: boolean;
|
|
handlers: {
|
|
onMouseEnter: () => void;
|
|
onMouseLeave: () => void;
|
|
onFocus: () => void;
|
|
onBlur: (e: FocusEvent) => void;
|
|
};
|
|
} {
|
|
const [hovered, setHovered] = useState(false);
|
|
const [focused, setFocused] = useState(false);
|
|
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
active: hovered || focused,
|
|
handlers: {
|
|
onMouseEnter: () => setHovered(true),
|
|
onMouseLeave: () => setHovered(false),
|
|
onFocus: () => setFocused(true),
|
|
onBlur: (e: FocusEvent) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
|
|
},
|
|
},
|
|
};
|
|
}
|