dashboard design changes
This commit is contained in:
123
src/components/shell/SidebarProvider.tsx
Normal file
123
src/components/shell/SidebarProvider.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'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;
|
||||
}
|
||||
Reference in New Issue
Block a user