"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 settles at a hard zero-width boundary. Keep the spring // critically damped so it cannot overshoot, pause against that boundary, and // then snap back during the final frame. const SIDEBAR_MORPH_TRANSITION = { type: "spring", stiffness: 380, damping: 35, 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); // The sheet is mounted for as long as the viewport is mobile, so it hides // itself while closed rather than sitting there transparent and interactive. // Opening shows it in the same commit that starts the slide — a delayed show // would run the focus effect below against a still-hidden panel, and focus() // on a hidden element is ignored. Closing waits for the slide to finish, and // the panel's own exit tells us when that is: no duration to keep in sync. const [hidden, setHidden] = useState(!context.openMobile); // The completion callback fires for the open slide too, and it reads state // from whenever motion settles: a ref keeps it on the current one. const openMobileRef = useRef(context.openMobile); useEffect(() => setMounted(true), []); useEffect(() => { openMobileRef.current = context.openMobile; if (context.openMobile) setHidden(false); }, [context.openMobile]); 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; // This container groups the sheet for hiding and the z-index and carries no // box: both children are `fixed` and resolve against the viewport themselves. // The scrim spans the viewport edges but paints a colour, and the panel is // inset off one side and paints its own surface, so no layer here is a // transparent edge-spanning one. See tests/fixed-overlay-edge-sampling.test.tsx. return createPortal( , 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 (