{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "async-combobox",
  "type": "registry:ui",
  "title": "Async Combobox",
  "description": "Remote search that debounces, aborts stale requests and never jolts the list.",
  "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/async-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 { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, Check, ChevronsUpDown, Loader, Refresh, Search, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\nexport type SearchStatus = \"idle\" | \"debouncing\" | \"loading\" | \"success\" | \"error\";\n\ntype SearchState<T> = { status: SearchStatus; results: T[]; query: string; error: unknown };\n\n/**\n * Debounced remote search. Every new query aborts the request before it, and a\n * late answer to an old query is dropped, so results never go backwards.\n */\nexport function useAsyncSearch<T>({\n  search,\n  debounce = 250,\n  minQueryLength = 1,\n}: {\n  search: (query: string, options: { signal: AbortSignal }) => Promise<T[]>;\n  debounce?: number;\n  minQueryLength?: number;\n}) {\n  const [state, setState] = useState<SearchState<T>>({ status: \"idle\", results: [], query: \"\", error: null });\n  const timer = useRef<number>(undefined);\n  const controller = useRef<AbortController | null>(null);\n  const latest = useRef(search);\n  useEffect(() => {\n    latest.current = search;\n  });\n  useEffect(\n    () => () => {\n      window.clearTimeout(timer.current);\n      controller.current?.abort();\n    },\n    [],\n  );\n\n  const run = useCallback(\n    (raw: string, wait = debounce) => {\n      window.clearTimeout(timer.current);\n      controller.current?.abort();\n      const query = raw.trim();\n      if (query.length < minQueryLength) {\n        setState((s) => ({ ...s, status: \"idle\", query, error: null }));\n        return;\n      }\n      setState((s) => ({ ...s, status: \"debouncing\", query, error: null }));\n      timer.current = window.setTimeout(async () => {\n        const c = new AbortController();\n        controller.current = c;\n        setState((s) => ({ ...s, status: \"loading\" }));\n        try {\n          const results = await latest.current(query, { signal: c.signal });\n          if (!c.signal.aborted) setState({ status: \"success\", results, query, error: null });\n        } catch (error) {\n          if (!c.signal.aborted) setState((s) => ({ ...s, status: \"error\", error }));\n        }\n      }, wait);\n    },\n    [debounce, minQueryLength],\n  );\n\n  const retry = useCallback(() => run(state.query, 0), [run, state.query]);\n  /** Stops anything in flight and goes back to idle, keeping the last results for next time. */\n  const reset = useCallback(() => {\n    window.clearTimeout(timer.current);\n    controller.current?.abort();\n    setState((s) => ({ ...s, status: \"idle\", query: \"\", error: null }));\n  }, []);\n  return { ...state, run, retry, reset };\n}\n\n// Waits before showing a loading state (most requests beat it) and, once shown,\n// holds it long enough not to flicker.\nfunction useDelayedFlag(on: boolean, delay = 150, hold = 300) {\n  const [shown, setShown] = useState(false);\n  const since = useRef(0);\n  useEffect(() => {\n    let t: number | undefined;\n    if (on && !shown)\n      t = window.setTimeout(() => {\n        since.current = performance.now();\n        setShown(true);\n      }, delay);\n    else if (!on && shown) t = window.setTimeout(() => setShown(false), Math.max(0, hold - (performance.now() - since.current)));\n    return () => window.clearTimeout(t);\n  }, [on, shown, delay, hold]);\n  return shown;\n}\n\nfunction fold(s: string) {\n  let text = \"\";\n  const map: number[] = [];\n  for (let i = 0; i < s.length; i++) {\n    for (const ch of s[i].normalize(\"NFD\").replace(/\\p{M}/gu, \"\").toLocaleLowerCase()) {\n      text += ch;\n      map.push(i);\n    }\n  }\n  return { text, map };\n}\n\n/** Sets the part of `text` that matches `query` in the primary color, ignoring case and accents. */\nexport function Highlight({ text, query }: { text: string; query: string }) {\n  const q = fold(query.trim()).text;\n  const { text: t, map } = fold(text);\n  const at = q ? t.indexOf(q) : -1;\n  if (at < 0) return <>{text}</>;\n  const [a, b] = [map[at], map[at + q.length - 1] + 1];\n  return (\n    <>\n      <span className=\"text-fg-2\">{text.slice(0, a)}</span>\n      <mark className=\"bg-transparent font-medium text-fg\">{text.slice(a, b)}</mark>\n      <span className=\"text-fg-2\">{text.slice(b)}</span>\n    </>\n  );\n}\n\nexport type AsyncComboboxProps<T> = {\n  /** Fetches results. Honor the signal so abandoned requests stop. */\n  search: (query: string, options: { signal: AbortSignal }) => Promise<T[]>;\n  getKey: (item: T) => string;\n  getLabel: (item: T) => string;\n  /** Row contents. Defaults to the label with the match highlighted. */\n  renderItem?: (item: T, query: string) => React.ReactNode;\n  value?: T | null;\n  defaultValue?: T | null;\n  onValueChange?: (item: T | null) => void;\n  /** Shown before anything is typed: recent picks, popular items. */\n  suggestions?: T[];\n  suggestionsLabel?: string;\n  debounce?: number;\n  minQueryLength?: number;\n  label?: React.ReactNode;\n  \"aria-label\"?: string;\n  placeholder?: string;\n  /** Plural noun for the empty state and errors: “No repositories match…”. */\n  noun?: string;\n  disabled?: boolean;\n  container?: ComboboxPrimitive.Portal.Props[\"container\"];\n  className?: string;\n};\n\nexport function AsyncCombobox<T>({\n  search,\n  getKey,\n  getLabel,\n  renderItem,\n  value,\n  defaultValue = null,\n  onValueChange,\n  suggestions = [],\n  suggestionsLabel = \"Suggested\",\n  debounce = 250,\n  minQueryLength = 1,\n  label,\n  \"aria-label\": ariaLabel,\n  placeholder = \"Search…\",\n  noun = \"results\",\n  disabled,\n  container,\n  className,\n}: AsyncComboboxProps<T>) {\n  const reduce = !!useReducedMotion();\n  const inputId = useId();\n  const [inner, setInner] = useState<T | null>(defaultValue);\n  const controlled = value !== undefined;\n  const selected = controlled ? value : inner;\n  const [input, setInput] = useState(selected ? getLabel(selected) : \"\");\n  const s = useAsyncSearch({ search, debounce, minQueryLength });\n\n  const typed = s.query.length >= minQueryLength;\n  const pending = s.status === \"debouncing\" || s.status === \"loading\";\n  const spinning = useDelayedFlag(s.status === \"loading\");\n  const hasResults = s.results.length > 0;\n  // While a search is in flight, whatever was on screen stays there (dimmed) until the answer replaces it.\n  const items = typed ? (hasResults || s.status === \"success\" ? s.results : suggestions) : suggestions;\n  // The chosen item stays in the collection so the input can keep showing its label.\n\n  const firstLoad = typed && pending && items.length === 0;\n  const empty = typed && s.status === \"success\" && !hasResults;\n  const failed = typed && s.status === \"error\";\n  const stale = typed && pending && items.length > 0;\n  // Only rows people can see are in the collection, so arrows never land on a hidden one.\n  const visible = failed || firstLoad || empty ? [] : items;\n\n  const status = !typed\n    ? suggestions.length\n      ? suggestionsLabel\n      : `Type to search ${noun}`\n    : failed\n      ? \"Search failed\"\n      : spinning || firstLoad\n        ? `Searching for “${s.query}”`\n        : s.status === \"success\" || hasResults\n          ? s.results.length === 0\n            ? \"No results\"\n            : `${s.results.length} ${s.results.length === 1 ? \"result\" : \"results\"}`\n          : suggestionsLabel;\n\n  const choose = (item: T | null) => {\n    if (!controlled) setInner(item);\n    onValueChange?.(item);\n    setInput(item ? getLabel(item) : \"\");\n  };\n\n  const { setList, y, height, opacity } = useGlide(reduce);\n  const { ref: bodyRef, height: bodyHeight } = useHeight();\n\n  return (\n    <ComboboxPrimitive.Root<T>\n      items={visible}\n      filter={null}\n      value={selected}\n      onValueChange={(next) => choose(next)}\n      inputValue={input}\n      onInputValueChange={(next, details) => {\n        setInput(next);\n        // Choosing writes the label into the input; that isn't a new search.\n        if (details.reason !== \"item-press\") s.run(next);\n      }}\n      onOpenChange={(open, details) => {\n        if (!open && details.reason !== \"item-press\") {\n          setInput(selected ? getLabel(selected) : \"\");\n          s.reset();\n        }\n      }}\n      isItemEqualToValue={(a, b) => getKey(a) === getKey(b)}\n      itemToStringLabel={getLabel}\n      itemToStringValue={getKey}\n      disabled={disabled}\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            \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n          )}\n        >\n          <span className=\"pointer-events-none grid w-8 shrink-0 place-items-center text-fg-3\">\n            <span className=\"relative grid size-4 place-items-center\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span key={spinning ? \"busy\" : \"idle\"} {...swapIn(reduce)} className=\"grid place-items-center\">\n                  {spinning ? <Loader size={14} className=\"animate-spin\" /> : <Search size={14} />}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          </span>\n          <ComboboxPrimitive.Input\n            id={inputId}\n            placeholder={placeholder}\n            aria-label={label ? undefined : ariaLabel}\n            aria-busy={pending || undefined}\n            onKeyDown={(e) => {\n              // With nothing highlighted, Enter retries a failed search.\n              if (e.key === \"Enter\" && failed) {\n                e.preventDefault();\n                e.preventBaseUIHandler();\n                s.retry();\n              }\n            }}\n            className=\"h-full min-w-0 flex-1 bg-transparent pr-16 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            <AnimatePresence initial={false}>\n              {(input || selected) && (\n                <motion.button\n                  key=\"clear\"\n                  type=\"button\"\n                  aria-label=\"Clear\"\n                  {...swapIn(reduce)}\n                  onClick={() => {\n                    choose(null);\n                    s.run(\"\", 0);\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] duration-150 active:scale-[0.9] after:absolute after:-inset-2 after:content-['']\",\n                  )}\n                >\n                  <X size={14} />\n                </motion.button>\n              )}\n            </AnimatePresence>\n            <ComboboxPrimitive.Trigger\n              aria-label=\"Show results\"\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      </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={pending || undefined}\n            className={cn(\n              \"w-(--anchor-width) max-w-(--available-width) sm:min-w-72 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            {/* A fixed-height status line: counts, searching and failures change here without moving the rows. */}\n            <ComboboxPrimitive.Status className=\"flex h-8 items-center border-b border-line px-3 font-mono text-2xs text-fg-3\">\n              <span className=\"min-w-0 truncate tabular\">{status}</span>\n            </ComboboxPrimitive.Status>\n\n            {/* The body eases to each new height instead of snapping, so results arriving never jolt the popup. */}\n            <motion.div\n              initial={false}\n              animate={{ height: bodyHeight ?? \"auto\" }}\n              transition={reduce ? { duration: 0 } : { duration: 0.22, ease: ease.inOut }}\n              className=\"overflow-hidden\"\n            >\n              <div ref={bodyRef}>\n                {firstLoad && <Skeleton show={spinning} />}\n                {failed && (\n                  <div role=\"alert\" className=\"flex items-center gap-3 px-3 py-3\">\n                    <Alert size={16} className=\"shrink-0 text-danger\" />\n                    <p className=\"min-w-0 flex-1 text-[12.5px] text-fg-2\">\n                      Couldn’t load {noun}. <span className=\"text-fg-3\">Check your connection.</span>\n                    </p>\n                    <button\n                      type=\"button\"\n                      onClick={() => s.retry()}\n                      className={cn(\n                        \"flex h-7 shrink-0 items-center gap-1.5 rounded-md border border-line-2 bg-frame px-2 text-[12px] font-medium text-fg hover:bg-hover\",\n                        \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                        \"transition-[background-color,scale] duration-150 active:scale-[0.96]\",\n                      )}\n                    >\n                      <Refresh size={14} />\n                      Try again\n                    </button>\n                  </div>\n                )}\n                {empty && (\n                  <p className=\"truncate px-3 py-5 text-center text-[12.5px] text-fg-3\">\n                    No {noun} match <span className=\"text-fg-2\">“{s.query}”</span>\n                  </p>\n                )}\n                {!typed && !suggestions.length && <p className=\"px-3 py-5 text-center text-[12.5px] text-fg-3\">Start typing to search.</p>}\n                <ComboboxPrimitive.List\n                  ref={setList}\n                  className={cn(\n                    \"relative max-h-[min(calc(var(--available-height)-3rem),18rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none empty:hidden\",\n                    \"transition-opacity duration-200\",\n                    (failed || firstLoad || empty) && \"hidden\",\n                    stale && \"opacity-60\",\n                  )}\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                    {(item: T) => (\n                      <ComboboxPrimitive.Item key={getKey(item)} value={item} className={itemClass}>\n                        <span className=\"flex min-w-0 flex-1 items-center gap-2\">{renderItem ? renderItem(item, s.query) : <span className=\"truncate\"><Highlight text={getLabel(item)} query={s.query} /></span>}</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                  </ComboboxPrimitive.Collection>\n                </ComboboxPrimitive.List>\n              </div>\n            </motion.div>\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 min-h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] text-fg outline-none select-none pointer-coarse:min-h-10\",\n  \"data-disabled:text-fg-4\",\n);\n\n// Rows shaped like results, held back until the request has taken longer than a blink.\nfunction Skeleton({ show }: { show: boolean }) {\n  return (\n    <div aria-hidden className={cn(\"flex flex-col gap-1 p-1 transition-opacity duration-200\", show ? \"opacity-100\" : \"opacity-0\")}>\n      {[62, 48, 70].map((w) => (\n        <div key={w} className=\"flex h-11 flex-col justify-center gap-1.5 px-2\">\n          <div className=\"h-2.5 rounded-full bg-line-2 animate-pulse-soft\" style={{ width: `${w}%` }} />\n          <div className=\"h-2 rounded-full bg-line animate-pulse-soft\" style={{ width: `${w - 22}%` }} />\n        </div>\n      ))}\n    </div>\n  );\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// Tracks an element's height so its wrapper can animate between sizes.\nfunction useHeight() {\n  const [el, ref] = useState<HTMLDivElement | null>(null);\n  const [height, setHeight] = useState<number | null>(null);\n  useEffect(() => {\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => setHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [el]);\n  return { ref, height };\n}\n\n// One highlight for the list: glides after the pointer, jumps for arrow keys and new results.\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      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      } else {\n        running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));\n      }\n      opacity.jump(1);\n      shown = true;\n    };\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/async-combobox"
}
