refactor(nav): extract the peek-open hook for reuse

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.
This commit is contained in:
2026-07-15 20:57:21 +02:00
parent 9d9c6894f4
commit 11451c1af2
2 changed files with 67 additions and 36 deletions
+7 -36
View File
@@ -30,6 +30,7 @@ import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n";
import AvatarImg from "./Avatar";
import LanguageSwitcher from "./LanguageSwitcher";
@@ -62,37 +63,12 @@ export default function NavSidebar({
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
// onFocus/onBlur (below) track focus through the REACT tree, so the rail's portaled menus
// (language switcher, account popover) count as "inside" and keep it open while you use them.
// The case they miss: a portal unmounts while focused, and browsers fire NO blur when a focused
// node is removed — focus silently falls to the body and `focused` would latch on forever.
// Safety net: whenever focus has landed nowhere, clear it. Deferred a tick so activeElement has
// settled, and rescheduled rather than queued — only the latest state matters.
useEffect(() => {
let timer: number | undefined;
const clearIfFocusLandedNowhere = () => {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) setFocused(false);
};
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);
};
}, []);
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
// `collapsed` (persisted per-account) now means "unpinned".
const peek = useHoverFocusWithin();
const pinned = !collapsed;
const expanded = pinned || hovered || focused;
const expanded = pinned || peek.active;
const slim = !expanded; // visual: icon-only rail
const [acctOpen, setAcctOpen] = useState(false);
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
@@ -300,13 +276,8 @@ export default function NavSidebar({
// Layout slot: reserves only the slim footprint when unpinned, so the expanded rail (below)
// overlays content instead of pushing it. Pinned reserves the full width (in-flow).
<div
{...peek.handlers}
className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
}}
>
<nav
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
+60
View File
@@ -0,0 +1,60 @@
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);
},
},
};
}