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:
@@ -30,6 +30,7 @@ import { getUnreadCount, subscribe } from "../lib/notifications";
|
|||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||||
import { type LangCode } from "../i18n";
|
import { type LangCode } from "../i18n";
|
||||||
import AvatarImg from "./Avatar";
|
import AvatarImg from "./Avatar";
|
||||||
import LanguageSwitcher from "./LanguageSwitcher";
|
import LanguageSwitcher from "./LanguageSwitcher";
|
||||||
@@ -62,37 +63,12 @@ export default function NavSidebar({
|
|||||||
onGoToFullHistory: () => void;
|
onGoToFullHistory: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Hover/focus expand: when unpinned, the rail sits slim (icons only) and expands to a labelled
|
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
|
||||||
// overlay on pointer hover or keyboard focus WITHOUT pushing content. Pinned keeps it expanded
|
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
|
||||||
// in-flow like before. `collapsed` (persisted per-account) now means "unpinned".
|
// `collapsed` (persisted per-account) now means "unpinned".
|
||||||
const [hovered, setHovered] = useState(false);
|
const peek = useHoverFocusWithin();
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
const pinned = !collapsed;
|
const pinned = !collapsed;
|
||||||
const expanded = pinned || hovered || focused;
|
const expanded = pinned || peek.active;
|
||||||
const slim = !expanded; // visual: icon-only rail
|
const slim = !expanded; // visual: icon-only rail
|
||||||
const [acctOpen, setAcctOpen] = useState(false);
|
const [acctOpen, setAcctOpen] = useState(false);
|
||||||
const acctBtnRef = useRef<HTMLButtonElement | null>(null);
|
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)
|
// 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).
|
// overlays content instead of pushing it. Pinned reserves the full width (in-flow).
|
||||||
<div
|
<div
|
||||||
|
{...peek.handlers}
|
||||||
className={`relative shrink-0 transition-[width] ${pinned ? "w-[232px]" : "w-20"}`}
|
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
|
<nav
|
||||||
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
|
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user