"use client"; import { ChevronRight } from "lucide-react"; import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion, type Variants, } from "motion/react"; import { type ButtonHTMLAttributes, type CSSProperties, createContext, forwardRef, type HTMLAttributes, type ReactNode, useCallback, useContext, useEffect, useId, useRef, useState, useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import { SharedLayoutBg } from "@/components/motion/shared-layout-bg"; import { EASE_DRAWER, EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, } from "@/lib/ease"; import { cn } from "@/lib/utils"; type SidebarState = "expanded" | "collapsed"; type SidebarSide = "left" | "right"; type SidebarVariant = "sidebar" | "floating" | "inset"; type SidebarCollapsible = "offcanvas" | "icon" | "none"; const MOBILE_QUERY = "(max-width: 767px)"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; const PANEL_TRANSITION = { duration: 0.36, ease: EASE_DRAWER, } as const; // The desktop rail is one surface changing shape, so a lightly underdamped // spring makes the width settle without scaling or stretching its contents. const SIDEBAR_MORPH_TRANSITION = { type: "spring", stiffness: 380, damping: 28, mass: 0.75, } as const; const LABEL_ENTER_TRANSITION = { duration: 0.2, delay: 0.08, ease: EASE_OUT, } as const; const LABEL_EXIT_TRANSITION = { duration: 0.12, ease: EASE_OUT, } as const; const SUBMENU_TRANSITION = { duration: 0.18, ease: EASE_OUT, } as const; const SUBMENU_VARIANTS: Variants = { closed: { opacity: 0, clipPath: "inset(0 0 100% 0 round 8px)", transition: { duration: 0.14, ease: EASE_OUT, staggerChildren: 0.025, staggerDirection: -1, }, }, open: { opacity: 1, clipPath: "inset(0 0 0% 0 round 8px)", transition: { duration: 0.2, delayChildren: 0.035, ease: EASE_OUT, staggerChildren: 0.045, }, }, }; const SUBMENU_ITEM_VARIANTS: Variants = { closed: { opacity: 0, y: -6, filter: "blur(3px)", }, open: { opacity: 1, y: 0, filter: "blur(0px)", transition: SUBMENU_TRANSITION, }, }; const REDUCED_TRANSITION = { duration: 0.16, ease: EASE_OUT, } as const; const FOCUSABLE_SELECTOR = [ "a[href]", "button:not([disabled])", "input:not([disabled])", "select:not([disabled])", "textarea:not([disabled])", "[tabindex]:not([tabindex='-1'])", ].join(","); function subscribeToMobileQuery(callback: () => void) { const query = window.matchMedia(MOBILE_QUERY); query.addEventListener("change", callback); return () => query.removeEventListener("change", callback); } function getMobileSnapshot() { return window.matchMedia(MOBILE_QUERY).matches; } function getServerMobileSnapshot() { return false; } function useIsMobile() { return useSyncExternalStore( subscribeToMobileQuery, getMobileSnapshot, getServerMobileSnapshot, ); } interface AnimatedSidebarContextValue { isMobile: boolean; layoutId: string; open: boolean; openMobile: boolean; reduce: boolean; setOpen: (open: boolean) => void; setOpenMobile: (open: boolean) => void; state: SidebarState; toggleSidebar: () => void; triggerRef: React.RefObject; } const AnimatedSidebarContext = createContext(null); interface AnimatedSidebarPanelContextValue { collapsed: boolean; collapsible: SidebarCollapsible; side: SidebarSide; } const AnimatedSidebarPanelContext = createContext(null); export function useAnimatedSidebar() { const context = useContext(AnimatedSidebarContext); if (!context) { throw new Error( "useAnimatedSidebar must be used inside AnimatedSidebarProvider.", ); } return context; } function useAnimatedSidebarPanel() { const context = useContext(AnimatedSidebarPanelContext); if (!context) { throw new Error( "Animated Sidebar parts must be used inside AnimatedSidebar.", ); } return context; } type SidebarProviderStyle = CSSProperties & { "--sidebar-width"?: string; "--sidebar-width-icon"?: string; "--sidebar-width-mobile"?: string; }; export interface AnimatedSidebarProviderProps extends HTMLAttributes { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; openMobile?: boolean; defaultOpenMobile?: boolean; onOpenMobileChange?: (open: boolean) => void; style?: SidebarProviderStyle; } export function AnimatedSidebarProvider({ children, open, defaultOpen = true, onOpenChange, openMobile, defaultOpenMobile = false, onOpenMobileChange, className, style, ...props }: AnimatedSidebarProviderProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const [internalOpenMobile, setInternalOpenMobile] = useState(defaultOpenMobile); const isMobile = useIsMobile(); const reduce = useReducedMotion() ?? false; const generatedId = useId(); const triggerRef = useRef(null); const desktopOpen = open ?? internalOpen; const mobileOpen = openMobile ?? internalOpenMobile; const setOpen = useCallback( (nextOpen: boolean) => { if (open === undefined) setInternalOpen(nextOpen); onOpenChange?.(nextOpen); }, [onOpenChange, open], ); const setOpenMobile = useCallback( (nextOpen: boolean) => { if (openMobile === undefined) setInternalOpenMobile(nextOpen); onOpenMobileChange?.(nextOpen); }, [onOpenMobileChange, openMobile], ); const toggleSidebar = useCallback(() => { if (isMobile) setOpenMobile(!mobileOpen); else setOpen(!desktopOpen); }, [desktopOpen, isMobile, mobileOpen, setOpen, setOpenMobile]); useEffect(() => { const handleShortcut = (event: KeyboardEvent) => { if ( event.key.toLowerCase() === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener("keydown", handleShortcut); return () => window.removeEventListener("keydown", handleShortcut); }, [toggleSidebar]); return (
{children}
); } function MobileSidebar({ ariaLabel, children, className, side, }: { ariaLabel: string; children: ReactNode; className?: string; side: SidebarSide; }) { const context = useAnimatedSidebar(); const panelRef = useRef(null); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); useEffect(() => { if (!context.openMobile) return; const body = document.body; const scrollY = window.scrollY; const previousBodyStyles = { left: body.style.left, overflow: body.style.overflow, position: body.style.position, right: body.style.right, top: body.style.top, }; body.style.position = "fixed"; body.style.top = `-${scrollY}px`; body.style.left = "0"; body.style.right = "0"; body.style.overflow = "hidden"; const focusFrame = requestAnimationFrame(() => { const firstFocusable = panelRef.current?.querySelector(FOCUSABLE_SELECTOR); (firstFocusable ?? panelRef.current)?.focus({ preventScroll: true }); }); return () => { cancelAnimationFrame(focusFrame); body.style.position = previousBodyStyles.position; body.style.top = previousBodyStyles.top; body.style.left = previousBodyStyles.left; body.style.right = previousBodyStyles.right; body.style.overflow = previousBodyStyles.overflow; window.scrollTo(0, scrollY); context.triggerRef.current?.focus({ preventScroll: true }); }; }, [context.openMobile, context.triggerRef]); if (!mounted) return null; return createPortal(
context.setOpenMobile(false)} className={cn( "absolute inset-0 bg-black/40", context.openMobile ? "pointer-events-auto" : "pointer-events-none", )} /> { if (event.key === "Escape") { event.preventDefault(); context.setOpenMobile(false); return; } if (event.key !== "Tab") return; const focusable = panelRef.current ? Array.from( panelRef.current.querySelectorAll( FOCUSABLE_SELECTOR, ), ) : []; if (focusable.length === 0) { event.preventDefault(); panelRef.current?.focus(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }} className={cn( "pointer-events-auto absolute inset-y-0 flex h-dvh w-(--sidebar-width-mobile) max-w-[88vw] flex-col overflow-hidden", "border-border bg-background shadow-2xl will-change-transform", side === "left" ? "left-0 border-r" : "right-0 border-l", !context.openMobile && "pointer-events-none", className, )} > {children}
, document.body, ); } export interface AnimatedSidebarProps extends Omit, "children"> { children?: ReactNode; side?: SidebarSide; variant?: SidebarVariant; collapsible?: SidebarCollapsible; ariaLabel?: string; panelClassName?: string; } export const AnimatedSidebar = forwardRef( function AnimatedSidebar( { side = "left", variant = "sidebar", collapsible = "icon", ariaLabel = "Sidebar", children, className, panelClassName, style, ...props }, forwardedRef, ) { const context = useAnimatedSidebar(); const collapsed = collapsible !== "none" && !context.open; const offcanvas = collapsed && collapsible === "offcanvas"; const width = offcanvas ? "0px" : collapsed ? "var(--sidebar-width-icon)" : "var(--sidebar-width)"; if (context.isMobile) { return ( {children} ); } return ( ); }, ); export interface AnimatedSidebarTriggerProps extends ButtonHTMLAttributes {} export const AnimatedSidebarTrigger = forwardRef< HTMLButtonElement, AnimatedSidebarTriggerProps >(function AnimatedSidebarTrigger( { className, onClick, type = "button", ...props }, forwardedRef, ) { const context = useAnimatedSidebar(); const expanded = context.isMobile ? context.openMobile : context.open; return (