diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 4a53ca1d..edd30a23 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -67,6 +67,30 @@ export default function NavSidebar({ // 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); + }; + }, []); const pinned = !collapsed; const expanded = pinned || hovered || focused; const slim = !expanded; // visual: icon-only rail