{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "Filters as you type, sets the matched run in bold and creates what’s missing.",
  "categories": [
    "pickers"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/combobox.tsx",
      "content": "\"use client\";\nimport { Combobox as ComboboxPrimitive } from \"@base-ui/react/combobox\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronsUpDown, Loader, Plus, X } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\nexport type ComboboxOption = {\n  value: string;\n  label: string;\n  /** Decorative icon before the label. */\n  icon?: React.ReactNode;\n  /** Right-aligned meta: a count, a code, a shortcut. */\n  hint?: React.ReactNode;\n  disabled?: boolean;\n};\n\n// The \"Create\" row travels through the list as an item, so arrows and Enter reach it like any other.\n// Its label is the query itself, so pressing it leaves the typed text in the input.\nconst CREATE = \"\\u0000create\";\n\nexport type ComboboxProps = {\n  options: ComboboxOption[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n  /** Turns on the “Create “x”” row. Return the new option, or a promise of it; throw to report a failure. */\n  onCreate?: (query: string) => ComboboxOption | Promise<ComboboxOption>;\n  /** Label for the create row. */\n  createLabel?: (query: string) => React.ReactNode;\n  label?: React.ReactNode;\n  \"aria-label\"?: string;\n  placeholder?: string;\n  /** Noun for the empty state: “No projects match…”. */\n  noun?: string;\n  disabled?: boolean;\n  invalid?: boolean;\n  name?: string;\n  container?: ComboboxPrimitive.Portal.Props[\"container\"];\n  className?: string;\n};\n\n// Case- and accent-insensitive, with a map back to the original characters so the\n// highlighted run lines up with what's on screen (\"sao\" finds \"São\").\nfunction fold(s: string) {\n  let text = \"\";\n  const map: number[] = [];\n  for (let i = 0; i < s.length; i++) {\n    const f = s[i].normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase();\n    for (const ch of f) {\n      text += ch;\n      map.push(i);\n    }\n  }\n  return { text, map };\n}\n\nexport function matchRange(label: string, query: string): [number, number] | null {\n  const q = fold(query.trim()).text;\n  if (!q) return null;\n  const { text, map } = fold(label);\n  const at = text.indexOf(q);\n  if (at < 0) return null;\n  return [map[at], map[at + q.length - 1] + 1];\n}\n\n/** The label with the matched run set in the primary color and the rest a step quieter. */\nexport function Highlight({ text, query }: { text: string; query: string }) {\n  const range = matchRange(text, query);\n  if (!range) return <>{text}</>;\n  return (\n    <>\n      <span className=\"text-fg-2\">{text.slice(0, range[0])}</span>\n      <mark className=\"bg-transparent font-medium text-fg\">{text.slice(range[0], range[1])}</mark>\n      <span className=\"text-fg-2\">{text.slice(range[1])}</span>\n    </>\n  );\n}\n\nfunction defaultCreateLabel(query: string) {\n  return (\n    <>\n      Create <span className=\"font-medium text-fg\">“{query}”</span>\n    </>\n  );\n}\n\nexport function Combobox({\n  options,\n  value,\n  defaultValue = null,\n  onValueChange,\n  onCreate,\n  createLabel = defaultCreateLabel,\n  label,\n  \"aria-label\": ariaLabel,\n  placeholder = \"Search…\",\n  noun = \"results\",\n  disabled,\n  invalid,\n  name,\n  container,\n  className,\n}: ComboboxProps) {\n  const reduce = !!useReducedMotion();\n  const inputId = useId();\n  const errorId = useId();\n  const [inner, setInner] = useState(defaultValue);\n  const controlled = value !== undefined;\n  const current = controlled ? value : inner;\n  const [created, setCreated] = useState<ComboboxOption[]>([]);\n  const all = useMemo(() => [...options, ...created.filter((c) => !options.some((o) => o.value === c.value))], [options, created]);\n  const selected = all.find((o) => o.value === current) ?? null;\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(selected?.label ?? \"\");\n  const [creating, setCreating] = useState<string | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  // Set in the same event as the press, before state catches up, so the close can be held back.\n  const pendingCreate = useRef(false);\n  // A value set from outside (controlled) brings its label into the input.\n  const [synced, setSynced] = useState(selected?.value ?? null);\n  if ((selected?.value ?? null) !== synced) {\n    setSynced(selected?.value ?? null);\n    setQuery(selected?.label ?? \"\");\n  }\n\n  // While the input still shows the chosen label, the list shows everything.\n  const q = selected && query === selected.label ? \"\" : query.trim();\n  const matches = useMemo(() => (q ? all.filter((o) => matchRange(o.label, q)) : all), [all, q]);\n  const exact = !!q && all.some((o) => fold(o.label).text === fold(q).text);\n  const canCreate = !!onCreate && !!q && !exact;\n  const createItem = useMemo<ComboboxOption>(() => ({ value: CREATE, label: q }), [q]);\n  const filtered = canCreate ? [...matches, createItem] : matches;\n\n  const choose = (next: ComboboxOption | null) => {\n    if (!controlled) setInner(next?.value ?? null);\n    onValueChange?.(next?.value ?? null);\n    setQuery(next?.label ?? \"\");\n  };\n\n  const create = async (text: string) => {\n    if (!onCreate || creating) return;\n    setError(null);\n    const result = onCreate(text);\n    if (!(result instanceof Promise)) {\n      pendingCreate.current = false;\n      setCreated((c) => [...c, result]);\n      choose(result);\n      setOpen(false);\n      return;\n    }\n    setCreating(text);\n    try {\n      const option = await result;\n      setCreated((c) => [...c, option]);\n      choose(option);\n      setOpen(false);\n    } catch {\n      setError(`Couldn't create “${text}”. Try again.`);\n    } finally {\n      pendingCreate.current = false;\n      setCreating(null);\n    }\n  };\n\n  const { setList, y, height, opacity } = useGlide(reduce);\n\n  return (\n    <ComboboxPrimitive.Root<ComboboxOption>\n      items={onCreate ? [...all, createItem] : all}\n      filteredItems={filtered}\n      value={selected}\n      onValueChange={(next) => {\n        if (next?.value === CREATE) {\n          pendingCreate.current = true;\n          return void create(q);\n        }\n        choose(next);\n      }}\n      inputValue={query}\n      onInputValueChange={(next) => {\n        setQuery(next);\n        setError(null);\n      }}\n      open={open}\n      onOpenChange={(next, details) => {\n        // An async create keeps the list open until it lands, so the spinner stays where it was pressed.\n        if (!next && pendingCreate.current && details.reason === \"item-press\") return details.cancel();\n        setOpen(next);\n        // Leaving with a half-typed query puts the chosen label back rather than keeping stray text.\n        if (!next && details.reason !== \"item-press\") setQuery(selected?.label ?? \"\");\n      }}\n      autoHighlight\n      isItemEqualToValue={(a, b) => a.value === b.value}\n      itemToStringLabel={(o) => o.label}\n      itemToStringValue={(o) => o.value}\n      disabled={disabled}\n      name={name}\n    >\n      <div className={cn(\"flex w-full min-w-0 flex-col gap-1.5\", className)}>\n        {label && (\n          <label htmlFor={inputId} className=\"w-fit cursor-default text-[12.5px] font-medium text-fg-2 select-none\">\n            {label}\n          </label>\n        )}\n        <ComboboxPrimitive.InputGroup\n          className={cn(\n            \"group/field relative flex h-8 w-full items-center rounded-lg border border-line-2 bg-raised shadow-[var(--shadow)]\",\n            \"transition-[border-color,box-shadow] duration-150 ease-out hover:border-fg-4\",\n            \"focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/8\",\n            \"has-aria-invalid:border-danger/70 has-aria-invalid:focus-within:ring-danger/15\",\n            \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          )}\n        >\n          <ComboboxPrimitive.Input\n            ref={inputRef}\n            id={inputId}\n            placeholder={placeholder}\n            aria-label={label ? undefined : ariaLabel}\n            aria-invalid={invalid || !!error || undefined}\n            aria-describedby={error && !open ? errorId : undefined}\n            className=\"h-full min-w-0 flex-1 rounded-lg bg-transparent pr-16 pl-2.5 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]\"\n          />\n          <div className=\"absolute inset-y-0 right-1 flex items-center gap-0.5\">\n            <span className=\"grid size-6 place-items-center\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {creating ? (\n                  <motion.span key=\"busy\" {...swapIn(reduce)} className=\"grid place-items-center text-fg-3\">\n                    <Loader size={14} className=\"animate-spin\" />\n                  </motion.span>\n                ) : (\n                  (query || selected) && (\n                    <motion.span key=\"clear\" {...swapIn(reduce)} className=\"grid\">\n                      <button\n                        type=\"button\"\n                        aria-label=\"Clear\"\n                        onClick={() => {\n                          choose(null);\n                          inputRef.current?.focus();\n                        }}\n                        className={cn(\n                          \"relative grid size-6 place-items-center rounded-md text-fg-3 hover:bg-hover hover:text-fg\",\n                          \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                          \"transition-[background-color,color,scale] duration-150 active:scale-[0.9] after:absolute after:-inset-2 after:content-['']\",\n                        )}\n                      >\n                        <X size={14} />\n                      </button>\n                    </motion.span>\n                  )\n                )}\n              </AnimatePresence>\n            </span>\n            <ComboboxPrimitive.Trigger\n              aria-label=\"Show all options\"\n              className={cn(\n                \"grid size-6 place-items-center rounded-md text-fg-3 hover:bg-hover hover:text-fg-2 data-popup-open:text-fg-2\",\n                \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n                \"transition-[background-color,color,scale] duration-150 active:scale-[0.9]\",\n              )}\n            >\n              <ChevronsUpDown size={16} />\n            </ComboboxPrimitive.Trigger>\n          </div>\n        </ComboboxPrimitive.InputGroup>\n        {/* While the list is open the failure shows in the create row; once it closes, under the field. */}\n        <AnimatePresence initial={false}>\n          {error && !open && (\n            <motion.p\n              id={errorId}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}\n              className=\"text-[12px] text-danger\"\n            >\n              {error}\n            </motion.p>\n          )}\n        </AnimatePresence>\n        <span role=\"status\" className=\"sr-only\">\n          {creating ? `Creating “${creating}”` : (error ?? \"\")}\n        </span>\n      </div>\n\n      <ComboboxPrimitive.Portal container={container}>\n        <ComboboxPrimitive.Positioner align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <ComboboxPrimitive.Popup\n            aria-busy={!!creating || undefined}\n            className={cn(\n              \"w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n              \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100\",\n            )}\n          >\n            <ComboboxPrimitive.Empty className=\"empty:hidden\">\n              <div className=\"px-3 py-5 text-center text-[12.5px] text-fg-3\">\n                <p className=\"truncate\">\n                  No {noun} match <span className=\"text-fg-2\">“{q}”</span>\n                </p>\n              </div>\n            </ComboboxPrimitive.Empty>\n            <ComboboxPrimitive.List\n              ref={setList}\n              className=\"relative max-h-[min(var(--available-height),17.5rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden\"\n            >\n              <motion.div aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line\" />\n              <ComboboxPrimitive.Collection>\n                {(o: ComboboxOption) =>\n                  o.value === CREATE ? (\n                    <ComboboxPrimitive.Item\n                      key=\"create\"\n                      value={o}\n                      className={cn(itemClass, matches.length > 0 && \"mt-1 before:absolute before:inset-x-1 before:-top-[3px] before:h-px before:bg-line\")}\n                    >\n                      <span className=\"grid size-4 shrink-0 place-items-center rounded-[4px] border border-line-2 text-fg-2 transition-colors group-data-highlighted/item:border-fg-4 group-data-highlighted/item:text-fg\">\n                        {creating ? <Loader size={11} className=\"animate-spin\" /> : <Plus size={11} />}\n                      </span>\n                      <span className={cn(\"min-w-0 flex-1 truncate\", error ? \"text-danger\" : \"text-fg-2\")}>\n                        {creating ? `Creating “${creating}”…` : error ? `Couldn’t create “${q}”. Try again` : createLabel(q)}\n                      </span>\n                      <kbd className=\"font-mono text-2xs text-fg-4 opacity-0 transition-opacity group-data-highlighted/item:opacity-100\">↵</kbd>\n                    </ComboboxPrimitive.Item>\n                  ) : (\n                    <ComboboxPrimitive.Item key={o.value} value={o} disabled={o.disabled} className={itemClass}>\n                      {o.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-4\">{o.icon}</span>}\n                      <span className=\"min-w-0 flex-1 truncate\">\n                        <Highlight text={o.label} query={q} />\n                      </span>\n                      {o.hint && <span className=\"shrink-0 font-mono text-2xs text-fg-4 tabular\">{o.hint}</span>}\n                      <span className=\"grid size-4 shrink-0 place-items-center\">\n                        <ComboboxPrimitive.ItemIndicator>\n                          <Check size={14} />\n                        </ComboboxPrimitive.ItemIndicator>\n                      </span>\n                    </ComboboxPrimitive.Item>\n                  )\n                }\n              </ComboboxPrimitive.Collection>\n            </ComboboxPrimitive.List>\n          </ComboboxPrimitive.Popup>\n        </ComboboxPrimitive.Positioner>\n      </ComboboxPrimitive.Portal>\n    </ComboboxPrimitive.Root>\n  );\n}\n\nconst itemClass = cn(\n  \"group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg px-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-10\",\n  \"data-disabled:text-fg-4 data-disabled:[&_*]:text-fg-4\",\n);\n\nconst swapIn = (reduce: boolean) => ({\n  initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" },\n  animate: { opacity: 1, scale: 1, filter: \"blur(0px)\" },\n  exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6, filter: \"blur(2px)\" },\n  transition: reduce ? { duration: 0.12 } : spring.pop,\n});\n\n// One highlight for the list: glides after the pointer, jumps for arrow keys and filtering.\nfunction useGlide(reduce: boolean) {\n  const [list, setList] = useState<HTMLDivElement | null>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(32);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    if (!list) return;\n    let keyboard = false;\n    let shown = false;\n    let running: AnimationPlaybackControls[] = [];\n    const stop = () => {\n      running.forEach((c) => c.stop());\n      running = [];\n    };\n    const place = () => {\n      const el = list.querySelector<HTMLElement>(\"[data-highlighted]\");\n      stop();\n      if (!el) {\n        shown = false;\n        running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));\n        return;\n      }\n      const target = el.hasAttribute(\"data-disabled\") ? 0.45 : 1;\n      let top = 0;\n      for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(el.offsetHeight);\n        opacity.jump(target);\n      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n        opacity.jump(target);\n      }\n      shown = true;\n    };\n    // Typing re-filters the list, which moves rows under the highlight; that should jump too.\n    const onKey = () => (keyboard = true);\n    const onPointer = () => (keyboard = false);\n    const observer = new MutationObserver(place);\n    observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: [\"data-highlighted\"] });\n    document.addEventListener(\"keydown\", onKey, true);\n    list.addEventListener(\"pointermove\", onPointer);\n    place();\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"keydown\", onKey, true);\n      list.removeEventListener(\"pointermove\", onPointer);\n      stop();\n    };\n  }, [list, reduce, y, height, opacity]);\n\n  return { setList, y, height, opacity };\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/combobox"
}
