{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "collapsible-sidebar",
  "type": "registry:ui",
  "title": "Collapsible Sidebar",
  "description": "An app sidebar that springs down to an icon rail, with fading labels, rail tooltips, nested groups and a sliding active marker.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/collapsible-sidebar.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface SidebarItem {\n  id: string;\n  label: string;\n  /** Icon shown in the rail. Recommended for top-level items. */\n  icon?: React.ReactNode;\n  /** Renders a link when given, otherwise a button. */\n  href?: string;\n  /** Small count or status shown after the label (a dot in the rail). */\n  badge?: string | number;\n  /** Nested items, rendered as a disclosure group. */\n  children?: SidebarItem[];\n}\n\nexport interface CollapsibleSidebarProps\n  extends Omit<React.HTMLAttributes<HTMLElement>, \"onSelect\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"> {\n  items: SidebarItem[];\n  /** Active item id (controlled). */\n  activeId?: string;\n  /** Initially active item id (uncontrolled). */\n  defaultActiveId?: string;\n  /** Called when an item is activated. Call event.preventDefault() for client routing. */\n  onSelect?: (item: SidebarItem, event: React.MouseEvent<HTMLElement>) => void;\n  /** Collapsed state (controlled). Persist it yourself if needed. */\n  collapsed?: boolean;\n  /** Initial collapsed state (uncontrolled). */\n  defaultCollapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n  /** Width when expanded, in px. */\n  expandedWidth?: number;\n  /** Width of the icon rail, in px. */\n  collapsedWidth?: number;\n  /** Show labels as tooltips on hover / focus while collapsed. */\n  showTooltips?: boolean;\n  /** Key that toggles the sidebar together with ⌘ / Ctrl, e.g. \"b\". Empty string disables it. */\n  shortcut?: string;\n  /** Brand or workspace content beside the toggle. */\n  header?: React.ReactNode;\n  /** Content pinned to the bottom (account, settings…). */\n  footer?: React.ReactNode;\n  /** Accessible name of the nav landmark. */\n  label?: string;\n  /** Accessible name of the toggle button. */\n  toggleLabel?: string;\n  /** Spring for the width and the active indicator. */\n  spring?: Spring;\n  /** Accent for the active marker and focus rings. */\n  accent?: string;\n}\n\nfunction findParent(items: SidebarItem[], id: string | undefined): SidebarItem | undefined {\n  if (!id) return undefined;\n  return items.find((item) => item.children?.some((child) => child.id === id));\n}\n\nconst ROW =\n  \"relative flex h-10 w-full items-center overflow-hidden rounded-lg text-left text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--cs-accent)]\";\n\n/**\n * An application sidebar that collapses to an icon rail with a spring width animation. Labels fade\n * and slide, collapsed items show tooltips on hover and focus, nested groups are disclosures and a\n * shared-layout indicator follows the active item. Controlled or uncontrolled.\n */\nexport function CollapsibleSidebar({\n  items,\n  activeId,\n  defaultActiveId,\n  onSelect,\n  collapsed,\n  defaultCollapsed = false,\n  onCollapsedChange,\n  expandedWidth = 248,\n  collapsedWidth = 64,\n  showTooltips = true,\n  shortcut = \"\",\n  header,\n  footer,\n  label = \"Sidebar\",\n  toggleLabel = \"Toggle sidebar\",\n  spring = { stiffness: 380, damping: 36, mass: 0.9 },\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: CollapsibleSidebarProps) {\n  const uid = React.useId();\n  const navId = `${uid}-nav`;\n  const reduceMotion = usePrefersReducedMotion();\n\n  const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed);\n  const isCollapsed = collapsed ?? internalCollapsed;\n  const setCollapsed = (next: boolean) => {\n    if (collapsed === undefined) setInternalCollapsed(next);\n    onCollapsedChange?.(next);\n  };\n\n  const [internalActive, setInternalActive] = React.useState(defaultActiveId);\n  const current = activeId ?? internalActive;\n\n  // Groups start open when they contain the active item.\n  const [openGroups, setOpenGroups] = React.useState<Set<string>>(() => {\n    const parent = findParent(items, activeId ?? defaultActiveId);\n    return new Set(parent ? [parent.id] : []);\n  });\n  const toggleGroup = (id: string, force?: boolean) =>\n    setOpenGroups((prev) => {\n      const next = new Set(prev);\n      const open = force ?? !next.has(id);\n      if (open) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n\n  // When the active child is hidden (rail or closed group), its parent carries the indicator.\n  const parentOfActive = findParent(items, current);\n  const visualActive = parentOfActive && (isCollapsed || !openGroups.has(parentOfActive.id)) ? parentOfActive.id : current;\n\n  const onShortcut = React.useEffectEvent(() => setCollapsed(!isCollapsed));\n  React.useEffect(() => {\n    if (!shortcut) return;\n    const key = shortcut.toLowerCase();\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return;\n      if (event.key.toLowerCase() !== key) return;\n      event.preventDefault();\n      onShortcut();\n    };\n    window.addEventListener(\"keydown\", onKeyDown);\n    return () => window.removeEventListener(\"keydown\", onKeyDown);\n  }, [shortcut]);\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const fade = reduceMotion ? { duration: 0 } : { duration: 0.18, ease: \"easeOut\" as const };\n\n  const select = (item: SidebarItem, event: React.MouseEvent<HTMLElement>) => {\n    if (activeId === undefined) setInternalActive(item.id);\n    onSelect?.(item, event);\n  };\n\n  const labelMotion = {\n    initial: false as const,\n    animate: { opacity: isCollapsed ? 0 : 1, x: isCollapsed ? -6 : 0 },\n    transition: fade,\n  };\n\n  const renderRow = (item: SidebarItem, depth: number) => {\n    const isGroup = Boolean(item.children?.length);\n    const groupOpen = isGroup && !isCollapsed && openGroups.has(item.id);\n    const isVisualActive = visualActive === item.id;\n    const isCurrent = current === item.id;\n    const groupId = `${uid}-group-${item.id}`;\n\n    const content = (\n      <>\n        {isVisualActive && (\n          <motion.span\n            aria-hidden=\"true\"\n            layoutId={`${uid}-active`}\n            transition={transition}\n            className=\"absolute inset-0 rounded-lg bg-zinc-900/[0.06] dark:bg-white/[0.07]\"\n          >\n            <span className=\"absolute bottom-2.5 left-0 top-2.5 w-[2px] rounded-full bg-[var(--cs-accent)]\" />\n          </motion.span>\n        )}\n        <span\n          className=\"relative flex h-10 shrink-0 items-center justify-center text-zinc-500 group-hover/row:text-zinc-900 group-aria-[current=page]/row:text-zinc-950 dark:text-zinc-400 dark:group-hover/row:text-zinc-100 dark:group-aria-[current=page]/row:text-white [&_svg]:h-[18px] [&_svg]:w-[18px]\"\n          style={{ width: \"calc(var(--cs-rail) - 16px)\" }}\n        >\n          {depth > 0 ? <span aria-hidden=\"true\" className=\"h-1 w-1 rounded-full bg-current opacity-60\" /> : item.icon}\n          {item.badge !== undefined && (\n            <motion.span\n              aria-hidden=\"true\"\n              initial={false}\n              animate={{ opacity: isCollapsed ? 1 : 0, scale: isCollapsed ? 1 : 0.4 }}\n              transition={fade}\n              className=\"absolute right-[calc(50%-13px)] top-[9px] h-2 w-2 rounded-full border-2 border-white bg-[var(--cs-accent)] dark:border-zinc-900\"\n            />\n          )}\n        </span>\n        <motion.span {...labelMotion} className=\"relative flex min-w-0 flex-1 items-center gap-2 pr-2.5\">\n          <span className=\"truncate font-medium tracking-tight\">{item.label}</span>\n          {item.badge !== undefined && (\n            <span className=\"ml-auto rounded-full bg-zinc-900/[0.06] px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-600 dark:bg-white/[0.08] dark:text-zinc-300\">\n              {item.badge}\n            </span>\n          )}\n          {isGroup && (\n            <svg\n              viewBox=\"0 0 16 16\"\n              aria-hidden=\"true\"\n              className={`h-3.5 w-3.5 shrink-0 text-zinc-400 transition-transform duration-200 ${item.badge === undefined ? \"ml-auto\" : \"\"} ${groupOpen ? \"rotate-90\" : \"\"}`}\n              fill=\"none\"\n            >\n              <path d=\"M6 3.5 10.5 8 6 12.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          )}\n        </motion.span>\n      </>\n    );\n\n    const rowClass = `${ROW} group/row ${isVisualActive ? \"text-zinc-950 dark:text-white\" : \"text-zinc-600 hover:bg-zinc-900/[0.04] hover:text-zinc-950 dark:text-zinc-400 dark:hover:bg-white/[0.04] dark:hover:text-white\"}`;\n\n    let control: React.ReactNode;\n    if (isGroup) {\n      control = (\n        <button\n          type=\"button\"\n          aria-expanded={groupOpen}\n          aria-controls={groupOpen ? groupId : undefined}\n          onClick={() => {\n            if (isCollapsed) {\n              // The rail has no room for children: expand and open the group.\n              setCollapsed(false);\n              toggleGroup(item.id, true);\n            } else toggleGroup(item.id);\n          }}\n          className={rowClass}\n        >\n          {content}\n        </button>\n      );\n    } else if (item.href) {\n      control = (\n        <a href={item.href} aria-current={isCurrent ? \"page\" : undefined} onClick={(event) => select(item, event)} className={rowClass}>\n          {content}\n        </a>\n      );\n    } else {\n      control = (\n        <button type=\"button\" aria-current={isCurrent ? \"page\" : undefined} onClick={(event) => select(item, event)} className={rowClass}>\n          {content}\n        </button>\n      );\n    }\n\n    return (\n      <li key={item.id} className=\"group/item relative\">\n        {control}\n        {isCollapsed && showTooltips && depth === 0 && (\n          // The label stays in the control's accessible name, so the tooltip is visual only.\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-full top-1/2 z-20 ml-3 -translate-y-1/2 scale-95 whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-xs font-medium text-white opacity-0 shadow-lg transition-[opacity,scale] duration-150 group-hover/item:scale-100 group-hover/item:opacity-100 group-has-[:focus-visible]/item:scale-100 group-has-[:focus-visible]/item:opacity-100 dark:bg-white dark:text-zinc-950\"\n          >\n            {item.label}\n            {item.badge !== undefined && <span className=\"ml-1.5 font-mono text-[10px] opacity-60\">{item.badge}</span>}\n          </span>\n        )}\n        {isGroup && (\n          <AnimatePresence initial={false}>\n            {groupOpen && (\n              <motion.ul\n                id={groupId}\n                initial={{ height: 0, opacity: 0 }}\n                animate={{ height: \"auto\", opacity: 1 }}\n                exit={{ height: 0, opacity: 0 }}\n                transition={reduceMotion ? { duration: 0 } : { duration: 0.22, ease: [0.16, 1, 0.3, 1] }}\n                className=\"overflow-hidden\"\n              >\n                {item.children?.map((child) => renderRow(child, depth + 1))}\n              </motion.ul>\n            )}\n          </AnimatePresence>\n        )}\n      </li>\n    );\n  };\n\n  return (\n    <motion.aside\n      initial={false}\n      animate={{ width: isCollapsed ? collapsedWidth : expandedWidth }}\n      transition={transition}\n      data-collapsed={isCollapsed || undefined}\n      className={`relative flex h-full shrink-0 flex-col border-r border-zinc-900/10 bg-white text-zinc-900 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 ${className}`}\n      style={{ [\"--cs-accent\" as string]: accent, [\"--cs-rail\" as string]: `${collapsedWidth}px`, ...style }}\n      {...props}\n    >\n      <div className=\"flex h-14 shrink-0 items-center overflow-hidden border-b border-zinc-900/[0.07] px-2 dark:border-white/[0.07]\">\n        <button\n          type=\"button\"\n          aria-label={toggleLabel}\n          aria-expanded={!isCollapsed}\n          aria-controls={navId}\n          aria-keyshortcuts={shortcut ? `Control+${shortcut.toUpperCase()} Meta+${shortcut.toUpperCase()}` : undefined}\n          onClick={() => setCollapsed(!isCollapsed)}\n          className=\"flex h-10 shrink-0 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--cs-accent)] dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white\"\n          style={{ width: \"calc(var(--cs-rail) - 16px)\" }}\n        >\n          <svg viewBox=\"0 0 20 20\" className=\"h-[18px] w-[18px]\" aria-hidden=\"true\" fill=\"none\">\n            <rect x=\"2.75\" y=\"3.75\" width=\"14.5\" height=\"12.5\" rx=\"2.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n            <motion.path\n              d=\"M7.5 4v12\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              initial={false}\n              animate={{ x: isCollapsed ? -1.5 : 0 }}\n              transition={transition}\n            />\n          </svg>\n        </button>\n        {header && (\n          <motion.div {...labelMotion} className=\"min-w-0 flex-1 pl-1\">\n            {header}\n          </motion.div>\n        )}\n      </div>\n\n      <nav\n        id={navId}\n        aria-label={label}\n        className={`min-h-0 flex-1 px-2 py-3 ${isCollapsed ? \"overflow-visible\" : \"overflow-y-auto overflow-x-hidden\"}`}\n      >\n        <ul className=\"flex flex-col gap-0.5\">{items.map((item) => renderRow(item, 0))}</ul>\n      </nav>\n\n      {footer && (\n        <div className=\"shrink-0 overflow-hidden border-t border-zinc-900/[0.07] p-2 dark:border-white/[0.07]\">{footer}</div>\n      )}\n    </motion.aside>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "docs": "Documentation: https://wancraft.my/components/collapsible-sidebar"
}
