From 11451c1af283af938d8c8e27b96ed2b178eaea75 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 15 Jul 2026 20:57:21 +0200 Subject: [PATCH 01/16] refactor(nav): extract the peek-open hook for reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/NavSidebar.tsx | 43 +++--------------- frontend/src/lib/useHoverFocusWithin.ts | 60 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 36 deletions(-) create mode 100644 frontend/src/lib/useHoverFocusWithin.ts diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index edd30a23..79db4e69 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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(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).
setHovered(true)} - onMouseLeave={() => setHovered(false)} - onFocus={() => setFocused(true)} - onBlur={(e) => { - if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false); - }} >