"use client"; import { LogOut, Menu, PanelLeftClose, PanelLeftOpen, Search, Settings, User } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useRef, useState, } from "react"; import { Breadcrumb, type BreadcrumbDataItem } from "@/components/motion/breadcrumb"; import { Drawer } from "@/components/motion/drawer"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/motion/dropdown-menu"; import { KbdCombo } from "@/components/motion/kbd"; import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease"; import { TOUCH_TARGET_CLASS } from "@/lib/touch"; import { cn } from "@/lib/utils"; export interface DashboardNavItem { id: string; label: string; icon: ReactNode; /** Small count or text after the label (a dot when collapsed). */ badge?: ReactNode; } export interface DashboardNavGroup { id: string; label?: string; items: DashboardNavItem[]; } export interface DashboardUserMenuItem { id: string; label: string; icon?: ReactNode; destructive?: boolean; onSelect?: () => void; } export interface DashboardShellProps { nav: DashboardNavGroup[]; /** Controlled active nav id. */ active?: string; defaultActive?: string; onActiveChange?: (id: string) => void; /** Controlled desktop collapse (icons only). */ collapsed?: boolean; defaultCollapsed?: boolean; onCollapsedChange?: (collapsed: boolean) => void; brand: { name: string; logo?: ReactNode }; user: { name: string; email?: string; avatar?: string }; /** Avatar menu rows. Defaults to Profile, Settings and Sign out. */ userMenu?: DashboardUserMenuItem[]; searchPlaceholder?: string; /** Fires on Enter in the search field. */ onSearch?: (query: string) => void; /** Also focus search on Mod+K anywhere on the page (default: only while focus is inside the shell). */ globalShortcut?: boolean; breadcrumbs?: BreadcrumbDataItem[]; title?: ReactNode; description?: ReactNode; /** Page-header actions (buttons). */ actions?: ReactNode; /** * Shell width in px below which the sidebar moves into a Drawer behind a * menu button. Measured on the shell itself (a CSS container query), not the * viewport. Default 768, which matches `md` when the shell spans the page. */ mobileBelow?: number; children?: ReactNode; className?: string; } function useControllable( value: T | undefined, defaultValue: T, onChange: ((next: T) => void) | undefined, ) { const [internal, setInternal] = useState(defaultValue); const controlled = value !== undefined; const current = controlled ? value : internal; const set = useCallback( (next: T) => { if (!controlled) setInternal(next); onChange?.(next); }, [controlled, onChange], ); return [current, set] as const; } const isShortcut = (e: { key: string; metaKey: boolean; ctrlKey: boolean }) => (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k"; const DEFAULT_MENU: DashboardUserMenuItem[] = [ { id: "profile", label: "Profile", icon: }, { id: "settings", label: "Settings", icon: }, { id: "sign-out", label: "Sign out", icon: , destructive: true }, ]; /** * Container-sized app shell: a sidebar that collapses to icons (labels fade, * tooltips take over, the active pill glides between rows), a top bar with a * Mod+K search field and avatar menu, and a content slot with breadcrumbs, * title and actions that cross-fades per page. When the shell itself is * narrower than `mobileBelow` (768px) the sidebar moves into a Drawer behind a * menu button. Fills its parent's box, unlike the viewport-height * `AnimatedSidebar` primitives. * * The switch is a CSS container query on the shell's own width, so it also * folds inside a docs frame or a split view. Tailwind variants are static and * the breakpoint is a prop, so the query is a small ` {/* Desktop sidebar. Width is animated on purpose: collapsing is a layout change — the content column has to reflow into the freed space. */}
{brand.logo ?? brand.name.slice(0, 1)} {brand.name}
{/* Mobile drawer */}
{brand.logo ?? brand.name.slice(0, 1)} {brand.name}
{/* Top bar */}
{user.name} {user.email ? ( {user.email} ) : null} {userMenu.map((item, i) => ( 0} /> ))}
{/* Content */}
{breadcrumbs || title || actions ? (
{breadcrumbs ? : null} {title ?

{title}

: null} {description ? (

{description}

) : null}
{actions ?
{actions}
: null}
) : null} {children}
); } function MenuRow({ item, separatorBefore, }: { item: DashboardUserMenuItem; separatorBefore?: boolean; }) { return ( <> {separatorBefore ? : null} item.onSelect?.()} > {item.label} ); } function FadeLabel({ show, children, className, }: { show: boolean; children: ReactNode; className?: string; }) { return ( {show ? ( {children} ) : null} ); } function NavList({ nav, active, collapsed, layoutGroup, onPick, }: { nav: DashboardNavGroup[]; active: string; collapsed: boolean; layoutGroup: string; onPick: (id: string) => void; }) { const reduce = useReducedMotion(); return ( ); }