28 lines
22 KiB
JSON
28 lines
22 KiB
JSON
{
|
|
"$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: <IconHome size={18} /> },\n { key: \"invite\", label: \"Invite users\", icon: <IconUserAdd size={18} />, 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 <GlideMenu\n rowSelector=\"[data-row]\"\n highlightClassName=\"sidebar-glide-highlight rounded-[7px] bg-hover-2\"\n className=\"group/glide flex flex-col gap-px\"\n >\n {children}\n </GlideMenu>\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 <button\n data-row\n type=\"button\"\n onClick={onClick}\n className={`sidebar-row relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left\n transition-[width,background-color,color,transform] duration-150 active:scale-[0.98]\n ${active ? \"bg-hover-2 group-hover/glide:bg-transparent\" : \"\"}`}\n >\n <span className={`flex size-5 shrink-0 items-center justify-center ${active ? \"text-ink\" : \"text-ink-2\"}`}>\n {icon}\n </span>\n <span className={`sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium ${active ? \"text-ink\" : \"text-ink-2\"}`}>\n {label}\n </span>\n {count && (\n <span className=\"sidebar-copy mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3\">\n {count}\n </span>\n )}\n </button>\n );\n}\n\nfunction WorkspaceMenu({\n position,\n onClose,\n}: {\n position: { top: number; left: number };\n onClose: () => void;\n}) {\n return createPortal(\n <div\n data-workspace-menu\n className=\"fixed z-50 w-64 rounded-[14px] bg-surface p-1.5 shadow-overlay\"\n style={{\n top: position.top,\n left: position.left,\n animation: \"pop-in 180ms cubic-bezier(0.23,1,0.32,1) both\",\n transformOrigin: \"top left\",\n }}\n >\n <GlideMenu className=\"flex flex-col gap-px\" highlightClassName=\"inset-x-0 rounded-[8px] bg-hover-2\">\n <button\n data-menu-row\n type=\"button\"\n onClick={onClose}\n className=\"relative z-10 flex h-10 w-full items-center gap-1.5 rounded-[8px] px-2 text-left\"\n >\n <span className=\"flex size-6 shrink-0 items-center justify-center rounded-[7px] bg-ink text-[11px] font-semibold text-surface\">\n {WORKSPACE.monogram}\n </span>\n <span className=\"min-w-0 flex-1 truncate text-[13.5px] font-medium text-ink\">{WORKSPACE.name}</span>\n <span className=\"shrink-0 text-ink\"><IconCheckmark1Small size={18} /></span>\n </button>\n <div className=\"my-1 h-px bg-line\" />\n {[\n { label: \"New workspace\", icon: <IconPlusMedium size={16} /> },\n { label: \"Workspace settings\", icon: <IconSettingsGear1 size={16} /> },\n { label: \"Invite team members\", icon: <IconUserAdd size={16} /> },\n ].map((item) => (\n <button\n key={item.label}\n data-menu-row\n type=\"button\"\n onClick={onClose}\n className=\"relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left\"\n >\n <span className=\"flex size-5 shrink-0 items-center justify-center text-ink-2\">{item.icon}</span>\n <span className=\"min-w-0 flex-1 truncate text-[13.5px] text-ink\">{item.label}</span>\n </button>\n ))}\n <div className=\"my-1 h-px bg-line\" />\n <button\n data-menu-row\n type=\"button\"\n onClick={onClose}\n className=\"relative z-10 flex h-9 w-full items-center gap-1.5 rounded-[8px] px-2 text-left\"\n >\n <span className=\"flex size-5 shrink-0 items-center justify-center text-ink-2\"><IconArrowBoxLeft size={16} /></span>\n <span className=\"min-w-0 flex-1 truncate text-[13.5px] text-ink\">Sign out</span>\n </button>\n </GlideMenu>\n </div>,\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<string | null>(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<HTMLButtonElement>(null);\n const searchRef = useRef<HTMLInputElement>(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 <aside\n data-sidebar-collapsed={collapsed}\n aria-label=\"Workspace navigation\"\n className={`relative flex shrink-0 overflow-hidden transition-[width] ${fill ? \"h-full\" : \"h-[600px]\"} ${className}`}\n style={{\n width: collapsed ? SIDEBAR_MOTION.collapsedWidth : SIDEBAR_MOTION.expandedWidth,\n transitionDuration: `${SIDEBAR_MOTION.duration}ms`,\n transitionTimingFunction: SIDEBAR_MOTION.easing,\n \"--sidebar-copy-duration\": `${SIDEBAR_MOTION.copyDuration}ms`,\n \"--sidebar-copy-offset\": `${SIDEBAR_MOTION.copyOffset}px`,\n \"--sidebar-easing\": SIDEBAR_MOTION.easing,\n } as CSSProperties}\n >\n <div className=\"flex min-h-0 w-[224px] shrink-0 flex-col\">\n <div className=\"relative mb-2.5 h-10 shrink-0\">\n <button\n ref={workspaceButtonRef}\n data-workspace-trigger\n type=\"button\"\n aria-expanded={workspaceOpen}\n aria-hidden={collapsed}\n tabIndex={collapsed ? -1 : 0}\n onClick={() => {\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 <span className=\"sidebar-logo flex size-5 shrink-0 items-center justify-center text-ink\">\n <IconPopsicle2 size={18} />\n </span>\n <span className=\"sidebar-copy ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2\">\n {WORKSPACE.name}\n </span>\n <span className=\"sidebar-copy ml-1 flex shrink-0 text-ink-3\">\n <IconChevronDownSmall size={16} />\n </span>\n </button>\n\n {workspaceOpen && <WorkspaceMenu position={workspacePosition} onClose={() => setWorkspaceOpen(false)} />}\n\n <button\n type=\"button\"\n aria-label=\"Collapse sidebar\"\n aria-hidden={collapsed}\n tabIndex={collapsed ? -1 : 0}\n onClick={collapse}\n className=\"sidebar-collapse-control absolute right-2 top-1 flex size-8 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 <IconSidebarLeftArrow size={18} />\n </button>\n <button\n type=\"button\"\n aria-label=\"Expand sidebar\"\n aria-hidden={!collapsed}\n tabIndex={collapsed ? 0 : -1}\n onClick={() => 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 <IconSidebarLeftArrow size={18} className=\"rotate-180\" />\n </button>\n </div>\n\n <GlideGroup>\n <RailButton\n icon={<IconEditBig size={18} />}\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 <RailButton\n key={item.key}\n icon={item.icon}\n label={item.label}\n count={item.count}\n active={currentNav === item.key}\n onClick={() => selectNav(item.key)}\n />\n ))}\n </GlideGroup>\n\n <div className=\"mt-3 min-h-0 flex-1 overflow-y-auto\">\n <div className=\"sidebar-copy relative mx-2 mb-1 h-8\">\n <div\n aria-hidden={searchOpen}\n className={`absolute inset-0 flex items-center gap-1.5 px-2 text-[12.5px] font-medium text-ink-3 transition-[opacity,transform] ${searchOpen ? \"pointer-events-none -translate-x-1 opacity-0\" : \"translate-x-0 opacity-100\"}`}\n style={{ transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`, transitionTimingFunction: CHAT_SEARCH_MOTION.easing }}\n >\n <IconChevronDownSmall size={16} />\n <span>Chats</span>\n </div>\n\n <button\n type=\"button\"\n aria-label=\"Search chats\"\n aria-expanded={searchOpen}\n onClick={() => 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 <IconMagnifyingGlass size={16} />\n </button>\n\n <div\n className={`absolute right-0 top-0 z-20 flex h-8 items-center overflow-hidden rounded-[8px] bg-field text-ink-3 shadow-hairline transition-[width,opacity] focus-within:text-ink-2 ${searchOpen ? \"pointer-events-auto opacity-100\" : \"pointer-events-none opacity-0\"}`}\n style={{\n width: searchOpen ? \"100%\" : CHAT_SEARCH_MOTION.closedWidth,\n transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,\n transitionTimingFunction: CHAT_SEARCH_MOTION.easing,\n }}\n >\n <span className=\"ml-2 flex shrink-0 items-center justify-center\">\n <IconMagnifyingGlass size={15} />\n </span>\n <input\n ref={searchRef}\n value={query}\n onChange={(event) => 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 <button\n type=\"button\"\n aria-label=\"Close chat search\"\n onClick={() => {\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 <IconCrossSmall size={16} />\n </button>\n </div>\n </div>\n\n <GlideGroup>\n {visibleRecents.map((item) => {\n const active = item.label === selectedTitle;\n return (\n <button\n key={item.id}\n data-row\n type=\"button\"\n title={item.label}\n onClick={() => {\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 <span className={`sidebar-copy min-w-0 flex-1 truncate text-[14px] font-medium ${active ? \"text-ink\" : \"text-ink-2\"}`}>\n {item.label}\n </span>\n </button>\n );\n })}\n {query && visibleRecents.length === 0 && (\n <div className=\"sidebar-copy mx-2 px-2 py-2 text-[12.5px] text-ink-3\">No chats found</div>\n )}\n </GlideGroup>\n </div>\n\n <div className=\"sidebar-copy mx-2 mt-3 w-[208px] border-t border-line pt-3\">\n <button\n type=\"button\"\n onClick={onFooterClick ?? onNewChat}\n className=\"flex h-8 w-full items-center justify-center gap-1.5 rounded-control bg-hover-2 text-[12.5px] font-medium text-ink transition-[background-color,transform] duration-150 hover:bg-line-strong active:scale-[0.98]\"\n >\n {footerIcon}\n {footerLabel}\n </button>\n </div>\n </div>\n </aside>\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"
|
|
}
|
|
]
|
|
} |