{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hook-sidebar",
  "type": "registry:ui",
  "title": "Hook Sidebar",
  "description": "Collapsible navigation sidebar with animated traveling spring rail and curved elbow hook.",
  "dependencies": [
    "clsx",
    "tailwind-merge",
    "motion",
    "next"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/hook-sidebar.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ComponentProps } from \"react\";\nimport Link from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst CORNER = 6;\nconst DASH =\n  \"repeating-linear-gradient(to top, transparent 0 2px, currentColor 2px 4px)\";\n\nexport type HookSidebarItem =\n  | string\n  | {\n      label: string;\n      href?: string;\n      onClick?: (e: React.MouseEvent<HTMLElement>) => void;\n    };\n\nexport type HookSidebarProps = Omit<ComponentProps<\"nav\">, \"onChange\"> & {\n  items: HookSidebarItem[];\n  label?: string;\n  value?: number;\n  defaultValue?: number;\n  onChange?: (index: number) => void;\n  color?: string;\n  dashed?: boolean;\n};\n\nconst hrefOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? undefined : item.href;\n\nconst labelOf = (item: HookSidebarItem) =>\n  typeof item === \"string\" ? item : item.label;\n\nconst Rail = ({\n  from = 0,\n  y,\n  visible,\n  color,\n  dashed,\n  className,\n}: {\n  from?: number;\n  y: number | null;\n  visible: boolean;\n  color?: string;\n  dashed: boolean;\n  className?: string;\n}) => {\n  const reduced = useReducedMotion();\n  const travel = reduced\n    ? { duration: 0 }\n    : { type: \"spring\" as const, stiffness: 420, damping: 34, mass: 0.7 };\n\n  return (\n    <motion.span\n      aria-hidden\n      initial={false}\n      style={{ color, transition: \"color 0.25s ease\" }}\n      animate={{ opacity: visible && y !== null ? 1 : 0 }}\n      transition={reduced ? { duration: 0 } : { duration: 0.2 }}\n      className={cn(\"pointer-events-none absolute inset-0\", className)}\n    >\n      <motion.span\n        initial={false}\n        animate={{ top: from, height: Math.max(0, (y ?? 0) - CORNER - from) }}\n        transition={travel}\n        style={\n          dashed\n            ? { backgroundImage: DASH }\n            : { backgroundColor: \"currentColor\" }\n        }\n        className=\"absolute left-0.5 w-px\"\n      />\n      <motion.svg\n        initial={false}\n        animate={{ top: (y ?? 0) - CORNER }}\n        transition={travel}\n        width=\"12\"\n        height=\"7\"\n        viewBox=\"0 0 12 7\"\n        fill=\"none\"\n        className=\"absolute left-0.5\"\n      >\n        <path\n          d=\"M0.5 0a6 6 0 0 0 6 6H12\"\n          stroke=\"currentColor\"\n          strokeDasharray={dashed ? \"2 2\" : undefined}\n        />\n      </motion.svg>\n    </motion.span>\n  );\n};\n\nexport function HookSidebar({\n  items,\n  label,\n  value,\n  defaultValue = 0,\n  onChange,\n  color = \"#FC4C01\",\n  dashed = true,\n  className,\n  ...props\n}: HookSidebarProps) {\n  const pathname = usePathname();\n  const listRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLElement | null)[]>([]);\n  const [centers, setCenters] = useState<number[]>([]);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [hoverIndex, setHoverIndex] = useState<number | null>(null);\n  const [pointerInside, setPointerInside] = useState(false);\n  const [focusInside, setFocusInside] = useState(false);\n\n  const routeIndex = items.findIndex((item) => hrefOf(item) === pathname);\n  const activeIndex = value ?? (routeIndex >= 0 ? routeIndex : internalValue);\n\n  useEffect(() => {\n    const list = listRef.current;\n    if (!list) return;\n\n    const measure = () =>\n      setCenters(\n        itemRefs.current.map((el) =>\n          el ? el.offsetTop + el.offsetHeight / 2 : 0,\n        ),\n      );\n\n    measure();\n    const rafId = requestAnimationFrame(measure);\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => {\n      cancelAnimationFrame(rafId);\n      observer.disconnect();\n    };\n  }, [items.length]);\n\n  const activeY = activeIndex < 0 ? null : (centers[activeIndex] ?? null);\n  const hoverY = hoverIndex === null ? null : (centers[hoverIndex] ?? null);\n\n  const hoverFrom =\n    activeY !== null && hoverY !== null && hoverY <= activeY\n      ? Math.max(0, hoverY - CORNER)\n      : (activeY ?? 0);\n\n  const select = (index: number) => {\n    if (value === undefined) setInternalValue(index);\n    onChange?.(index);\n  };\n\n  return (\n    <nav\n      data-slot=\"hook-sidebar\"\n      aria-label={label}\n      className={cn(\"flex flex-col\", className)}\n      {...props}\n    >\n      {label && (\n        <span\n          data-slot=\"hook-sidebar-label\"\n          className=\"pb-2.5 pl-0.5 pr-2 font-poppins text-[11px] font-medium uppercase tracking-wider text-zinc-500\"\n        >\n          {label}\n        </span>\n      )}\n\n      <div\n        ref={listRef}\n        onMouseLeave={() => setPointerInside(false)}\n        className=\"relative flex flex-col gap-0.5\"\n      >\n        <Rail\n          from={hoverFrom}\n          y={hoverY}\n          visible={(pointerInside || focusInside) && hoverIndex !== activeIndex}\n          dashed={dashed}\n          className=\"text-zinc-400 dark:text-zinc-600\"\n        />\n        <Rail\n          y={activeY}\n          visible={activeY !== null}\n          color={color}\n          dashed={dashed}\n        />\n\n        {items.map((item, index) => {\n          const text = labelOf(item);\n          const href = hrefOf(item);\n          const isActive = index === activeIndex;\n          const setRef = (el: HTMLElement | null) => {\n            itemRefs.current[index] = el;\n          };\n          const rowProps = {\n            \"data-slot\": \"hook-sidebar-item\",\n            \"data-active\": isActive,\n            onMouseEnter: () => {\n              setHoverIndex(index);\n              setPointerInside(true);\n            },\n            onFocus: () => {\n              setHoverIndex(index);\n              setFocusInside(true);\n            },\n            onBlur: () => setFocusInside(false),\n            onClick: (e: React.MouseEvent<HTMLElement>) => {\n              if (typeof item !== \"string\" && item.onClick) {\n                item.onClick(e);\n              }\n              if (href?.startsWith(\"#\")) {\n                e.preventDefault();\n              }\n              select(index);\n            },\n            className: cn(\n              \"rounded-lg py-1 pl-5 pr-2 text-left text-xs transition-colors duration-200 motion-reduce:transition-none select-none cursor-pointer\",\n              isActive\n                ? \"text-zinc-950 font-semibold dark:text-white dark:font-medium\"\n                : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-zinc-200\",\n            ),\n          };\n\n          return href ? (\n            <Link\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              href={href}\n              aria-current={isActive ? \"page\" : undefined}\n            >\n              {text}\n            </Link>\n          ) : (\n            <button\n              key={`${index}-${text}`}\n              {...rowProps}\n              ref={setRef}\n              type=\"button\"\n              aria-current={isActive ? \"true\" : undefined}\n            >\n              {text}\n            </button>\n          );\n        })}\n      </div>\n    </nav>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/hook-sidebar.tsx"
    }
  ]
}
