{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "sidebar-nav", "type": "registry:component", "title": "Sidebar Nav", "description": "Collapsible workspace and chat navigation with gliding hover states. Then add `@import \"./beautifui/sidebar-nav.css\";` to your app's globals.css.", "dependencies": [ "@central-icons-react/round-outlined-radius-2-stroke-2" ], "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json", "https://www.beautifului.dev/r/glide-menu.json" ], "files": [ { "path": "components/primitives/SidebarNav.tsx", "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { IconArrowBoxLeft } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconArrowBoxLeft\";\nimport { IconCheckmark1Small } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconCheckmark1Small\";\nimport { IconChevronDownSmall } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconChevronDownSmall\";\nimport { IconCrossSmall } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconCrossSmall\";\nimport { IconEditBig } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconEditBig\";\nimport { IconHome } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconHome\";\nimport { IconMagnifyingGlass } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconMagnifyingGlass\";\nimport { IconPlusMedium } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconPlusMedium\";\nimport { IconPopsicle2 } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconPopsicle2\";\nimport { IconSettingsGear1 } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconSettingsGear1\";\nimport { IconSidebarLeftArrow } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconSidebarLeftArrow\";\nimport { IconUserAdd } from \"@central-icons-react/round-outlined-radius-2-stroke-2/IconUserAdd\";\nimport GlideMenu from \"@/components/primitives/GlideMenu\";\n\n/* ─────────────────────────────────────────────────────────\n * SIDEBAR NAV\n * Shared by the design-system preview and the harness shell:\n * compact workspace switcher, primary navigation, searchable\n * chat history, and a collapse that preserves icon alignment.\n * ───────────────────────────────────────────────────────── */\n\nconst WORKSPACE = { key: \"creamery\", name: \"Creamery Ops\", monogram: \"C\" };\n\nconst NAV_ITEMS = [\n { key: \"home\", label: \"Home\", icon: },\n { key: \"invite\", label: \"Invite users\", icon: , count: \"3/10\" },\n];\n\nexport type SidebarRecent = {\n id: string;\n label: string;\n prompt?: string;\n};\n\nconst DEFAULT_RECENTS: SidebarRecent[] = [\n { id: \"suppliers\", label: \"Supplier records\" },\n { id: \"todos\", label: \"Urgent to-dos this morning\" },\n { id: \"flavor\", label: \"Flavor page ticket\" },\n { id: \"workload\", label: \"Workload summary\" },\n { id: \"offboarding\", label: \"Off-board a supplier\" },\n { id: \"restock\", label: \"Batch restock function\" },\n { id: \"edits\", label: \"Propose flavor edits\" },\n { id: \"subway\", label: \"Subway surfing\" },\n];\n\ntype SidebarNavProps = {\n activeTitle?: string | null;\n className?: string;\n fill?: boolean;\n onNewChat?: () => void;\n onPick?: (id: string, label: string, prompt?: string) => void;\n /** controlled primary-nav selection (e.g. \"home\" | \"invite\") */\n activeNav?: string;\n onNavigate?: (key: string) => void;\n /** footer call-to-action — defaults to the demo \"Upgrade\" button */\n footerLabel?: string;\n footerIcon?: ReactNode;\n onFooterClick?: () => void;\n recents?: SidebarRecent[];\n variant?: string;\n};\n\nconst SIDEBAR_MOTION = {\n expandedWidth: 224,\n collapsedWidth: 52,\n duration: 280,\n copyDuration: 180,\n copyOffset: 8,\n easing: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n};\n\n/* ─────────────────────────────────────────────────────────\n * CHAT SEARCH STORYBOARD\n *\n * 0ms search is triggered; Chats label begins fading\n * 0ms field grows right → left from the search control\n * 180ms field fills the row; cursor is focused and ready\n * ───────────────────────────────────────────────────────── */\nconst CHAT_SEARCH_MOTION = {\n duration: 180,\n closedWidth: 28,\n easing: \"cubic-bezier(0.16, 1, 0.3, 1)\",\n};\n\nfunction GlideGroup({ children }: { children: ReactNode }) {\n return (\n \n {children}\n \n );\n}\n\nfunction RailButton({\n icon,\n label,\n active = false,\n count,\n onClick,\n}: {\n icon: ReactNode;\n label: string;\n active?: boolean;\n count?: string;\n onClick?: () => void;\n}) {\n return (\n \n \n {icon}\n \n \n {label}\n \n {count && (\n \n {count}\n \n )}\n \n );\n}\n\nfunction WorkspaceMenu({\n position,\n onClose,\n}: {\n position: { top: number; left: number };\n onClose: () => void;\n}) {\n return createPortal(\n \n \n \n \n {WORKSPACE.monogram}\n \n {WORKSPACE.name}\n \n \n
\n {[\n { label: \"New workspace\", icon: },\n { label: \"Workspace settings\", icon: },\n { label: \"Invite team members\", icon: },\n ].map((item) => (\n \n {item.icon}\n {item.label}\n \n ))}\n
\n \n \n Sign out\n \n \n
,\n document.body,\n );\n}\n\nexport default function SidebarNav({\n activeTitle,\n className = \"\",\n fill = false,\n onNewChat,\n onPick,\n activeNav,\n onNavigate,\n footerLabel = \"Upgrade\",\n footerIcon,\n onFooterClick,\n recents = DEFAULT_RECENTS,\n}: SidebarNavProps) {\n const [collapsed, setCollapsed] = useState(false);\n const [internalNav, setInternalNav] = useState(\"chats\");\n const currentNav = activeNav ?? internalNav;\n const selectNav = (key: string) => {\n setInternalNav(key);\n onNavigate?.(key);\n };\n const [demoActiveTitle, setDemoActiveTitle] = useState(null);\n const [workspaceOpen, setWorkspaceOpen] = useState(false);\n const [workspacePosition, setWorkspacePosition] = useState({ top: 0, left: 0 });\n const [searchOpen, setSearchOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const workspaceButtonRef = useRef(null);\n const searchRef = useRef(null);\n\n const selectedTitle = activeTitle === undefined ? demoActiveTitle : activeTitle;\n const visibleRecents = recents.filter((item) => item.label.toLowerCase().includes(query.trim().toLowerCase()));\n\n useEffect(() => {\n if (!workspaceOpen) return;\n const close = (event: PointerEvent) => {\n const target = event.target as Element;\n if (!target.closest(\"[data-workspace-trigger]\") && !target.closest(\"[data-workspace-menu]\")) {\n setWorkspaceOpen(false);\n }\n };\n document.addEventListener(\"pointerdown\", close);\n return () => document.removeEventListener(\"pointerdown\", close);\n }, [workspaceOpen]);\n\n useEffect(() => {\n if (searchOpen) searchRef.current?.focus();\n }, [searchOpen]);\n\n const collapse = () => {\n setCollapsed(true);\n setWorkspaceOpen(false);\n setSearchOpen(false);\n setQuery(\"\");\n };\n\n return (\n \n
\n
\n {\n if (!workspaceOpen && workspaceButtonRef.current) {\n const rect = workspaceButtonRef.current.getBoundingClientRect();\n setWorkspacePosition({ top: rect.bottom + 6, left: rect.left });\n }\n setWorkspaceOpen((open) => !open);\n }}\n className=\"sidebar-workspace-control absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-[8px] px-2 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99]\"\n >\n \n \n \n \n {WORKSPACE.name}\n \n \n \n \n \n\n {workspaceOpen && setWorkspaceOpen(false)} />}\n\n \n \n \n setCollapsed(false)}\n className=\"sidebar-expand-control absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color] duration-150 hover:bg-hover-2 hover:text-ink\"\n >\n \n \n
\n\n \n }\n label=\"New chat\"\n onClick={() => {\n if (activeTitle === undefined) setDemoActiveTitle(null);\n selectNav(\"chats\");\n onNewChat?.();\n }}\n />\n {NAV_ITEMS.map((item) => (\n selectNav(item.key)}\n />\n ))}\n \n\n
\n
\n \n \n Chats\n
\n\n setSearchOpen(true)}\n className={`absolute right-0 top-0 z-10 flex size-8 items-center justify-center rounded-[8px] text-ink-3 transition-[opacity,background-color,color,transform] hover:bg-hover-2 hover:text-ink active:scale-[0.96] ${searchOpen ? \"pointer-events-none opacity-0\" : \"opacity-100\"}`}\n style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms` }}\n >\n \n \n\n \n \n \n \n setQuery(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Escape\") {\n setSearchOpen(false);\n setQuery(\"\");\n }\n }}\n placeholder=\"Search chats\"\n aria-label=\"Search chat history\"\n className=\"ml-1.5 min-w-0 flex-1 bg-transparent text-[13px] font-medium text-ink outline-none placeholder:text-ink-3\"\n />\n {\n setSearchOpen(false);\n setQuery(\"\");\n }}\n className=\"flex size-8 shrink-0 items-center justify-center rounded-[8px] text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink active:scale-[0.96]\"\n >\n \n \n
\n
\n\n \n {visibleRecents.map((item) => {\n const active = item.label === selectedTitle;\n return (\n {\n selectNav(\"chats\");\n if (activeTitle === undefined) setDemoActiveTitle(item.label);\n onPick?.(item.id, item.label, item.prompt);\n }}\n className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] duration-150 active:scale-[0.98] ${\n active ? \"bg-hover-2 group-hover/glide:bg-transparent\" : \"\"\n }`}\n >\n \n {item.label}\n \n \n );\n })}\n {query && visibleRecents.length === 0 && (\n
No chats found
\n )}\n
\n
\n\n
\n \n {footerIcon}\n {footerLabel}\n \n
\n \n \n );\n}\n", "type": "registry:component", "target": "components/primitives/SidebarNav.tsx" }, { "path": "app/beautifui/sidebar-nav.css", "content": "/* ══════════════════════════════════════════════════════════\n * COMPONENT-SPECIFIC — SidebarNav\n * The 52↔224px collapsible sidebar tree.\n * Copy this block only alongside SidebarNav; it is not part of the\n * generic foundation above.\n * ══════════════════════════════════════════════════════════ */\n.sidebar-row {\n width: 208px;\n transition-duration: var(--sidebar-copy-duration);\n transition-timing-function: var(--sidebar-easing);\n}\n\n.sidebar-copy {\n opacity: 1;\n transform: translateX(0);\n transition:\n opacity var(--sidebar-copy-duration) ease-out,\n transform var(--sidebar-copy-duration) var(--sidebar-easing);\n}\n\n.sidebar-logo,\n.sidebar-collapse-control,\n.sidebar-expand-control {\n transition-duration: var(--sidebar-copy-duration);\n transition-timing-function: ease-out;\n}\n\n.sidebar-expand-control {\n pointer-events: none;\n opacity: 0;\n}\n\n.sidebar-glide-highlight {\n right: 8px;\n left: 8px;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-row {\n width: 36px;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-copy {\n pointer-events: none;\n opacity: 0;\n transform: translateX(calc(var(--sidebar-copy-offset) * -1));\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-workspace-control {\n pointer-events: none;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-logo,\n[data-sidebar-collapsed=\"true\"] .sidebar-collapse-control {\n pointer-events: none;\n opacity: 0;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-expand-control {\n pointer-events: auto;\n opacity: 1;\n}\n\n[data-sidebar-collapsed=\"true\"] .sidebar-glide-highlight {\n right: auto;\n width: 36px;\n}\n\n@media (prefers-reduced-motion: reduce) {\n .home-reveal {\n opacity: 1 !important;\n transform: none !important;\n filter: none !important;\n }\n}\n\n/* ── records table ────────────────────────────────────────\n * The grid is deliberately more architectural than card-like:\n * one quiet surface, precise rules, and color reserved for\n * meaning. The first column remains an anchor while both axes\n * can move independently inside the table viewport.\n */\n", "type": "registry:file", "target": "app/beautifui/sidebar-nav.css" } ] }