import * as React from 'react'; import { Slot } from '@radix-ui/react-slot'; import { cva, VariantProps } from 'class-variance-authority'; import { PanelLeftIcon } from 'lucide-react'; import { useIsMobile } from '@/hooks/use-mobile'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip'; const SIDEBAR_STORAGE_KEY = 'sidebar_state'; const SIDEBAR_WIDTH_STORAGE_KEY = 'sidebar_width'; const SIDEBAR_WIDTH_DEFAULT = 256; const SIDEBAR_WIDTH_MIN = 220; const SIDEBAR_WIDTH_MAX = 420; const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_ICON = '3rem'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; const SIDEBAR_RESIZE_DRAG_THRESHOLD = 4; type SidebarContextProps = { state: 'expanded' | 'collapsed'; open: boolean; setOpen: (open: boolean | ((open: boolean) => boolean)) => void; sidebarWidth: number; setSidebarWidth: (width: number | ((width: number) => number)) => void; isSidebarResizing: boolean; setSidebarResizing: (isResizing: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; }; const SidebarContext = React.createContext(null); function useSidebar() { const context = React.useContext(SidebarContext); if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.'); } return context; } function clampSidebarWidth(width: number) { if (!Number.isFinite(width)) return SIDEBAR_WIDTH_DEFAULT; return Math.min( SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, Math.round(width)), ); } function getStoredSidebarWidth() { if (typeof window === 'undefined') return SIDEBAR_WIDTH_DEFAULT; const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); if (stored === null) return SIDEBAR_WIDTH_DEFAULT; return clampSidebarWidth(Number.parseInt(stored, 10)); } function SidebarProvider({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }: React.ComponentProps<'div'> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; }) { const isMobile = useIsMobile(); const [openMobile, setOpenMobile] = React.useState(false); const [sidebarWidth, _setSidebarWidth] = React.useState( getStoredSidebarWidth, ); const [isSidebarResizing, setSidebarResizing] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(() => { if (typeof window !== 'undefined') { const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY); if (stored !== null) return stored === 'true'; } return defaultOpen; }); const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } // Persist sidebar state to localStorage if (typeof window !== 'undefined') { localStorage.setItem(SIDEBAR_STORAGE_KEY, String(openState)); } }, [setOpenProp, open], ); const setSidebarWidth = React.useCallback( (value: number | ((width: number) => number)) => { _setSidebarWidth((currentWidth) => { const nextWidth = typeof value === 'function' ? value(currentWidth) : value; const clampedWidth = clampSidebarWidth(nextWidth); if (typeof window !== 'undefined') { localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampedWidth)); } return clampedWidth; }); }, [], ); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed'; const contextValue = React.useMemo( () => ({ state, open, setOpen, sidebarWidth, setSidebarWidth, isSidebarResizing, setSidebarResizing, isMobile, openMobile, setOpenMobile, toggleSidebar, }), [ state, open, setOpen, sidebarWidth, setSidebarWidth, isSidebarResizing, setSidebarResizing, isMobile, openMobile, setOpenMobile, toggleSidebar, ], ); return (
{children}
); } function Sidebar({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }: React.ComponentProps<'div'> & { side?: 'left' | 'right'; variant?: 'sidebar' | 'floating' | 'inset'; collapsible?: 'offcanvas' | 'icon' | 'none'; }) { const { isMobile, state, openMobile, setOpenMobile, isSidebarResizing } = useSidebar(); if (collapsible === 'none') { return (
{children}
); } if (isMobile) { return ( Sidebar Displays the mobile sidebar.
{children}
); } return (
{/* This is what handles the sidebar gap on desktop */}
); } function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps) { const { toggleSidebar } = useSidebar(); return ( ); } type SidebarResizeDragState = { pointerId: number; startX: number; startWidth: number; side: 'left' | 'right'; hasDragged: boolean; captureTarget: HTMLElement; previousCursor: string; previousUserSelect: string; }; function getSidebarSide(element: HTMLElement): 'left' | 'right' { const sidebarRoot = element.closest('[data-slot="sidebar"]'); return sidebarRoot?.getAttribute('data-side') === 'right' ? 'right' : 'left'; } function restoreSidebarResizeBodyState(dragState: SidebarResizeDragState) { document.body.style.cursor = dragState.previousCursor; document.body.style.userSelect = dragState.previousUserSelect; } function SidebarRail({ className, onClick, onPointerCancel, onPointerDown, onPointerMove, onPointerUp, ...props }: React.ComponentProps<'button'>) { const { setOpen, sidebarWidth, setSidebarResizing, setSidebarWidth, toggleSidebar, } = useSidebar(); const dragStateRef = React.useRef(null); const removeDragListenersRef = React.useRef<(() => void) | null>(null); const ignoreNextClickRef = React.useRef(false); React.useEffect(() => { return () => { removeDragListenersRef.current?.(); removeDragListenersRef.current = null; if (dragStateRef.current) { restoreSidebarResizeBodyState(dragStateRef.current); dragStateRef.current = null; } setSidebarResizing(false); }; }, [setSidebarResizing]); function clearDragState() { const dragState = dragStateRef.current; if (!dragState) return null; removeDragListenersRef.current?.(); removeDragListenersRef.current = null; if (dragState.captureTarget.hasPointerCapture(dragState.pointerId)) { dragState.captureTarget.releasePointerCapture(dragState.pointerId); } restoreSidebarResizeBodyState(dragState); dragStateRef.current = null; setSidebarResizing(false); return dragState; } function handleResizePointerMove(event: PointerEvent | React.PointerEvent) { const dragState = dragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) return; const delta = dragState.side === 'left' ? event.clientX - dragState.startX : dragState.startX - event.clientX; if ( !dragState.hasDragged && Math.abs(delta) >= SIDEBAR_RESIZE_DRAG_THRESHOLD ) { dragState.hasDragged = true; setSidebarResizing(true); setOpen(true); } if (!dragState.hasDragged) return; event.preventDefault(); setSidebarWidth(dragState.startWidth + delta); } function handleResizePointerEnd(event: PointerEvent | React.PointerEvent) { const currentDragState = dragStateRef.current; if (!currentDragState || currentDragState.pointerId !== event.pointerId) return; const dragState = clearDragState(); if (!dragState) return; ignoreNextClickRef.current = dragState.hasDragged; if (dragState.hasDragged) { event.preventDefault(); window.setTimeout(() => { ignoreNextClickRef.current = false; }, 0); } } function handleResizePointerCancel(event: PointerEvent | React.PointerEvent) { const currentDragState = dragStateRef.current; if (!currentDragState || currentDragState.pointerId !== event.pointerId) return; const dragState = clearDragState(); if (dragState?.hasDragged) { ignoreNextClickRef.current = true; window.setTimeout(() => { ignoreNextClickRef.current = false; }, 0); } } return (