{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sidebar-nav","title":"Sidebar Nav","description":"Collapsible workspace and chat navigation with gliding hover states.","dependencies":["lucide-react@1.47.0"],"registryDependencies":["https://ward.so/r/foundation.json","https://ward.so/r/glide-menu.json"],"files":[{"path":"registry/ward/SidebarNav.tsx","content":"\"use client\";\n/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n\nimport \"./styles/sidebar-nav.css\";\nimport \"./styles/foundation.css\";\nimport { PanelLeftClose, Check, ChevronDown, X, Pencil, House, Search, Plus, Sparkles, Settings, PanelLeftOpen, UserRoundPlus } from \"lucide-react\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport GlideMenu from \"./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: \"studio\", name: \"Studio Ops\", monogram: \"C\" };\n\nconst NAV_ITEMS = [\n  { key: \"home\", label: \"Home\", icon: <House size={18} /> },\n  { key: \"invite\", label: \"Invite users\", icon: <UserRoundPlus 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: \"project\", label: \"Project 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 project edits\" },\n  { id: \"background\", label: \"Background 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-card 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-card\">\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\"><Check size={18} /></span>\n        </button>\n        <div className=\"my-1 h-px bg-line\" />\n        {[\n          { label: \"New workspace\", icon: <Plus size={16} /> },\n          { label: \"Workspace settings\", icon: <Settings size={16} /> },\n          { label: \"Invite team members\", icon: <UserRoundPlus 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\"><PanelLeftClose 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              <Sparkles 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              <ChevronDown 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            <PanelLeftOpen 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            <PanelLeftOpen size={18} className=\"rotate-180\" />\n          </button>\n        </div>\n\n        <GlideGroup>\n          <RailButton\n            icon={<Pencil 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              <ChevronDown 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              <Search 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                <Search 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                <X 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/ward/SidebarNav.tsx"},{"path":"registry/ward/styles/sidebar-nav.css","content":"/*\n * MIT License\n *\n * Copyright (c) 2026 Shane Levine\n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to deal\n * in the Software without restriction, including without limitation the rights\n * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n * copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in all\n * copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n * SOFTWARE.\n */\n/* ══════════════════════════════════════════════════════════\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":"components/ward/styles/sidebar-nav.css"}],"meta":{"variants":["Default"],"version":"1.1.0","source":"https://github.com/slev12397/beautiful-ui/blob/44a274e598395ab61e7c96c26fda2758780253b7/components/primitives/SidebarNav.tsx","access":"free"},"categories":["shells"],"type":"registry:component"}