import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from 'react'; import { isDesktopViewport, useSidePanelMachine } from '../panels/useSidePanelMachine'; const SIDEBAR_WIDTH_KEY = 'authSidebarWidth'; const SIDEBAR_COLLAPSED_KEY = 'authSidebarCollapsed'; const SIDEBAR_DEFAULT_WIDTH = 280; const SIDEBAR_MIN_WIDTH = 220; const SIDEBAR_MAX_WIDTH = 420; const SIDEBAR_COLLAPSED_WIDTH = 56; export type LeftMenuSizing = { defaultWidth?: number; minWidth?: number; maxWidth?: number; collapsedWidth?: number; }; export type LeftMenuRenderState = { collapsed: boolean; mobileOpen: boolean; isDesktop: boolean; closeMenu: () => void; }; export type LeftMenuContent = { ariaLabel?: string; render: (state: LeftMenuRenderState) => ReactNode; }; export type LeftMenuStyle = CSSProperties & { '--auth-sidebar-width': string; }; type LeftMenuContextValue = { collapsed: boolean; mobileOpen: boolean; content: LeftMenuContent; desktopMenuStyle: LeftMenuStyle; openMenu: (content?: LeftMenuContent) => void; closeMenu: () => void; toggleMenu: (content?: LeftMenuContent) => void; expandMenu: () => void; collapseMenu: () => void; toggleCollapsed: () => void; setMenuContent: (content: LeftMenuContent | null) => void; startResize: ReturnType['startResize']; }; type LeftMenuProviderProps = { children: ReactNode; defaultContent: LeftMenuContent; closeOnPathname?: string; sizing?: LeftMenuSizing; }; const LeftMenuContext = createContext(undefined); function readSizingValue(value: number | undefined, fallback: number): number { if (!Number.isFinite(value) || value == null || value <= 0) { return fallback; } return value; } function resolveLeftMenuSizing(sizing: LeftMenuSizing | undefined) { const requestedMinWidth = readSizingValue(sizing?.minWidth, SIDEBAR_MIN_WIDTH); const requestedMaxWidth = readSizingValue(sizing?.maxWidth, SIDEBAR_MAX_WIDTH); const minWidth = Math.min(requestedMinWidth, requestedMaxWidth); const maxWidth = Math.max(requestedMinWidth, requestedMaxWidth); const requestedDefaultWidth = readSizingValue(sizing?.defaultWidth, SIDEBAR_DEFAULT_WIDTH); const defaultWidth = Math.min(maxWidth, Math.max(minWidth, requestedDefaultWidth)); const requestedCollapsedWidth = readSizingValue( sizing?.collapsedWidth, SIDEBAR_COLLAPSED_WIDTH, ); const collapsedWidth = Math.min(minWidth, requestedCollapsedWidth); return { defaultWidth, minWidth, maxWidth, collapsedWidth, }; } function readStoredCollapsed(): boolean { if (!globalThis.window) { return false; } return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1'; } export function LeftMenuProvider({ children, defaultContent, closeOnPathname, sizing, }: Readonly) { const { defaultWidth, minWidth, maxWidth, collapsedWidth } = resolveLeftMenuSizing(sizing); const [collapsed, setCollapsed] = useState(() => readStoredCollapsed()); const [mobileOpen, setMobileOpen] = useState(false); const [content, setContent] = useState(defaultContent); const defaultContentRef = useRef(defaultContent); useEffect(() => { const previousDefaultContent = defaultContentRef.current; defaultContentRef.current = defaultContent; setContent((currentContent) => { if (currentContent === previousDefaultContent) { return defaultContent; } return currentContent; }); }, [defaultContent]); useEffect(() => { if (!globalThis.window) { return; } localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? '1' : '0'); }, [collapsed]); const expandMenu = useCallback(() => { setCollapsed(false); }, []); const collapseMenu = useCallback(() => { setCollapsed(true); }, []); const toggleCollapsed = useCallback(() => { setCollapsed((previous) => !previous); }, []); const closeMobile = useCallback(() => { setMobileOpen(false); }, []); const setMenuContent = useCallback((nextContent: LeftMenuContent | null) => { setContent(nextContent ?? defaultContentRef.current); }, []); const closeMenu = useCallback(() => { if (isDesktopViewport()) { collapseMenu(); return; } closeMobile(); }, [collapseMenu, closeMobile]); const openMenu = useCallback( (nextContent?: LeftMenuContent) => { if (nextContent) { setContent(nextContent); } if (isDesktopViewport()) { expandMenu(); return; } setMobileOpen(true); }, [expandMenu], ); const toggleMenu = useCallback( (nextContent?: LeftMenuContent) => { if (nextContent) { setContent(nextContent); } if (isDesktopViewport()) { toggleCollapsed(); return; } setMobileOpen((previous) => !previous); }, [toggleCollapsed], ); const handleCloseOnPathname = useCallback(() => { setMobileOpen(false); setContent(defaultContentRef.current); }, []); const { width, startResize } = useSidePanelMachine({ storageKey: SIDEBAR_WIDTH_KEY, defaultWidth, minWidth, maxWidth, resizeAxis: 'from-left', resizingBodyClass: 'auth-sidebar-resizing', isOpen: mobileOpen, canResize: !collapsed, shouldPersistWidth: !collapsed, closeOnPathname, onCloseOnPathname: handleCloseOnPathname, onEscape: closeMobile, }); const desktopMenuStyle = useMemo( () => ({ '--auth-sidebar-width': `${collapsed ? collapsedWidth : width}px`, }), [collapsed, collapsedWidth, width], ); const value = useMemo( () => ({ collapsed, mobileOpen, content, desktopMenuStyle, openMenu, closeMenu, toggleMenu, expandMenu, collapseMenu, toggleCollapsed, setMenuContent, startResize, }), [ collapsed, mobileOpen, content, desktopMenuStyle, openMenu, closeMenu, toggleMenu, expandMenu, collapseMenu, toggleCollapsed, setMenuContent, startResize, ], ); return {children}; } export function useLeftMenu() { const ctx = useContext(LeftMenuContext); if (!ctx) { throw new Error('useLeftMenu must be used within LeftMenuProvider'); } return ctx; }