"use client" import * as React from "react" import { ChevronRightIcon, MenuIcon, SearchIcon, XIcon } from "lucide-react" import { useIsMobile } from "@/hooks/use-is-mobile" import { Input } from "@/components/ui/input" import { Tooltip } from "@/components/ui/tooltip" import { cn } from "@/lib/utils" import { ControllableDetails } from "./controllable-details" import { useSidebar } from "./sidebar-context" export type SidebarItem = { key: string label: React.ReactNode icon?: React.ReactNode href?: string items?: SidebarItem[] active?: boolean disabled?: boolean badge?: React.ReactNode hidden?: boolean sectionLabel?: React.ReactNode defaultExpanded?: boolean expanded?: boolean onExpandedChange?: (expanded: boolean) => void current?: React.AriaAttributes["aria-current"] tooltip?: React.ReactNode onSelect?: () => void keywords?: string[] action?: React.ReactNode actionLabel?: string onAction?: () => void } export type SidebarSearch = { value?: string defaultValue?: string placeholder?: string label?: string onValueChange?: (value: string) => void empty?: React.ReactNode } export type SidebarFooterAccount = { label: React.ReactNode description?: React.ReactNode avatar?: React.ReactNode href?: string tooltip?: React.ReactNode onSelect?: () => void } export type SidebarProps = React.ComponentProps<"aside"> & { header?: React.ReactNode footer?: React.ReactNode items?: SidebarItem[] collapsed?: boolean defaultCollapsed?: boolean onCollapsedChange?: (collapsed: boolean) => void variant?: "sidebar" | "floating" | "inset" side?: "left" | "right" collapsible?: "icon" | "offcanvas" | "none" width?: React.CSSProperties["width"] collapsedWidth?: React.CSSProperties["width"] mobileWidth?: React.CSSProperties["width"] collapsedRail?: React.ReactNode railItems?: SidebarItem[] footerAccount?: SidebarFooterAccount secondaryActions?: SidebarItem[] footerSecondary?: React.ReactNode tooltipOnCollapsed?: boolean showSectionLabels?: boolean itemSize?: "sm" | "md" | "lg" activeIndicator?: "none" | "bar" | "pill" navigationLabel?: string search?: SidebarSearch | React.ReactNode hideScrollbar?: boolean keyboardShortcut?: string | false persistKey?: string responsive?: boolean mobileBreakpoint?: number mobileOpen?: boolean defaultMobileOpen?: boolean onMobileOpenChange?: (open: boolean) => void mobileTitle?: React.ReactNode mobileDescription?: React.ReactNode mobileToggleLabel?: string mobileCloseLabel?: string mobileToggleIcon?: React.ReactNode showMobileToggle?: boolean closeOnSelect?: boolean mobileToggleClassName?: string mobilePanelClassName?: string mobileOverlayClassName?: string footerClassName?: string renderMobileToggle?: (state: { open: boolean; setOpen: (open: boolean) => void }) => React.ReactNode onItemSelect?: (item: SidebarItem) => void renderItem?: (item: SidebarItem, state: { collapsed: boolean }) => React.ReactNode renderLink?: (props: React.ComponentProps<"a"> & { item: SidebarItem; [key: `data-${string}`]: string | boolean | undefined }) => React.ReactNode } const DEFAULT_SIDEBAR_BREAKPOINT = 1024 const DEFAULT_SIDEBAR_WIDTH = "18rem" const DEFAULT_COLLAPSED_SIDEBAR_WIDTH = "4.75rem" const DEFAULT_MOBILE_SIDEBAR_WIDTH = "min(88vw, 22rem)" function getSidebarInteractiveClassName({ active, disabled, }: { active?: boolean disabled?: boolean }) { return cn( "border border-transparent bg-transparent text-[color:color-mix(in_oklch,var(--sidebar-foreground),transparent_6%)] hover:border-[color:var(--aui-sidebar-item-active-border)] hover:bg-[color:var(--aui-sidebar-item-hover-bg)] hover:text-[color:var(--sidebar-foreground)] focus-visible:border-[color:var(--sidebar-ring)] focus-visible:bg-[color:var(--aui-sidebar-item-hover-bg)] focus-visible:text-[color:var(--sidebar-foreground)] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklch,var(--sidebar-ring),transparent_82%)]", active && "border-[color:var(--aui-sidebar-item-active-border)] bg-[color:var(--aui-sidebar-item-active-bg)] text-[color:var(--aui-sidebar-item-active-fg)] shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_10px_24px_color-mix(in_oklch,var(--sidebar-primary),transparent_88%)]", disabled && "hover:border-transparent hover:bg-transparent hover:text-[color:color-mix(in_oklch,var(--sidebar-foreground),transparent_6%)]" ) } function getSidebarItemLayoutClassName({ collapsed, depth, itemSize, activeIndicator, }: { collapsed: boolean depth: number itemSize: NonNullable activeIndicator: NonNullable }) { return cn( "relative w-full rounded-[min(var(--radius-xl),14px)] py-2", itemSize === "sm" && "min-h-8 text-xs", itemSize === "md" && "min-h-9 text-sm", itemSize === "lg" && "min-h-11 text-sm", collapsed ? "justify-center px-2" : "px-2.5", !collapsed && depth > 0 && "pl-3", activeIndicator === "bar" && "data-[active=true]:pl-4 before:pointer-events-none before:absolute before:left-1.5 before:top-1/2 before:hidden before:h-5 before:w-1 before:-translate-y-1/2 before:rounded-full before:bg-[color:var(--sidebar-primary)] data-[active=true]:before:block", activeIndicator === "pill" && "rounded-full", activeIndicator === "none" && "data-[active=true]:shadow-none" ) } function hasVisibleSidebarChildren(item: SidebarItem) { return item.items?.some((child) => !child.hidden) ?? false } function isSidebarItemActive(item: SidebarItem): boolean { if (item.active) return true return item.items?.some((child) => isSidebarItemActive(child)) ?? false } function triggerSidebarItem(item: SidebarItem, onItemSelect?: (item: SidebarItem) => void) { item.onSelect?.() onItemSelect?.(item) } function SidebarLeafItem({ item, collapsed, depth, itemSize, activeIndicator, onItemSelect, renderLink, }: { item: SidebarItem collapsed: boolean depth: number itemSize: NonNullable activeIndicator: NonNullable onItemSelect?: (item: SidebarItem) => void renderLink?: SidebarProps["renderLink"] }) { const currentValue: React.AriaAttributes["aria-current"] = item.current ?? (item.active ? "page" : undefined) const commonProps = { "aria-label": collapsed && typeof item.label === "string" ? item.label : undefined, "aria-current": currentValue, "aria-disabled": item.disabled || undefined, "data-slot": "sidebar-item" as const, "data-active": item.active || undefined, "data-disabled": item.disabled || undefined, "data-depth": String(depth), "data-size": itemSize, "data-active-indicator": activeIndicator, className: cn( "flex min-w-0 flex-1 items-center gap-2 border border-transparent text-left font-medium outline-none transition-[background-color,border-color,color,box-shadow] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50", getSidebarInteractiveClassName({ active: item.active, disabled: item.disabled }), getSidebarItemLayoutClassName({ collapsed, depth, itemSize, activeIndicator }) ), } const content = ( <> {item.icon && {item.icon}} {!collapsed && {item.label}} {!collapsed && item.badge && {item.badge}} ) const wrapCollapsedContent = (node: React.ReactNode) => collapsed ? ( {node} ) : ( node ) const wrapItemAction = (node: React.ReactNode) => { if (collapsed || !item.action) return node return (
{node}
) } if (item.href?.startsWith("/")) { if (renderLink) { return wrapCollapsedContent(wrapItemAction( <> {renderLink({ item, href: item.href, ...commonProps, onClick: (event) => { if (item.disabled) { event.preventDefault() return } triggerSidebarItem(item, onItemSelect) }, children: content, })} )) } return wrapCollapsedContent(wrapItemAction( { if (item.disabled) { event.preventDefault() return } triggerSidebarItem(item, onItemSelect) }} > {content} )) } if (item.href) { return wrapCollapsedContent(wrapItemAction( )) } return wrapCollapsedContent(wrapItemAction( )) } function SidebarTree({ items, collapsed, depth, showSectionLabels, itemSize, activeIndicator, onItemSelect, renderLink, }: { items: SidebarItem[] collapsed: boolean depth: number showSectionLabels: boolean itemSize: NonNullable activeIndicator: NonNullable onItemSelect?: (item: SidebarItem) => void renderLink?: SidebarProps["renderLink"] }) { return items.map((item) => { if (item.hidden) return null const hasChildren = hasVisibleSidebarChildren(item) const active = isSidebarItemActive(item) const showSectionLabel = showSectionLabels && !collapsed && depth === 0 && item.sectionLabel if (!hasChildren) { return ( {showSectionLabel ? (
{item.sectionLabel}
) : null}
) } const defaultExpanded = item.defaultExpanded ?? active return (
{showSectionLabel && (
{item.sectionLabel}
)} {item.icon ? ( collapsed ? ( {item.icon} ) : ( {item.icon} ) ) : null} {!collapsed && {item.label}} {!collapsed && item.badge && {item.badge}} {!collapsed && ( )}
) }) } function filterSidebarItems(items: SidebarItem[], query: string): SidebarItem[] { const normalized = query.trim().toLocaleLowerCase() if (!normalized) return items return items.flatMap((item) => { const children = item.items ? filterSidebarItems(item.items, normalized) : [] const label = typeof item.label === "string" ? item.label : "" const haystack = [label, item.sectionLabel, ...(item.keywords ?? [])] .filter((value): value is string => typeof value === "string") .join(" ") .toLocaleLowerCase() if (!haystack.includes(normalized) && children.length === 0) return [] return [{ ...item, items: children.length ? children : item.items, defaultExpanded: children.length ? true : item.defaultExpanded }] }) } function SidebarActionButton({ item, collapsed, onItemSelect, }: { item: SidebarItem collapsed: boolean onItemSelect?: (item: SidebarItem) => void }) { const content = ( ) return collapsed ? ( {content} ) : ( content ) } function SidebarFooterAccount({ account, collapsed, onAfterSelect, }: { account: SidebarFooterAccount collapsed: boolean onAfterSelect?: () => void }) { const body = ( ) return collapsed ? ( {body} ) : ( body ) } function SidebarSurface({ className, style, header, footer, items = [], collapsed = false, width = DEFAULT_SIDEBAR_WIDTH, collapsedWidth = DEFAULT_COLLAPSED_SIDEBAR_WIDTH, collapsedRail, railItems = [], footerAccount, secondaryActions = [], footerSecondary, footerClassName, tooltipOnCollapsed, showSectionLabels = true, itemSize = "md", activeIndicator = "bar", navigationLabel = "Primary navigation", search, hideScrollbar = true, variant = "sidebar", side = "left", collapsible = "icon", onItemSelect, renderItem, renderLink, children, mobile, mobileTitle, mobileDescription, mobileCloseLabel = "Close navigation", onRequestClose, closeOnSelect = true, ...props }: Omit & { mobile?: boolean onRequestClose?: () => void }) { const searchConfig = search && !React.isValidElement(search) && typeof search === "object" ? search as SidebarSearch : undefined const [internalSearch, setInternalSearch] = React.useState(searchConfig?.defaultValue ?? "") const searchValue = searchConfig?.value ?? internalSearch const visibleItems = filterSidebarItems(items.filter((item) => !item.hidden), searchValue) const visibleRailItems = railItems.filter((item) => !item.hidden) const visibleSecondaryActions = secondaryActions.filter((item) => !item.hidden) const handleSelect = React.useCallback((item: SidebarItem) => { onItemSelect?.(item) if (mobile && closeOnSelect) onRequestClose?.() }, [closeOnSelect, mobile, onItemSelect, onRequestClose]) const showMobileHeader = mobile && (mobileTitle || mobileDescription || onRequestClose) return ( ) } function Sidebar({ className, header, footer, items = [], collapsed: collapsedProp, defaultCollapsed = false, onCollapsedChange, variant = "sidebar", side = "left", collapsible = "icon", width = DEFAULT_SIDEBAR_WIDTH, collapsedWidth = DEFAULT_COLLAPSED_SIDEBAR_WIDTH, mobileWidth = DEFAULT_MOBILE_SIDEBAR_WIDTH, collapsedRail, railItems = [], footerAccount, secondaryActions = [], footerSecondary, tooltipOnCollapsed = true, showSectionLabels = true, itemSize = "md", activeIndicator = "bar", navigationLabel = "Primary navigation", search, hideScrollbar = true, keyboardShortcut = "b", persistKey, responsive = true, mobileBreakpoint = DEFAULT_SIDEBAR_BREAKPOINT, mobileOpen: mobileOpenProp, defaultMobileOpen = false, onMobileOpenChange, mobileTitle, mobileDescription, mobileToggleLabel = "Open navigation", mobileCloseLabel = "Close navigation", mobileToggleIcon, showMobileToggle = true, closeOnSelect = true, mobileToggleClassName, mobilePanelClassName, mobileOverlayClassName, renderMobileToggle, onItemSelect, renderItem, renderLink, children, ...props }: SidebarProps) { const sidebarContext = useSidebar(true) const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed) const collapsed = collapsible === "none" ? false : collapsedProp ?? sidebarContext?.collapsed ?? internalCollapsed const matchesMobileBreakpoint = useIsMobile(mobileBreakpoint) const isMobile = responsive && matchesMobileBreakpoint const [uncontrolledMobileOpen, setUncontrolledMobileOpen] = React.useState(defaultMobileOpen) const mobileOpen = mobileOpenProp ?? sidebarContext?.mobileOpen ?? uncontrolledMobileOpen const setCollapsed = React.useCallback((nextCollapsed: boolean) => { if (collapsible === "none") return if (collapsedProp === undefined && sidebarContext) sidebarContext.setCollapsed(nextCollapsed) else if (collapsedProp === undefined) setInternalCollapsed(nextCollapsed) onCollapsedChange?.(nextCollapsed) if (persistKey) window.localStorage.setItem(persistKey, String(nextCollapsed)) }, [collapsedProp, collapsible, onCollapsedChange, persistKey, sidebarContext]) React.useEffect(() => { if (!persistKey || collapsedProp !== undefined) return const stored = window.localStorage.getItem(persistKey) if (stored === "true" || stored === "false") setCollapsed(stored === "true") }, [collapsedProp, persistKey, setCollapsed]) React.useEffect(() => { if (!keyboardShortcut || collapsible === "none") return const handleShortcut = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === keyboardShortcut.toLocaleLowerCase()) { event.preventDefault() setCollapsed(!collapsed) } } window.addEventListener("keydown", handleShortcut) return () => window.removeEventListener("keydown", handleShortcut) }, [collapsed, collapsible, keyboardShortcut, setCollapsed]) const setMobileOpen = React.useCallback((nextOpen: boolean) => { if (mobileOpenProp == null && sidebarContext) { sidebarContext.setMobileOpen(nextOpen) } else if (mobileOpenProp == null) { setUncontrolledMobileOpen(nextOpen) } onMobileOpenChange?.(nextOpen) }, [mobileOpenProp, onMobileOpenChange, sidebarContext]) React.useEffect(() => { if (!isMobile && mobileOpen) { setMobileOpen(false) } }, [isMobile, mobileOpen, setMobileOpen]) React.useEffect(() => { if (!isMobile || !mobileOpen) return const previousOverflow = document.body.style.overflow document.body.style.overflow = "hidden" const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setMobileOpen(false) } } window.addEventListener("keydown", handleKeyDown) return () => { document.body.style.overflow = previousOverflow window.removeEventListener("keydown", handleKeyDown) } }, [isMobile, mobileOpen, setMobileOpen]) const baseProps = { className, header, footer, items, collapsed, width, collapsedWidth, collapsedRail, railItems, footerAccount, secondaryActions, footerSecondary, tooltipOnCollapsed, showSectionLabels, itemSize, activeIndicator, navigationLabel, search, hideScrollbar, variant, side, collapsible, mobileCloseLabel, closeOnSelect, onItemSelect, renderItem, renderLink, children, ...props, } if (!responsive || !isMobile) { return } const defaultTrigger = ( ) return ( <> {showMobileToggle ? renderMobileToggle ? renderMobileToggle({ open: mobileOpen, setOpen: setMobileOpen }) : defaultTrigger : null}
) } function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { return (
) } export { Sidebar, SidebarInset }