Files
loyaly-merchant/src/components/shell/SidebarProvider.tsx
2026-08-05 18:34:37 +05:30

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;
}