{
  "$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.",
  "categories": [
    "ai-interface"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/glide-menu.json"
  ],
  "files": [
    {
      "path": "src/registry/components/sidebar-nav/sidebar-nav.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  Check,\n  ChevronDown,\n  House,\n  LogOut,\n  Newspaper,\n  PanelLeft,\n  Plus,\n  Search,\n  Settings,\n  SquarePen,\n  UserPlus,\n  X,\n} from \"lucide-react\";\nimport { cn } from \"../lib/utils\";\nimport GlideMenu from \"../glide-menu/glide-menu\";\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\n/* an ErmisAI newsroom */\nconst WORKSPACE = { key: \"aegean-evening-desk\", name: \"Aegean evening desk\", monogram: \"A\" };\n\nconst NAV_ITEMS = [\n  { key: \"home\", label: \"Home\", icon: <House size={18} /> },\n  { key: \"invite\", label: \"Invite members\", icon: <UserPlus size={18} />, count: \"3/5\" },\n];\n\nexport type SidebarRecent = {\n  id: string;\n  label: string;\n  prompt?: string;\n};\n\nconst DEFAULT_RECENTS: SidebarRecent[] = [\n  { id: \"bond-yields\", label: \"Greek bond yields conflicts\" },\n  { id: \"queue\", label: \"Review queue this morning\" },\n  { id: \"ferry\", label: \"Ferry strike quick brief\" },\n  { id: \"workload\", label: \"Desk workload summary\" },\n  { id: \"source\", label: \"Retire a stale source\" },\n  { id: \"digest\", label: \"Friday digest block\" },\n  { id: \"ecb\", label: \"Localize the ECB draft\" },\n  { id: \"corridor\", label: \"Black Sea corridor sources\" },\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 * ───────────────────────────────────────────────────────── */\n/* The sidebar is one persistent 224px tree clipped by a 52px shell; every rail glyph stays centred at x=26 in both\n * states. Collapsing (data-sidebar-collapsed on the aside, read through group/sidebar) narrows rows to 36px and fades\n * the copy out with a small leftward shift, on the copy duration set inline on the aside. */\nconst COLLAPSIBLE_ROW =\n  \"w-[208px] duration-(--sidebar-copy-duration) ease-(--sidebar-easing) group-data-[sidebar-collapsed=true]/sidebar:w-9\";\nconst COPY =\n  \"translate-x-0 opacity-100 transition-[opacity,translate] duration-(--sidebar-copy-duration) [transition-timing-function:ease-out,var(--sidebar-easing)] group-data-[sidebar-collapsed=true]/sidebar:pointer-events-none group-data-[sidebar-collapsed=true]/sidebar:translate-x-[calc(var(--sidebar-copy-offset)*-1)] group-data-[sidebar-collapsed=true]/sidebar:opacity-0\";\n/* header controls fade on the copy duration; the open-state ones hide when collapsed, the expand control shows */\nconst HIDE_COLLAPSED =\n  \"duration-(--sidebar-copy-duration) ease-out group-data-[sidebar-collapsed=true]/sidebar:pointer-events-none group-data-[sidebar-collapsed=true]/sidebar:opacity-0\";\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=\"right-2 left-2 rounded-[7px] bg-hover-2 group-data-[sidebar-collapsed=true]/sidebar:right-auto group-data-[sidebar-collapsed=true]/sidebar:w-9\"\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={cn(\n        \"relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] active:scale-[0.98]\",\n        COLLAPSIBLE_ROW,\n        active && \"bg-hover-2 group-hover/glide:bg-transparent\",\n      )}\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\n        className={cn(\n          \"ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium\",\n          COPY,\n          active ? \"text-ink\" : \"text-ink-2\",\n        )}\n      >\n        {label}\n      </span>\n      {count && (\n        <span className={cn(\"mr-2 shrink-0 text-[12px] font-medium tabular-nums text-ink-3\", COPY)}>{count}</span>\n      )}\n    </button>\n  );\n}\n\nfunction WorkspaceMenu({ position, onClose }: { position: { top: number; left: number }; onClose: () => void }) {\n  return createPortal(\n    <div\n      data-workspace-menu\n      className=\"fixed z-50 w-64 origin-top-left animate-sidebar-nav-menu-in rounded-[14px] bg-surface p-1.5 shadow-overlay\"\n      style={{ top: position.top, left: position.left }}\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\">\n            <Check size={18} />\n          </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 members\", icon: <UserPlus 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\">\n            <LogOut size={16} />\n          </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={`group/sidebar relative flex shrink-0 overflow-hidden transition-[width] ${fill ? \"h-full\" : \"h-[600px]\"} ${className}`}\n      style={\n        {\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    >\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=\"absolute left-2 top-1 flex h-8 w-[164px] items-center rounded-button px-2.5 text-left transition-[background-color,transform] duration-100 hover:bg-hover-2 active:scale-[0.99] group-data-[sidebar-collapsed=true]/sidebar:pointer-events-none\"\n          >\n            <span className={cn(\"flex size-5 shrink-0 items-center justify-center text-ink\", HIDE_COLLAPSED)}>\n              <Newspaper size={18} />\n            </span>\n            <span className={cn(\"ml-1.5 min-w-0 flex-1 truncate text-[14px] font-medium text-ink-2\", COPY)}>\n              {WORKSPACE.name}\n            </span>\n            <span className={cn(\"ml-1 flex shrink-0 text-ink-3\", COPY)}>\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={cn(\n              \"absolute right-2 top-1 flex size-8 items-center justify-center rounded-button text-ink-3 transition-[opacity,background-color,color] hover:bg-hover-2 hover:text-ink\",\n              HIDE_COLLAPSED,\n            )}\n          >\n            <PanelLeft 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=\"pointer-events-none absolute left-2 top-0.5 flex size-9 items-center justify-center rounded-button text-ink-3 opacity-0 transition-[opacity,background-color,color] duration-(--sidebar-copy-duration) ease-out hover:bg-hover-2 hover:text-ink group-data-[sidebar-collapsed=true]/sidebar:pointer-events-auto group-data-[sidebar-collapsed=true]/sidebar:opacity-100\"\n          >\n            <PanelLeft size={18} className=\"rotate-180\" />\n          </button>\n        </div>\n\n        <GlideGroup>\n          <RailButton\n            icon={<SquarePen 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={cn(\"relative mx-2 mb-1 h-8\", COPY)}>\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={{\n                transitionDuration: `${CHAT_SEARCH_MOTION.duration}ms`,\n                transitionTimingFunction: CHAT_SEARCH_MOTION.easing,\n              }}\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-button 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-button 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={cn(\n                    \"relative z-10 mx-2 flex h-8 items-center rounded-[8px] px-2 text-left transition-[width,background-color,color,transform] active:scale-[0.98]\",\n                    COLLAPSIBLE_ROW,\n                    active && \"bg-hover-2 group-hover/glide:bg-transparent\",\n                  )}\n                >\n                  <span\n                    className={cn(\n                      \"min-w-0 flex-1 truncate text-[14px] font-medium\",\n                      COPY,\n                      active ? \"text-ink\" : \"text-ink-2\",\n                    )}\n                  >\n                    {item.label}\n                  </span>\n                </button>\n              );\n            })}\n            {query && visibleRecents.length === 0 && (\n              <div className={cn(\"mx-2 px-2 py-2 text-[12.5px] text-ink-3\", COPY)}>No chats found</div>\n            )}\n          </GlideGroup>\n        </div>\n\n        <div className={cn(\"mx-2 mt-3 w-[208px] border-t border-line pt-3\", COPY)}>\n          <button\n            type=\"button\"\n            onClick={onFooterClick ?? onNewChat}\n            className=\"flex h-8 w-full items-center justify-center gap-1.5 rounded-button 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/iris/sidebar-nav/sidebar-nav.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/sidebar-nav",
  "meta": {
    "kind": "component",
    "group": "AI interface",
    "export": "SidebarNav",
    "docs": "https://irisui.dev/components/sidebar-nav"
  }
}
