{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "category-dropdown",
  "title": "Category Dropdown",
  "description": "A dropdown with vertical category tabs on the left and their items on the right, with a sliding tab indicator, animated height, and a morphing chevron.",
  "dependencies": [
    "@base-ui/react@^1.8.0",
    "cn@^0.3.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "src/components/ai/category-dropdown.tsx",
      "content": "import { Menu } from '@base-ui/react/menu'\nimport {\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type ReactElement,\n  type ReactNode,\n} from 'react'\n\nimport { cn } from 'cn'\n\ninterface CategoryDropdownItem {\n  id: string\n  label: string\n  href?: string\n}\n\ninterface CategoryDropdownCategory {\n  id: string\n  label: string\n  items: CategoryDropdownItem[]\n}\n\ninterface CategoryDropdownProps {\n  categories: CategoryDropdownCategory[]\n  value?: string\n  onValueChange?: (id: string) => void\n  placeholder?: string\n  renderItem?: (item: CategoryDropdownItem) => ReactElement\n  align?: ComponentProps<typeof Menu.Positioner>['align']\n  className?: string\n}\n\nconst ease = 'ease-[cubic-bezier(0.22,1,0.36,1)]'\n\nfunction categoryOf(categories: CategoryDropdownCategory[], id?: string) {\n  return categories.find((category) => category.items.some((item) => item.id === id))?.id\n}\n\nfunction useEnableTransition(ready: boolean) {\n  const [enabled, setEnabled] = useState(false)\n\n  useLayoutEffect(() => {\n    if (!ready || enabled) return\n    const frame = requestAnimationFrame(() => setEnabled(true))\n    return () => cancelAnimationFrame(frame)\n  }, [ready, enabled])\n\n  return enabled\n}\n\nfunction CategoryDropdown({\n  categories,\n  value,\n  onValueChange,\n  placeholder = 'Browse',\n  renderItem,\n  align = 'center',\n  className,\n}: CategoryDropdownProps) {\n  const selectedCategory = categoryOf(categories, value)\n  const selectedItem = categories\n    .flatMap((category) => category.items)\n    .find((item) => item.id === value)\n  const [tab, setTab] = useState(selectedCategory ?? categories[0]?.id ?? '')\n  const [tabsHeight, setTabsHeight] = useState(420)\n  const activeCategory = categories.find((category) => category.id === tab)\n\n  return (\n    <Menu.Root onOpenChange={(open) => open && selectedCategory && setTab(selectedCategory)}>\n      <Menu.Trigger\n        className={cn(\n          'group flex h-9 w-48 items-center justify-between gap-2 rounded-full bg-foreground/7 pr-3 pl-4 text-[14px] font-[550] text-foreground transition-colors outline-none hover:bg-foreground/10 focus-visible:ring-2 focus-visible:ring-ring',\n          className,\n        )}\n      >\n        <span className=\"truncate\">{selectedItem?.label ?? placeholder}</span>\n        <MorphChevron />\n      </Menu.Trigger>\n      <Menu.Portal>\n        <Menu.Positioner align={align} className=\"z-50\" sideOffset={6}>\n          <Menu.Popup className=\"flex w-[480px] max-w-[calc(100vw-2rem)] origin-(--transform-origin) gap-1 rounded-xl border-[0.5px] border-foreground/10 bg-popover p-1 text-popover-foreground shadow-[0_12px_32px_rgb(0_0_0/.18)] transition-[opacity,scale] duration-150 ease-out data-ending-style:scale-[0.96] data-ending-style:opacity-0 data-starting-style:scale-[0.96] data-starting-style:opacity-0 motion-reduce:transition-none dark:shadow-[0_12px_32px_rgb(0_0_0/.5)]\">\n            <CategoryTabs\n              categories={categories}\n              onChange={setTab}\n              onHeight={setTabsHeight}\n              value={tab}\n            />\n            <AnimatedHeight className=\"min-w-0 flex-1\" contentKey={tab} maxHeight={tabsHeight}>\n              <HoverList key={tab}>\n                {(highlight) => (\n                  <Menu.Group className=\"flex animate-in flex-col duration-200 fade-in-0 slide-in-from-bottom-1 motion-reduce:animate-none\">\n                    <Menu.GroupLabel className=\"sr-only\">{activeCategory?.label}</Menu.GroupLabel>\n                    {activeCategory?.items.map((item) => (\n                      <Menu.Item\n                        aria-current={item.id === value ? 'page' : undefined}\n                        className=\"group/item flex h-9 cursor-default items-center rounded-[10px] px-3 text-[14px] font-medium text-muted-foreground transition-colors duration-200 outline-none select-none aria-[current=page]:font-semibold aria-[current=page]:text-foreground data-[highlighted]:text-foreground\"\n                        key={item.id}\n                        onClick={() => onValueChange?.(item.id)}\n                        onFocus={(event) => highlight(event.currentTarget)}\n                        onPointerEnter={(event) => highlight(event.currentTarget)}\n                        render={\n                          renderItem?.(item) ??\n                          (item.href\n                            ? (props) => (\n                                <a {...props} href={item.href}>\n                                  {props.children}\n                                </a>\n                              )\n                            : undefined)\n                        }\n                      >\n                        <span className={nudgeClass}>{item.label}</span>\n                      </Menu.Item>\n                    ))}\n                  </Menu.Group>\n                )}\n              </HoverList>\n            </AnimatedHeight>\n          </Menu.Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  )\n}\n\nconst nudgeClass = cn(\n  'truncate transition-transform duration-300 group-hover/item:translate-x-0.5 group-data-[highlighted]/item:translate-x-0.5 motion-reduce:transition-none',\n  ease,\n)\n\nfunction HoverList({\n  children,\n}: {\n  children: (highlight: (element: HTMLElement) => void) => ReactNode\n}) {\n  const [box, setBox] = useState<{ top: number; height: number; slide: boolean } | null>(null)\n  const [visible, setVisible] = useState(false)\n\n  const highlight = (element: HTMLElement) => {\n    setBox({ top: element.offsetTop, height: element.offsetHeight, slide: visible })\n    setVisible(true)\n  }\n\n  return (\n    <div\n      className=\"relative isolate\"\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) setVisible(false)\n      }}\n      onPointerLeave={() => setVisible(false)}\n    >\n      {box ? (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            'absolute inset-x-0 top-0 -z-10 rounded-[10px] bg-foreground/7 transition-opacity duration-200 motion-reduce:transition-none',\n            box.slide && cn('transition-[translate,height,opacity] duration-300', ease),\n            !visible && 'opacity-0',\n          )}\n          style={{ translate: `0 ${box.top}px`, height: box.height }}\n        />\n      ) : null}\n      {children(highlight)}\n    </div>\n  )\n}\n\nconst chevronBarClass = cn(\n  'absolute top-1/2 h-[1.5px] w-[7px] rounded-full bg-current transition-transform duration-300 motion-reduce:transition-none',\n  ease,\n)\n\nfunction MorphChevron() {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        'relative size-3.5 shrink-0 translate-y-[2px] text-muted-foreground transition-transform duration-300 group-data-[popup-open]:-translate-y-[2px] motion-reduce:transition-none',\n        ease,\n      )}\n    >\n      <span\n        className={cn(\n          chevronBarClass,\n          'left-0 origin-right -translate-y-1/2 rotate-[38deg] group-data-[popup-open]:-rotate-[38deg]',\n        )}\n      />\n      <span\n        className={cn(\n          chevronBarClass,\n          'right-0 origin-left -translate-y-1/2 -rotate-[38deg] group-data-[popup-open]:rotate-[38deg]',\n        )}\n      />\n    </span>\n  )\n}\n\nfunction CategoryTabs({\n  categories,\n  value,\n  onChange,\n  onHeight,\n}: {\n  categories: CategoryDropdownCategory[]\n  value: string\n  onChange: (value: string) => void\n  onHeight: (height: number) => void\n}) {\n  const listRef = useRef<HTMLDivElement>(null)\n  const tabRefs = useRef(new Map<string, HTMLButtonElement>())\n  const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null)\n  const animate = useEnableTransition(indicator !== null)\n\n  useLayoutEffect(() => {\n    if (listRef.current) onHeight(listRef.current.offsetHeight)\n  }, [onHeight])\n\n  useLayoutEffect(() => {\n    const element = tabRefs.current.get(value)\n    if (element) setIndicator({ top: element.offsetTop, height: element.offsetHeight })\n  }, [value])\n\n  return (\n    <div\n      aria-label=\"Categories\"\n      aria-orientation=\"vertical\"\n      className=\"relative isolate flex w-44 shrink-0 flex-col gap-0.5 self-start rounded-[10px] bg-foreground/5 p-0.5\"\n      ref={listRef}\n      role=\"tablist\"\n    >\n      {indicator ? (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            'absolute inset-x-0.5 top-0 -z-10 rounded-[8px] bg-popover shadow-[0_1px_2px_rgb(0_0_0/0.15)] motion-reduce:transition-none',\n            animate && cn('transition-[translate,height] duration-300', ease),\n          )}\n          style={{ translate: `0 ${indicator.top}px`, height: indicator.height }}\n        />\n      ) : null}\n      {categories.map((category) => (\n        <button\n          aria-selected={value === category.id}\n          className={cn(\n            'group/item flex h-8 items-center rounded-[8px] px-3 text-left text-[13px] font-[550] whitespace-nowrap transition-colors duration-200',\n            value === category.id\n              ? 'text-foreground'\n              : 'text-muted-foreground hover:text-foreground',\n          )}\n          key={category.id}\n          onClick={() => onChange(category.id)}\n          onPointerEnter={() => onChange(category.id)}\n          ref={(element) => {\n            if (element) tabRefs.current.set(category.id, element)\n            else tabRefs.current.delete(category.id)\n          }}\n          role=\"tab\"\n          type=\"button\"\n        >\n          <span className={nudgeClass}>{category.label}</span>\n        </button>\n      ))}\n    </div>\n  )\n}\n\nfunction AnimatedHeight({\n  children,\n  contentKey,\n  maxHeight,\n  className,\n}: {\n  children: ReactNode\n  contentKey: string\n  maxHeight: number\n  className?: string\n}) {\n  const contentRef = useRef<HTMLDivElement>(null)\n  const heightRef = useRef<number | null>(null)\n  const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)\n  const [height, setHeight] = useState<number | null>(null)\n  const [overflowing, setOverflowing] = useState(false)\n  const [settling, setSettling] = useState(false)\n  const animate = useEnableTransition(height !== null)\n\n  useLayoutEffect(() => {\n    const element = contentRef.current\n    if (!element) return\n    const measure = () => {\n      const next = Math.min(element.offsetHeight, maxHeight)\n      if (heightRef.current !== null && heightRef.current !== next) {\n        setSettling(true)\n        clearTimeout(settleTimer.current)\n        settleTimer.current = setTimeout(() => setSettling(false), 420)\n      }\n      heightRef.current = next\n      setHeight(next)\n      setOverflowing(element.offsetHeight > maxHeight)\n    }\n    measure()\n    const observer = new ResizeObserver(measure)\n    observer.observe(element)\n    return () => observer.disconnect()\n  }, [contentKey, maxHeight])\n\n  useEffect(() => () => clearTimeout(settleTimer.current), [])\n\n  return (\n    <div\n      className={cn(\n        '[scrollbar-width:thin] [scrollbar-color:var(--border)_transparent] overscroll-contain motion-reduce:transition-none',\n        overflowing && !settling ? 'overflow-y-auto' : 'overflow-hidden',\n        animate && cn('transition-[height] duration-[400ms]', ease),\n        className,\n      )}\n      style={height === null ? undefined : { height }}\n    >\n      <div ref={contentRef}>{children}</div>\n    </div>\n  )\n}\n\nexport { CategoryDropdown }\nexport type { CategoryDropdownCategory, CategoryDropdownItem, CategoryDropdownProps }\n",
      "type": "registry:component",
      "target": "@components/ai/category-dropdown.tsx"
    }
  ],
  "type": "registry:component"
}