124 lines
4.4 KiB
TypeScript
124 lines
4.4 KiB
TypeScript
'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<SidebarValue | null>(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 <dialog> already clips the scroll on
|
|
// <html>, but iOS Safari still rubber-bands <body> 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 <SidebarContext value={value}>{children}</SidebarContext>;
|
|
}
|
|
|
|
export function useSidebar(): SidebarValue {
|
|
const ctx = useContext(SidebarContext);
|
|
if (!ctx) {
|
|
throw new Error('useSidebar must be used inside <SidebarProvider>');
|
|
}
|
|
return ctx;
|
|
}
|