'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import {useBreakpoint, isSideNavInline} from '@/lib/breakpoints'; import {usePersistentTriState} from '@/lib/usePersistentFlag'; /** * One source of truth for "where is the navigation right now". * * Before this, three components each owned a piece: SideNav held its own * collapse flag, the layout held the drawer flag, and AppShell injected a * hamburger only in its mobile top bar. Nothing could see the whole picture, * which is how the sidebar became unreachable — collapsed on desktop with no * control left to expand it, and closed on mobile with the toggle living in a * slot that only exists below 640px. * * Hoisting both flags here means a single button (NavMenuButton) can render in * the header at every width and always know what it should do, and the drawer * can never be open in a layout that has no way to close it. * * ── The three states ────────────────────────────────────────────────────── * drawer <640 overlay, closed by default, opened by the hamburger * rail 640+ inline; collapsed (84px) or expanded (260px) * * `isDrawer` deliberately mirrors AppShell's own `mobileNav.breakpoint` of * 'sm' via isSideNavInline — the two must agree or you get two hamburgers or * none. See lib/breakpoints. */ /** The drawer's DOM id, so the header button's `aria-controls` resolves. */ export const NAV_DRAWER_ID = 'workspace-nav-drawer'; /** The inline rail's DOM id, for the same reason above the breakpoint. */ export const NAV_RAIL_ID = 'workspace-nav-rail'; const COLLAPSE_KEY = 'loyaly.sidenav.collapsed'; interface SidebarValue { /** True below 640px, where the nav is an overlay rather than a column. */ isDrawer: boolean; /** Rail state. Meaningless while `isDrawer` — the drawer is never a rail. */ isCollapsed: boolean; isDrawerOpen: boolean; setDrawerOpen: (open: boolean) => void; closeDrawer: () => void; /** Open/close below the breakpoint, collapse/expand above it. */ toggle: () => void; /** Whichever element the header button currently controls. */ controlsId: string; } const SidebarContext = createContext(null); export function SidebarProvider({children}: {children: React.ReactNode}) { const bp = useBreakpoint(); const isDrawer = !isSideNavInline(bp); // Tri-state, not a boolean: a tablet has to start collapsed WITHOUT that // being a stored preference, or the first desktop visit inherits it. const [storedCollapsed, setStoredCollapsed] = usePersistentTriState(COLLAPSE_KEY); const isCollapsed = storedCollapsed ?? bp === 'tablet'; const [isMenuOpen, setMenuOpen] = useState(false); // DERIVED, not reset in an effect: a resize past the breakpoint would // otherwise leave the drawer mounted over a layout whose hamburger has // turned into a collapse button — a nav trap with no visible way out. // Gating on the current breakpoint costs no extra render pass. const isDrawerOpen = isDrawer && isMenuOpen; const closeDrawer = useCallback(() => setMenuOpen(false), []); const toggle = useCallback(() => { if (isDrawer) { setMenuOpen((open) => !open); } else { setStoredCollapsed(!isCollapsed); } }, [isDrawer, isCollapsed, setStoredCollapsed]); // Body scroll lock. MobileNav's native already clips the scroll on // , but iOS Safari still rubber-bands underneath the top layer, // which slides the page behind a drawer that stays put. useEffect(() => { if (!isDrawerOpen) return; const previous = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = previous; }; }, [isDrawerOpen]); const value = useMemo( () => ({ isDrawer, isCollapsed, isDrawerOpen, setDrawerOpen: setMenuOpen, closeDrawer, toggle, controlsId: isDrawer ? NAV_DRAWER_ID : NAV_RAIL_ID, }), [isDrawer, isCollapsed, isDrawerOpen, closeDrawer, toggle], ); return {children}; } export function useSidebar(): SidebarValue { const ctx = useContext(SidebarContext); if (!ctx) { throw new Error('useSidebar must be used inside '); } return ctx; }