{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"combobox","type":"registry:component","title":"Combobox","description":"Pick one or several from a list your server searches: results morph between queries instead of flashing, and picks fly into chips.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/combobox.tsx","type":"registry:component","content":"// Hairline UI · combobox · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE, fly, play, snapshot, textRect, type Snapshot } from \"./combobox-motion\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * COMBOBOX: pick from a list too long to scroll, or one your\n * server has to search\n *\n *   open      the panel unfolds from the field's edge, clear of\n *             it, with the search field already focused; things\n *             you picked before come first\n *   search    your search() is called as you type: debounced,\n *             older requests aborted, answers cached so going\n *             back a letter is instant. While it asks, the old\n *             results stay (dimmed after a beat) and a hairline\n *             runs under the field; when the answer lands, rows\n *             that stay glide to their new places, new ones rise\n *             in and gone ones fade where they stood\n *   match     the letters you typed are picked out in each row\n *   pick      one: the panel folds and the label morphs. Several:\n *             the row's name is carried up into the chip it\n *             becomes, and a check draws in the row\n *   remove    a chip's × (or Backspace in an empty search) and\n *             the other chips glide into its place\n *   create    nothing fits: \"Create “design-review”\" runs your\n *             onCreate and picks what it returns\n *\n * The panel lives on the page's body, so no card or scroller\n * clips it; it follows the field when the page scrolls and\n * opens upward when there's no room below.\n * ───────────────────────────────────────────────────────── */\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  /** A second line: an email, a repository's owner, a count. */\n  description?: string;\n  /** An avatar picture. */\n  image?: string;\n  /** Letters for an avatar without a picture. */\n  initials?: string;\n  /** Any icon instead of an avatar. */\n  icon?: React.ReactNode;\n  /** More words a search should match (a team, an old name). */\n  keywords?: string[];\n  /** Rows with the same group sit together under its name. */\n  group?: string;\n  disabled?: boolean;\n}\n\ninterface ComboboxBase {\n  /** Accessible name, e.g. \"Assignee\". */\n  label: string;\n  /** What the field says while nothing is picked. */\n  placeholder?: string;\n  searchPlaceholder?: string;\n  /** A list to filter here. */\n  options?: ComboboxOption[];\n  /** Or ask your server. Called with \"\" when the panel opens; pass the signal on to fetch. */\n  search?: (query: string, context: { signal: AbortSignal }) => Promise<ComboboxOption[]>;\n  /** Milliseconds to wait after a keystroke before calling search. */\n  debounce?: number;\n  /** Shows \"Create …\" when nothing matches exactly; return the new option (or nothing to use the text). */\n  onCreate?: (query: string) => Promise<ComboboxOption | void> | ComboboxOption | void;\n  createLabel?: (query: string) => string;\n  emptyMessage?: (query: string) => string;\n  /** Rows to show at once; the rest wait for a narrower search. */\n  limit?: number;\n  /** \"field\" is a 38px form field; \"pill\" a 28px pill for sidebars and toolbars. */\n  variant?: \"field\" | \"pill\";\n  /** The panel's least width; it's never narrower than the field. */\n  width?: number;\n  /** Which edge of the field the panel lines up with. */\n  align?: \"start\" | \"end\";\n  /** A × that clears a single choice. */\n  clearable?: boolean;\n  /** Submits the chosen values with a form. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport interface ComboboxSingleProps extends ComboboxBase {\n  multiple?: false;\n  value?: ComboboxOption | null;\n  defaultValue?: ComboboxOption | null;\n  onValueChange?: (value: ComboboxOption | null) => void;\n}\n\nexport interface ComboboxMultipleProps extends ComboboxBase {\n  multiple: true;\n  value?: ComboboxOption[];\n  defaultValue?: ComboboxOption[];\n  onValueChange?: (value: ComboboxOption[]) => void;\n  /** How many can be picked. */\n  max?: number;\n}\n\nexport type ComboboxProps = ComboboxSingleProps | ComboboxMultipleProps;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Nav = { key: string; option?: ComboboxOption; create?: true };\ntype Flight = { value: string; rect: DOMRect; text: string; style: Partial<CSSStyleDeclaration> };\n\nconst GAP = 6;\nconst MAX_LIST = 300;\nconst SEARCH_H = 41;\nconst FOOTER_H = 37;\nconst CREATE_KEY = \"+create\";\nconst NONE: ComboboxOption[] = [];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n// The theme travels with the panel, so it matches the field wherever the field's colours were set.\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\n/* ── Matching ─────────────────────────────────────────── */\n\nconst fold = (s: string) => s.toLowerCase().normalize(\"NFKD\").replace(/[̀-ͯ]/g, \"\");\nconst terms = (q: string) => fold(q).split(/\\s+/).filter(Boolean);\nconst BOUNDARY = /[\\s\\-_/.@:#(]/;\n\n/** Lower is better: the label starts with it, a word in it does, it's inside it, or only the description has it. */\nfunction rank(option: ComboboxOption, words: string[]): number | null {\n  const label = fold(option.label);\n  const rest = fold([option.description ?? \"\", ...(option.keywords ?? []), option.value].join(\" \"));\n  let score = 0;\n  for (const w of words) {\n    const at = label.indexOf(w);\n    if (at === 0) continue;\n    if (at > 0) score += BOUNDARY.test(label[at - 1]) ? 1 : 2;\n    else if (rest.includes(w)) score += 3;\n    else return null;\n  }\n  return score;\n}\n\n/** Filters and orders a list the way the panel would; useful on a server too. */\nexport function filterOptions(options: ComboboxOption[], query: string): ComboboxOption[] {\n  const words = terms(query);\n  if (!words.length) return options;\n  return options\n    .map((option, i) => ({ option, i, score: rank(option, words) }))\n    .filter((x): x is { option: ComboboxOption; i: number; score: number } => x.score !== null)\n    .sort((a, b) => a.score - b.score || a.i - b.i)\n    .map((x) => x.option);\n}\n\n/** The label with the typed words picked out (a word start wins over a match mid-word). */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const words = terms(query);\n  const folded = fold(text);\n  if (!words.length || folded.length !== text.length) return <>{text}</>;\n  const hit = new Array<boolean>(text.length).fill(false);\n  for (const w of words) {\n    let at = -1;\n    for (let i = folded.indexOf(w); i !== -1; i = folded.indexOf(w, i + 1)) {\n      if (at === -1) at = i;\n      if (i === 0 || BOUNDARY.test(folded[i - 1])) {\n        at = i;\n        break;\n      }\n    }\n    if (at !== -1) for (let i = at; i < at + w.length; i++) hit[i] = true;\n  }\n  const parts: React.ReactNode[] = [];\n  let start = 0;\n  for (let i = 1; i <= text.length; i++) {\n    if (i === text.length || hit[i] !== hit[start]) {\n      const piece = text.slice(start, i);\n      parts.push(\n        hit[start] ? (\n          <span key={start} className=\"font-semibold text-foreground\">\n            {piece}\n          </span>\n        ) : (\n          piece\n        ),\n      );\n      start = i;\n    }\n  }\n  return <>{parts}</>;\n}\n\n/** Keeps each group's rows together, groups in the order they first appear. */\nfunction grouped(list: ComboboxOption[]) {\n  if (!list.some((o) => o.group)) return list;\n  const order: string[] = [];\n  for (const o of list) if (!order.includes(o.group ?? \"\")) order.push(o.group ?? \"\");\n  return order.flatMap((g) => list.filter((o) => (o.group ?? \"\") === g));\n}\n\nconst unique = (list: ComboboxOption[]) => {\n  const seen = new Set<string>();\n  return list.filter((o) => (seen.has(o.value) ? false : (seen.add(o.value), true)));\n};\n\n/* ── Small parts ──────────────────────────────────────── */\n\nconst hasLead = (o?: ComboboxOption | null) => Boolean(o && (o.icon || o.image || o.initials));\n\nfunction Lead({ option, size }: { option: ComboboxOption; size: number }) {\n  if (option.icon) return <span className=\"flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-full\" style={{ width: size, height: size }}>{option.icon}</span>;\n  if (option.image)\n    // eslint-disable-next-line @next/next/no-img-element\n    return <img src={option.image} alt=\"\" draggable={false} className=\"shrink-0 rounded-full object-cover\" style={{ width: size, height: size }} />;\n  return (\n    <span\n      className=\"flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}\n    >\n      {option.initials}\n    </span>\n  );\n}\n\n/** The single choice's avatar or icon: the slot opens and closes, and a new one scales in through a blur as the old one leaves. */\nfunction LeadSlot({ option, size, reduced }: { option: ComboboxOption | undefined; size: number; reduced: boolean }) {\n  const [out, setOut] = React.useState<ComboboxOption | null>(null);\n  const last = React.useRef(option);\n  const key = option?.value ?? \"\";\n  React.useLayoutEffect(() => {\n    const prev = last.current;\n    last.current = option;\n    if (!prev || prev.value === key || !hasLead(prev) || reduced) return;\n    setOut(prev);\n    const timer = window.setTimeout(() => setOut(null), 260);\n    return () => window.clearTimeout(timer);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [key, reduced]);\n  const on = hasLead(option);\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative flex shrink-0 items-center justify-center\"\n      style={{ width: on ? size : 0, height: size, marginRight: on ? 8 : 0, transition: reduced ? \"none\" : `width 380ms ${EASE}, margin 380ms ${EASE}` }}\n    >\n      {out && (\n        <span\n          key={`out-${out.value}`}\n          className=\"absolute inset-0 flex items-center justify-center\"\n          ref={(el) => {\n            if (!el || el.dataset.out) return;\n            el.dataset.out = \"1\";\n            el.animate(\n              [\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n                { opacity: 0, transform: \"scale(0.6)\", filter: \"blur(3px)\" },\n              ],\n              { duration: 220, easing: EASE, fill: \"forwards\" },\n            );\n          }}\n        >\n          <Lead option={out} size={size} />\n        </span>\n      )}\n      {on && option && (\n        <span\n          key={key}\n          className=\"absolute inset-0 flex items-center justify-center\"\n          ref={(el) => {\n            if (!el || reduced || el.dataset.in) return;\n            el.dataset.in = \"1\";\n            el.animate(\n              [\n                { opacity: 0, transform: \"scale(0.6)\", filter: \"blur(3px)\" },\n                { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n              ],\n              { duration: 380, delay: 60, easing: EASE, fill: \"backwards\" },\n            );\n          }}\n        >\n          <Lead option={option} size={size} />\n        </span>\n      )}\n    </span>\n  );\n}\n\nfunction Chevron({ up, reduced, className = \"\" }: { up: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={`shrink-0 text-muted-foreground ${className}`} style={{ transform: up ? \"rotate(180deg)\" : \"none\", transition: reduced ? \"none\" : `transform 320ms ${EASE}` }}>\n      <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction Cross({ className = \"size-3\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 12 12\" className={className} fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M3.5 3.5 8.5 8.5M8.5 3.5 3.5 8.5\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction Check({ drawn, reduced, className = \"size-3.5\" }: { drawn: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={`shrink-0 ${className}`}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.8\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{ strokeDashoffset: drawn ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset ${drawn ? 380 : 200}ms ${EASE} ${drawn ? 80 : 0}ms` }}\n      />\n    </svg>\n  );\n}\n\n/** A square for rows you can pick several of: it fills and its check draws itself. */\nfunction Box({ on, reduced }: { on: boolean; reduced: boolean }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-4 shrink-0 items-center justify-center rounded-[5px]\"\n      style={{\n        background: on ? \"var(--primary)\" : \"transparent\",\n        color: \"var(--primary-foreground)\",\n        boxShadow: on ? \"inset 0 0 0 1px var(--primary)\" : \"inset 0 0 0 1px color-mix(in oklab, var(--foreground) 22%, transparent)\",\n        transition: reduced ? \"none\" : `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,\n      }}\n    >\n      <Check drawn={on} reduced={reduced} className=\"size-3\" />\n    </span>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\">\n      <circle cx=\"7\" cy=\"7\" r=\"4.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"m10.25 10.25 3 3\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction AlertIcon() {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5 shrink-0\">\n      <circle cx=\"8\" cy=\"8\" r=\"6.25\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n      <path d=\"M8 4.75v3.75\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\n      <circle cx=\"8\" cy=\"11\" r=\"1\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nconst shake = (el: Element | null | undefined, reduced: boolean) => {\n  if (!el || reduced) return;\n  el.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], {\n    duration: 380,\n    easing: \"ease-out\",\n    composite: \"add\",\n  });\n};\n\n/* ── The combobox ─────────────────────────────────────── */\n\nexport function Combobox(props: ComboboxProps) {\n  const {\n    label,\n    placeholder = \"Choose\",\n    searchPlaceholder = \"Search\",\n    options,\n    search,\n    debounce = 180,\n    onCreate,\n    createLabel = (q: string) => `Create “${q}”`,\n    emptyMessage = (q: string) => (q ? `No matches for “${q}”` : \"Nothing to choose yet\"),\n    limit = 100,\n    variant = \"field\",\n    width = 280,\n    align = \"start\",\n    clearable = false,\n    name,\n    disabled = false,\n    className = \"\",\n  } = props;\n  const multiple = props.multiple === true;\n  const max = props.multiple ? props.max : undefined;\n  const uid = React.useId();\n  const reduced = useReducedMotion();\n\n  // What's picked, always a list inside.\n  const listOf = (v: ComboboxOption | ComboboxOption[] | null | undefined) => (v == null ? NONE : Array.isArray(v) ? v : [v]);\n  const [own, setOwn] = React.useState<ComboboxOption[]>(() => listOf(props.defaultValue));\n  const selected = props.value !== undefined ? listOf(props.value) : own;\n  const selectedKey = selected.map((o) => o.value).join(\"\\u0000\");\n  const picked = React.useMemo(() => new Set(selected.map((o) => o.value)), [selectedKey]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [fit, setFit] = React.useState({ x: 0, y: 0, w: width, up: false, room: 480, theme: {} as Record<string, string> });\n  const [query, setQuery] = React.useState(\"\");\n  const [results, setResults] = React.useState<ComboboxOption[] | null>(null);\n  const [resultsFor, setResultsFor] = React.useState<string | null>(null);\n  const [loading, setLoading] = React.useState(false);\n  const [failed, setFailed] = React.useState<string | null>(null);\n  const [retry, setRetry] = React.useState(0);\n  const [created, setCreated] = React.useState<ComboboxOption[]>([]);\n  const [creating, setCreating] = React.useState<\"idle\" | \"pending\" | \"error\">(\"idle\");\n  const [pinned, setPinned] = React.useState<ComboboxOption[]>([]);\n  const [activeKey, setActiveKey] = React.useState<string | null>(null);\n  const [mark, setMark] = React.useState({ y: 0, h: 0, on: false, slide: false });\n  const [listH, setListH] = React.useState(0);\n  const [sized, setSized] = React.useState(false);\n  const [fieldH, setFieldH] = React.useState(0);\n  const [ring, setRing] = React.useState(false);\n  const [notice, setNotice] = React.useState<string | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n\n  const anchorRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowGhosts = React.useRef<HTMLDivElement>(null);\n  const chipsRef = React.useRef<HTMLDivElement>(null);\n  const chipGhosts = React.useRef<HTMLDivElement>(null);\n  const cache = React.useRef(new Map<string, ComboboxOption[]>());\n  const searchRef = React.useRef(search);\n  const resultsForRef = React.useRef<string | null>(null);\n  const rowSnap = React.useRef<Snapshot | null>(null);\n  const chipSnap = React.useRef<Snapshot | null>(null);\n  const flight = React.useRef<Flight | null>(null);\n  const timer = React.useRef(0);\n\n  const mounted = phase !== \"closed\";\n  const expanded = phase === \"open\";\n  const async = Boolean(search);\n  const q = query.trim();\n  const panelId = `${uid}-panel`;\n  const listId = `${uid}-list`;\n\n  React.useLayoutEffect(() => {\n    searchRef.current = search;\n    resultsForRef.current = resultsFor;\n  });\n\n  const commit = (next: ComboboxOption[]) => {\n    if (props.value === undefined) setOwn(next);\n    if (props.multiple === true) props.onValueChange?.(next);\n    else (props as ComboboxSingleProps).onValueChange?.(next[0] ?? null);\n  };\n\n  /* ── What the list shows ── */\n\n  const pool = React.useMemo(() => {\n    const extra = filterOptions(created, query);\n    // Rows for an older query stay while a newer one is asked, but not under its error.\n    const answer = failed && resultsFor !== q ? NONE : (results ?? NONE);\n    const base = async ? unique([...extra, ...answer]) : filterOptions(unique([...created, ...(options ?? [])]), query);\n    if (q || !pinned.length) return grouped(base);\n    // Before you type, what you'd already picked comes first, in the order it was when the panel opened.\n    const first = pinned.map((p) => base.find((o) => o.value === p.value) ?? p);\n    const ids = new Set(first.map((o) => o.value));\n    return [...first, ...grouped(base.filter((o) => !ids.has(o.value)))];\n  }, [async, results, resultsFor, failed, options, created, query, q, pinned]);\n  const shown = pool.length > limit ? pool.slice(0, limit) : pool;\n  const more = pool.length - shown.length;\n  const canCreate = Boolean(onCreate && q && !pool.some((o) => fold(o.label) === fold(q)));\n  const firstLoad = async && results === null && !failed;\n  const nav: Nav[] = React.useMemo(() => {\n    const out: Nav[] = shown.filter((o) => !o.disabled).map((o) => ({ key: o.value, option: o }));\n    if (canCreate) out.push({ key: CREATE_KEY, create: true });\n    return out;\n  }, [shown, canCreate]);\n  const activeIndex = Math.max(0, nav.findIndex((n) => n.key === activeKey));\n  const active = nav.length ? nav[activeIndex] : undefined;\n  const rowsKey = [\n    ...shown.map((o) => o.value),\n    canCreate ? CREATE_KEY : \"\",\n    firstLoad ? \"!loading\" : \"\",\n    failed ? \"!error\" : \"\",\n    !shown.length && !canCreate && !firstLoad && !failed ? \"!empty\" : \"\",\n    more ? \"!more\" : \"\",\n  ].join(\"\\u0000\");\n  const stale = async && loading && resultsFor !== null && resultsFor !== q;\n\n  /* ── Panel placement ── */\n\n  const place = React.useCallback(() => {\n    const anchor = anchorRef.current;\n    if (!anchor) return;\n    const r = anchor.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const vh = window.innerHeight;\n    const w = Math.min(Math.max(width, r.width), vw - 16);\n    const x = Math.min(Math.max(8, align === \"start\" ? r.left : r.right - w), vw - 8 - w);\n    const below = vh - r.bottom - GAP - 8;\n    const above = r.top - GAP - 8;\n    const up = below < 240 && above > below;\n    const cs = getComputedStyle(anchor);\n    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ x, y: up ? vh - r.top + GAP : r.bottom + GAP, w, up, room: up ? above : below, theme });\n  }, [width, align]);\n\n  const open = (seed = \"\") => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    place();\n    setQuery(seed);\n    const hit = async ? cache.current.get(seed.trim()) : undefined;\n    setResults(hit ?? null);\n    setResultsFor(hit ? seed.trim() : null);\n    setFailed(null);\n    setPinned(multiple ? selected : NONE);\n    setActiveKey(!multiple && selected[0] ? selected[0].value : null);\n    setNotice(null);\n    setCreating(\"idle\");\n    setSized(false);\n    setMark({ y: 0, h: 0, on: false, slide: false });\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), 240);\n    },\n    [reduced],\n  );\n\n  const toggle = () => (phase === \"open\" || phase === \"opening\" ? close() : open());\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    if (phase === \"opening\" || (reduced && phase === \"open\")) inputRef.current?.focus({ preventScroll: true });\n  }, [phase, reduced]);\n\n  // Outside presses close it; the page scrolling or resizing carries it along with the field.\n  React.useEffect(() => {\n    if (!mounted) return;\n    const inside = (n: Node) => Boolean(anchorRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    let frame = 0;\n    const follow = (e: Event) => {\n      if (e.target instanceof Node && surfaceRef.current?.contains(e.target)) return;\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const r = anchorRef.current?.getBoundingClientRect();\n        if (!r || r.bottom < 0 || r.top > window.innerHeight) close(false);\n        else place();\n      });\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", follow, true);\n    window.addEventListener(\"resize\", follow);\n    // A field that grows (chips wrapping onto a new line) pushes the panel down with it.\n    const resized = new ResizeObserver(() => place());\n    if (anchorRef.current) resized.observe(anchorRef.current);\n    return () => {\n      resized.disconnect();\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", follow, true);\n      window.removeEventListener(\"resize\", follow);\n    };\n  }, [mounted, close, place]);\n\n  /* ── Asking the server ── */\n\n  const show = (list: ComboboxOption[], forQuery: string) => {\n    if (!reduced) rowSnap.current = snapshot(listRef.current, \"data-row\");\n    setResults(list);\n    setResultsFor(forQuery);\n  };\n\n  React.useEffect(() => {\n    const ask = searchRef.current;\n    if (!mounted || !ask) return;\n    const hit = cache.current.get(q);\n    if (hit) {\n      if (resultsForRef.current !== q) show(hit, q);\n      setLoading(false);\n      setFailed(null);\n      return;\n    }\n    const controller = new AbortController();\n    setLoading(true);\n    const wait = window.setTimeout(\n      () => {\n        ask(q, { signal: controller.signal }).then(\n          (list) => {\n            if (controller.signal.aborted) return;\n            cache.current.set(q, list);\n            // A small memory: the oldest answers go first.\n            if (cache.current.size > 40) cache.current.delete(cache.current.keys().next().value!);\n            show(list, q);\n            setLoading(false);\n            setFailed(null);\n          },\n          (err: unknown) => {\n            if (controller.signal.aborted || (err instanceof Error && err.name === \"AbortError\")) return;\n            if (!reduced) rowSnap.current = snapshot(listRef.current, \"data-row\");\n            setLoading(false);\n            setFailed(err instanceof Error && err.message ? err.message : \"Couldn’t load results.\");\n          },\n        );\n      },\n      resultsForRef.current === null ? 0 : debounce,\n    );\n    return () => {\n      window.clearTimeout(wait);\n      controller.abort();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [q, mounted, retry, debounce]);\n\n  // Rows that changed: glide, rise in, fade out (from positions taken right before the change).\n  React.useLayoutEffect(() => {\n    const snap = rowSnap.current;\n    rowSnap.current = null;\n    if (snap) play(listRef.current, \"data-row\", snap, rowGhosts.current);\n  }, [rowsKey]);\n\n  // The count, said once typing settles.\n  React.useEffect(() => {\n    if (!expanded || !q || firstLoad) return;\n    const t = window.setTimeout(() => setAnnounce(shown.length ? `${pool.length} ${pool.length === 1 ? \"result\" : \"results\"}` : emptyMessage(q)), 500);\n    return () => window.clearTimeout(t);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [rowsKey, expanded]);\n\n  /* ── List height and the highlight ── */\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list || !mounted) return;\n    const measure = () => setListH((h) => (h === list.offsetHeight ? h : list.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [mounted]);\n  React.useEffect(() => {\n    if (expanded && listH && !sized) {\n      const frame = requestAnimationFrame(() => setSized(true));\n      return () => cancelAnimationFrame(frame);\n    }\n  }, [expanded, listH, sized]);\n\n  React.useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list || !mounted) return;\n    const row = active ? list.querySelector<HTMLElement>(`[data-nav=\"${activeIndex}\"]`) : null;\n    if (!row) {\n      setMark((m) => (m.on ? { ...m, on: false, slide: false } : m));\n      return;\n    }\n    setMark((m) => (m.y === row.offsetTop && m.h === row.offsetHeight && m.on ? m : { y: row.offsetTop, h: row.offsetHeight, on: true, slide: m.on }));\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;\n      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 8;\n    }\n  }, [activeIndex, active, rowsKey, mounted, listH]);\n\n  /* ── Chips ── */\n\n  React.useLayoutEffect(() => {\n    const chips = chipsRef.current;\n    if (!chips) return;\n    const measure = () => setFieldH((h) => (h === chips.offsetHeight ? h : chips.offsetHeight));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(chips);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const snap = chipSnap.current;\n    chipSnap.current = null;\n    if (snap) play(chipsRef.current, \"data-chip\", snap, chipGhosts.current, { enter: \"pop\", duration: 340 });\n    const f = flight.current;\n    flight.current = null;\n    const to = f && chipsRef.current?.querySelector<HTMLElement>(`[data-chip=\"${CSS.escape(f.value)}\"] [data-chip-label]`);\n    if (f && to) fly(f, to);\n  }, [selectedKey]);\n\n  // Declared after the effects that use them: a snapshot no change used (the same rows came back) would be stale by the next one.\n  React.useLayoutEffect(() => {\n    rowSnap.current = null;\n    chipSnap.current = null;\n    flight.current = null;\n  });\n\n  /* ── Picking ── */\n\n  const pick = (option: ComboboxOption, row?: HTMLElement | null) => {\n    if (option.disabled) return;\n    if (!multiple) {\n      if (option.value !== selected[0]?.value) commit([option]);\n      setAnnounce(`${option.label} selected`);\n      close();\n      return;\n    }\n    const on = picked.has(option.value);\n    if (!on && max !== undefined && selected.length >= max) {\n      setNotice(`You can pick up to ${max}.`);\n      shake(row, reduced);\n      return;\n    }\n    if (!reduced) chipSnap.current = snapshot(chipsRef.current, \"data-chip\");\n    const label = row?.querySelector<HTMLElement>(\"[data-label]\");\n    if (!on && label && variant === \"field\" && !reduced) {\n      const cs = getComputedStyle(label);\n      flight.current = { value: option.value, rect: textRect(label), text: option.label, style: { font: cs.font, color: cs.color, letterSpacing: cs.letterSpacing } };\n    }\n    const next = on ? selected.filter((o) => o.value !== option.value) : [...selected, option];\n    commit(next);\n    setNotice(null);\n    setAnnounce(`${option.label} ${on ? \"removed\" : \"added\"}. ${next.length} selected.`);\n  };\n\n  const remove = (option: ComboboxOption) => {\n    if (!reduced) chipSnap.current = snapshot(chipsRef.current, \"data-chip\");\n    const next = selected.filter((o) => o.value !== option.value);\n    commit(next);\n    setNotice(null);\n    setAnnounce(`${option.label} removed. ${next.length} selected.`);\n  };\n\n  const clear = () => {\n    if (!reduced) chipSnap.current = snapshot(chipsRef.current, \"data-chip\");\n    commit(NONE);\n    setNotice(null);\n    setAnnounce(\"Cleared\");\n  };\n\n  const create = async () => {\n    if (!onCreate || !q || creating === \"pending\") return;\n    const row = listRef.current?.querySelector<HTMLElement>(`[data-row=\"${CREATE_KEY}\"]`);\n    setCreating(\"pending\");\n    setNotice(null);\n    try {\n      const made = await onCreate(q);\n      const option = made && typeof made === \"object\" ? made : { value: q, label: q };\n      setCreated((list) => [option, ...list.filter((o) => o.value !== option.value)]);\n      setCreating(\"idle\");\n      setActiveKey(option.value);\n      pick(option, row);\n    } catch (err) {\n      setCreating(\"error\");\n      setNotice(err instanceof Error && err.message ? err.message : `Couldn’t create “${q}”.`);\n      shake(row, reduced);\n    }\n  };\n\n  const onInputKey = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.nativeEvent.isComposing) return;\n    const go = (i: number) => {\n      e.preventDefault();\n      if (nav.length) setActiveKey(nav[((i % nav.length) + nav.length) % nav.length].key);\n    };\n    if (e.key === \"ArrowDown\") go(activeIndex + 1);\n    else if (e.key === \"ArrowUp\") go(activeIndex - 1);\n    else if (e.key === \"PageDown\") go(Math.min(nav.length - 1, activeIndex + 8));\n    else if (e.key === \"PageUp\") go(Math.max(0, activeIndex - 8));\n    else if (e.key === \"Enter\") {\n      e.preventDefault();\n      if (!active) return;\n      if (active.create) void create();\n      else if (active.option) pick(active.option, listRef.current?.querySelector<HTMLElement>(`[data-nav=\"${activeIndex}\"]`));\n    } else if (e.key === \"Escape\") {\n      e.preventDefault();\n      e.stopPropagation();\n      close();\n    } else if (e.key === \"Tab\") {\n      e.preventDefault();\n      close();\n    } else if (e.key === \"Backspace\" && multiple && !query && selected.length) {\n      e.preventDefault();\n      remove(selected[selected.length - 1]);\n    }\n  };\n\n  const onTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      open();\n    } else if (e.key.length === 1 && /\\S/.test(e.key) && !e.metaKey && !e.ctrlKey && !e.altKey) {\n      // Typing on the closed field opens it with what you typed.\n      e.preventDefault();\n      open(e.key);\n    } else if (e.key === \"Backspace\" && multiple && selected.length && !mounted) {\n      e.preventDefault();\n      remove(selected[selected.length - 1]);\n    }\n  };\n\n  /* ── The field ── */\n\n  const single = selected[0];\n  const pill = variant === \"pill\";\n  const summary = !selected.length ? placeholder : multiple ? (selected.length === 1 ? selected[0].label : `${selected[0].label} +${selected.length - 1}`) : single!.label;\n  const triggerName = `${label}: ${selected.length ? selected.map((o) => o.label).join(\", \") : \"none\"}`;\n  const ringStyle = mounted || ring ? \"color-mix(in oklab, var(--ring) 55%, var(--border))\" : \"var(--border)\";\n  const triggerProps = {\n    ref: triggerRef,\n    type: \"button\" as const,\n    disabled,\n    \"aria-haspopup\": \"dialog\" as const,\n    \"aria-expanded\": expanded,\n    \"aria-controls\": mounted ? panelId : undefined,\n    \"aria-label\": triggerName,\n    onKeyDown: onTriggerKey,\n    onFocus: (e: React.FocusEvent<HTMLButtonElement>) => setRing(e.currentTarget.matches(\":focus-visible\")),\n    onBlur: () => setRing(false),\n  };\n\n  let field: React.ReactNode;\n  if (multiple && !pill) {\n    field = (\n      <div\n        onMouseDown={(e) => {\n          if (disabled || e.button !== 0 || (e.target as HTMLElement).closest(\"[data-chip-remove]\")) return;\n          e.preventDefault();\n          toggle();\n        }}\n        className={`rounded-[19px] border bg-background transition-[border-color] duration-200 ${disabled ? \"cursor-default opacity-60\" : \"cursor-pointer\"}`}\n        style={{ borderColor: ringStyle }}\n      >\n        {/* clip, not hidden: hidden boxes still scroll, and focus would scroll the chips away. */}\n        <div className=\"overflow-clip\" style={{ height: fieldH || undefined, transition: reduced || !fieldH ? \"none\" : `height 320ms ${EASE}` }}>\n          <div ref={chipsRef} className=\"relative flex flex-wrap items-center gap-1.5 py-[5px] pl-[5px] pr-8\">\n            {selected.map((o) => (\n              <span\n                key={o.value}\n                data-chip={o.value}\n                className=\"relative z-[1] inline-flex h-7 max-w-full shrink-0 items-center rounded-full bg-background text-[13px] shadow-[inset_0_0_0_1px_var(--border)]\"\n              >\n                {hasLead(o) && (\n                  <span className=\"ml-1 flex\">\n                    <Lead option={o} size={20} />\n                  </span>\n                )}\n                <span data-chip-label=\"\" className=\"max-w-[12rem] truncate py-1\" style={{ marginLeft: hasLead(o) ? 6 : 10, marginRight: 2 }}>\n                  {o.label}\n                </span>\n                <button\n                  type=\"button\"\n                  data-chip-remove=\"\"\n                  tabIndex={-1}\n                  aria-label={`Remove ${o.label}`}\n                  disabled={disabled}\n                  onMouseDown={(e) => e.preventDefault()}\n                  onClick={() => remove(o)}\n                  className=\"relative mr-1 flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-default\"\n                >\n                  <Cross />\n                </button>\n              </span>\n            ))}\n            <button\n              {...triggerProps}\n              onClick={(e) => {\n                // The field's own press handles the mouse; this is Enter and Space.\n                if (e.detail === 0) toggle();\n              }}\n              // With chips it lies behind them across the whole field, so it never needs a line of its own.\n              className={`cursor-pointer text-left text-[14px] outline-none disabled:cursor-default ${selected.length ? \"absolute inset-0\" : \"flex h-7 min-w-[7rem] flex-1 items-center pl-1.5\"}`}\n            >\n              {!selected.length && <span className=\"min-w-0 flex-1 truncate text-muted-foreground\">{placeholder}</span>}\n            </button>\n            {/* In the first line's corner, so it never wraps onto a line of its own. */}\n            <Chevron up={expanded} reduced={reduced} className=\"pointer-events-none absolute right-3 top-[12px] size-3.5\" />\n            <div ref={chipGhosts} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n          </div>\n        </div>\n      </div>\n    );\n  } else {\n    const leadSize = pill ? 18 : 22;\n    field = (\n      <button\n        {...triggerProps}\n        onClick={toggle}\n        className={`relative flex w-full min-w-0 cursor-pointer items-center whitespace-nowrap text-left transition-[background-color,border-color] duration-200 disabled:cursor-default disabled:opacity-60 ${\n          pill\n            ? `h-7 rounded-full px-2.5 text-[12px] font-medium shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent ${FOCUS} ${expanded ? \"bg-accent\" : \"\"}`\n            : \"h-[38px] rounded-[19px] border bg-background pl-3 pr-2.5 text-[14px] outline-none\"\n        }`}\n        style={pill ? undefined : { borderColor: ringStyle }}\n      >\n        {!multiple && <LeadSlot option={single} size={leadSize} reduced={reduced} />}\n        <span className={`min-w-0 flex-1 overflow-hidden ${selected.length ? \"text-foreground\" : \"text-muted-foreground\"}`}>\n          <TextMorph>{summary}</TextMorph>\n        </span>\n        {/* Room for the clear button, which can't live inside this one. */}\n        {clearable && !multiple && <span aria-hidden=\"true\" className=\"shrink-0\" style={{ width: single ? 22 : 0, transition: reduced ? \"none\" : `width 320ms ${EASE}` }} />}\n        <Chevron up={expanded} reduced={reduced} className={pill ? \"ml-1.5 size-3\" : \"ml-2 size-3.5\"} />\n      </button>\n    );\n  }\n\n  /* ── The panel ── */\n\n  const maxList = Math.max(120, Math.min(MAX_LIST, fit.room - SEARCH_H - (multiple ? FOOTER_H : 0) - 10));\n  let navIndex = 0;\n  let lastGroup: string | undefined;\n  const rows: React.ReactNode[] = [];\n  const pinnedFirst = !q && pinned.length > 0;\n  const hasGroups = shown.some((o) => o.group);\n  shown.forEach((o, index) => {\n    const isPinned = pinnedFirst && index < pinned.length;\n    if (isPinned && index === 0 && hasGroups)\n      rows.push(\n        <div key=\"#selected\" data-row=\"#selected\" role=\"presentation\" className=\"px-2 pb-1 pt-1 text-[11.5px] font-medium text-muted-foreground\">\n          Selected\n        </div>,\n      );\n    if (o.group && o.group !== lastGroup && !isPinned)\n      rows.push(\n        <div key={`#${o.group}`} data-row={`#${o.group}`} role=\"presentation\" className=\"px-2 pb-1 pt-2.5 text-[11.5px] font-medium text-muted-foreground first:pt-1\">\n          {o.group}\n        </div>,\n      );\n    if (!isPinned) lastGroup = o.group;\n    const on = picked.has(o.value);\n    const i = o.disabled ? -1 : navIndex++;\n    rows.push(\n      <div\n        key={o.value}\n        data-row={o.value}\n        data-nav={i === -1 ? undefined : i}\n        id={i === -1 ? undefined : `${uid}-o${i}`}\n        role=\"option\"\n        aria-selected={on}\n        aria-disabled={o.disabled || undefined}\n        onPointerMove={() => {\n          if (!o.disabled && activeKey !== o.value) setActiveKey(o.value);\n        }}\n        onClick={(e) => pick(o, e.currentTarget)}\n        className={`relative flex min-h-9 items-center gap-2.5 rounded-[10px] px-2 py-1.5 ${o.disabled ? \"cursor-default opacity-45\" : \"cursor-pointer\"}`}\n      >\n        {multiple && <Box on={on} reduced={reduced} />}\n        {hasLead(o) && <Lead option={o} size={22} />}\n        <span className=\"min-w-0 flex-1\">\n          <span data-label=\"\" className=\"block truncate text-[13.5px] leading-tight\">\n            <Highlight text={o.label} query={query} />\n          </span>\n          {o.description && <span className=\"mt-0.5 block truncate text-[12px] leading-tight text-muted-foreground\">{o.description}</span>}\n        </span>\n        {!multiple && <Check drawn={on && expanded} reduced={reduced} />}\n      </div>,\n    );\n  });\n  if (canCreate) {\n    const i = navIndex++;\n    rows.push(\n      <div\n        key={CREATE_KEY}\n        data-row={CREATE_KEY}\n        data-nav={i}\n        id={`${uid}-o${i}`}\n        role=\"option\"\n        aria-selected={false}\n        onPointerMove={() => activeKey !== CREATE_KEY && setActiveKey(CREATE_KEY)}\n        onClick={() => void create()}\n        className={`relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[10px] px-2 py-1.5 text-[13.5px] ${creating === \"error\" ? \"text-red-600 dark:text-red-400\" : \"\"}`}\n      >\n        <span className=\"relative flex size-[22px] shrink-0 items-center justify-center rounded-full text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n          <span className=\"absolute inset-0 flex items-center justify-center\" style={{ opacity: creating === \"pending\" ? 0 : 1, transition: `opacity 200ms ${EASE}` }}>\n            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" aria-hidden=\"true\">\n              <path d=\"M6 2.5v7M2.5 6h7\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n            </svg>\n          </span>\n          <span\n            className={`size-3 rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none ${creating === \"pending\" ? \"animate-spin\" : \"\"}`}\n            style={{ opacity: creating === \"pending\" ? 1 : 0, transition: `opacity 200ms ${EASE}` }}\n          />\n        </span>\n        <span data-label=\"\" className=\"min-w-0 truncate\">\n          <TextMorph>{creating === \"pending\" ? `Creating “${q}”` : createLabel(q)}</TextMorph>\n        </span>\n      </div>,\n    );\n  }\n  if (firstLoad)\n    rows.push(\n      <div key=\"!loading\" data-row=\"!loading\" role=\"presentation\" className=\"flex min-h-9 items-center gap-2.5 px-2 text-[13px] text-muted-foreground\">\n        <span className=\"size-3 rounded-full border-[1.5px] border-current border-t-transparent motion-safe:animate-spin\" />\n        Searching\n      </div>,\n    );\n  else if (failed)\n    rows.push(\n      <div key=\"!error\" data-row=\"!error\" role=\"presentation\" className=\"flex min-h-10 items-center gap-2 px-2 text-[13px] text-red-600 dark:text-red-400\">\n        <AlertIcon />\n        <span className=\"min-w-0 flex-1 truncate\">{failed}</span>\n        <StatusButton\n          variant=\"ghost\"\n          size=\"sm\"\n          status={loading ? \"pending\" : \"idle\"}\n          labels={{ idle: \"Retry\", pending: \"Retrying\" }}\n          onClick={() => {\n            setFailed(null);\n            setRetry((r) => r + 1);\n          }}\n        />\n      </div>,\n    );\n  else if (!shown.length && !canCreate)\n    rows.push(\n      <div key=\"!empty\" data-row=\"!empty\" role=\"presentation\" className=\"flex min-h-9 items-center px-2 text-[13px] text-muted-foreground\">\n        {emptyMessage(q)}\n      </div>,\n    );\n  if (more)\n    rows.push(\n      <div key=\"!more\" data-row=\"!more\" role=\"presentation\" className=\"px-2 pb-1.5 pt-2 text-[12px] text-muted-foreground\">\n        Showing {shown.length} of {pool.length}. Keep typing to narrow it down.\n      </div>,\n    );\n\n  const panel = mounted\n    ? createPortal(\n        <div\n          ref={surfaceRef}\n          id={panelId}\n          role=\"dialog\"\n          aria-label={label}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              close();\n            }\n          }}\n          onMouseDown={(e) => {\n            // Presses in the panel keep focus in the search field.\n            // (The scroller itself is only the target when its scrollbar is pressed.)\n            if (!(e.target as HTMLElement).matches(\"input, [data-scroll-bar]\")) e.preventDefault();\n          }}\n          className={`fixed z-[60] flex overflow-hidden rounded-[14px] border border-border bg-popover text-popover-foreground ${fit.up ? \"flex-col-reverse\" : \"flex-col\"}`}\n          style={{\n            ...fit.theme,\n            left: fit.x,\n            [fit.up ? \"bottom\" : \"top\"]: fit.y,\n            width: fit.w,\n            clipPath: expanded ? \"inset(0px 0px 0px 0px round 14px)\" : fit.up ? \"inset(100% 0px 0px 0px round 14px)\" : \"inset(0px 0px 100% 0px round 14px)\",\n            opacity: expanded ? 1 : 0,\n            transition: reduced\n              ? \"none\"\n              : phase === \"closing\"\n                ? `clip-path 220ms ${EASE}, opacity 180ms ${EASE} 40ms`\n                : `clip-path 360ms ${EASE}, opacity 200ms ${EASE}`,\n          }}\n        >\n          <div className={`relative flex shrink-0 items-center gap-2 px-3 ${fit.up ? \"border-t\" : \"border-b\"} border-border`} style={{ height: SEARCH_H - 1 }}>\n            <SearchIcon />\n            <input\n              ref={inputRef}\n              role=\"combobox\"\n              aria-expanded={expanded}\n              aria-controls={listId}\n              aria-autocomplete=\"list\"\n              aria-activedescendant={active ? `${uid}-o${activeIndex}` : undefined}\n              aria-label={`Search ${label.toLowerCase()}`}\n              aria-busy={loading || undefined}\n              value={query}\n              placeholder={searchPlaceholder}\n              autoComplete=\"off\"\n              spellCheck={false}\n              onChange={(e) => {\n                if (!async && !reduced) rowSnap.current = snapshot(listRef.current, \"data-row\");\n                setQuery(e.target.value);\n                if (creating === \"error\") setCreating(\"idle\");\n                setNotice(null);\n              }}\n              onKeyDown={onInputKey}\n              className=\"h-full min-w-0 flex-1 bg-transparent text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground [@media(pointer:coarse)]:text-[16px]\"\n            />\n            {/* A hairline that runs along the edge while the server is asked. */}\n            <span\n              aria-hidden=\"true\"\n              className={`pointer-events-none absolute inset-x-0 h-px overflow-hidden ${fit.up ? \"top-[-1px]\" : \"bottom-[-1px]\"}`}\n              style={{ opacity: loading ? 1 : 0, transition: `opacity ${loading ? 200 : 400}ms ${EASE} ${loading ? 150 : 0}ms` }}\n            >\n              <span className={`absolute inset-y-0 left-0 w-2/5 bg-primary/70 ${loading && !reduced ? \"animate-[ui-scan_1.1s_cubic-bezier(0.45,0,0.55,1)_infinite]\" : \"\"}`} />\n            </span>\n          </div>\n\n          <div data-scroll-bar=\"\" className=\"overflow-y-auto overscroll-contain p-1\" style={{ height: listH ? Math.min(listH + 8, maxList) : undefined, transition: sized && !reduced ? `height 280ms ${EASE}` : \"none\" }}>\n            <div\n              ref={listRef}\n              id={listId}\n              role=\"listbox\"\n              aria-label={label}\n              aria-multiselectable={multiple || undefined}\n              className=\"relative\"\n              style={{ opacity: stale ? 0.55 : 1, transition: `opacity ${stale ? 240 : 160}ms ${EASE} ${stale ? 140 : 0}ms` }}\n            >\n              <span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[10px] bg-accent\"\n                style={{\n                  height: mark.h,\n                  transform: `translateY(${mark.y}px)`,\n                  opacity: mark.on ? 1 : 0,\n                  transition: reduced ? \"none\" : `${mark.slide ? `transform 220ms ${EASE}, height 220ms ${EASE}, ` : \"\"}opacity 160ms ${EASE}`,\n                }}\n              />\n              {rows}\n              <div ref={rowGhosts} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n            </div>\n          </div>\n\n          {multiple && (\n            <div className={`flex shrink-0 items-center justify-between gap-3 px-3 text-[12px] text-muted-foreground ${fit.up ? \"border-b\" : \"border-t\"} border-border`} style={{ height: FOOTER_H }}>\n              <span className={`min-w-0 truncate tabular-nums ${notice ? \"text-amber-700 dark:text-amber-400\" : \"\"}`}>\n                {notice ? (\n                  <span key={notice} className=\"inline-block animate-[ui-fade-in_320ms_cubic-bezier(0.16,1,0.3,1)_both]\">\n                    {notice}\n                  </span>\n                ) : (\n                  <span className=\"inline-flex items-baseline\">\n                    <NumberRoll value={selected.length} duration={600} />\n                    {max !== undefined && <span className=\"ml-1\">of {max}</span>}\n                    <span className=\"ml-1\">selected</span>\n                  </span>\n                )}\n              </span>\n              <span className=\"flex shrink-0 items-center gap-0.5\">\n                <span\n                  className=\"grid\"\n                  style={{ gridTemplateColumns: selected.length ? \"1fr\" : \"0fr\", opacity: selected.length ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 320ms ${EASE}, opacity 200ms ${EASE}` }}\n                >\n                  <span className=\"min-w-0 overflow-hidden\">\n                    <button\n                      type=\"button\"\n                      tabIndex={selected.length ? 0 : -1}\n                      onClick={clear}\n                      className={`inline-flex h-6 cursor-pointer items-center rounded-full px-2 font-medium transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}\n                    >\n                      Clear\n                    </button>\n                  </span>\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => close()}\n                  className={`inline-flex h-6 cursor-pointer items-center rounded-full px-2 font-medium text-foreground transition-colors hover:bg-accent ${FOCUS}`}\n                >\n                  Done\n                </button>\n              </span>\n            </div>\n          )}\n        </div>,\n        document.body,\n      )\n    : null;\n\n  return (\n    <div ref={anchorRef} className={`relative ${pill ? \"inline-flex max-w-full\" : \"w-full\"} ${className}`}>\n      {field}\n      {clearable && !multiple && (\n        <button\n          type=\"button\"\n          aria-label={`Clear ${label.toLowerCase()}`}\n          tabIndex={single ? 0 : -1}\n          disabled={disabled || !single}\n          onClick={() => {\n            commit(NONE);\n            setAnnounce(\"Cleared\");\n            triggerRef.current?.focus({ preventScroll: true });\n          }}\n          className={`absolute top-1/2 flex size-5 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS} ${pill ? \"right-6\" : \"right-8\"}`}\n          style={{\n            opacity: single ? 1 : 0,\n            filter: single ? \"none\" : \"blur(3px)\",\n            pointerEvents: single ? undefined : \"none\",\n            transition: reduced ? \"none\" : `opacity 240ms ${EASE}, filter 240ms ${EASE}`,\n          }}\n        >\n          <Cross />\n        </button>\n      )}\n      {panel}\n      {name && (selected.length ? selected.map((o) => <input key={o.value} type=\"hidden\" name={name} value={o.value} />) : <input type=\"hidden\" name={name} value=\"\" />)}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/combobox-motion.ts","type":"registry:component","content":"// Hairline UI · combobox · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * COMBOBOX MOTION: how rows, chips and picked labels move\n *\n *   rows     results that change (a new query, an answer from\n *            the server) don't flash: rows that stay glide to\n *            their new place, new ones rise in, and gone ones\n *            fade where they stood (their own detached elements,\n *            so leaving costs no render)\n *   chips    removing one glides the rest into the gap\n *   flight   a picked option's label is carried from its row to\n *            the chip it becomes\n *\n * Positions are layout offsets taken right before the change,\n * and glides are additive, so a change mid-glide never jumps.\n * ───────────────────────────────────────────────────────── */\n\nexport const EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\ntype Seen = { x: number; y: number; w: number; el: HTMLElement };\nexport type Snapshot = Map<string, Seen>;\n\n/** Layout offsets (transforms ignored) of every element carrying the attribute, by its value. */\nexport function snapshot(root: HTMLElement | null, attr: string): Snapshot {\n  const out: Snapshot = new Map();\n  root?.querySelectorAll<HTMLElement>(`[${attr}]`).forEach((el) => out.set(el.getAttribute(attr)!, { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, el }));\n  return out;\n}\n\n/**\n * After a render: glide what moved, raise what's new, and fade what left in `ghosts` (a layer React never renders into).\n * `enter` decides what new elements do; pass null to let them simply appear.\n */\nexport function play(\n  root: HTMLElement | null,\n  attr: string,\n  before: Snapshot,\n  ghosts: HTMLElement | null,\n  { enter = \"rise\", duration = 300 }: { enter?: \"rise\" | \"pop\" | null; duration?: number } = {},\n) {\n  if (!root) return;\n  const present = new Set<string>();\n  let order = 0;\n  root.querySelectorAll<HTMLElement>(`[${attr}]`).forEach((el) => {\n    const key = el.getAttribute(attr)!;\n    present.add(key);\n    const was = before.get(key);\n    if (was) {\n      const dx = was.x - el.offsetLeft;\n      const dy = was.y - el.offsetTop;\n      if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5)\n        el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: \"translate(0px, 0px)\" }], { duration, easing: EASE, composite: \"add\" });\n    } else if (enter === \"rise\") {\n      el.animate(\n        [\n          { opacity: 0, transform: \"translateY(4px)\", filter: \"blur(3px)\" },\n          { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        ],\n        { duration: 280, delay: Math.min(order++ * 14, 140), easing: EASE, fill: \"backwards\" },\n      );\n    } else if (enter === \"pop\") {\n      el.animate(\n        [\n          { opacity: 0, transform: \"scale(0.9)\" },\n          { opacity: 1, transform: \"none\" },\n        ],\n        { duration: 260, easing: EASE, fill: \"backwards\" },\n      );\n    }\n  });\n  if (!ghosts) return;\n  before.forEach((was, key) => {\n    if (present.has(key) || was.el.isConnected) return;\n    const el = was.el;\n    el.removeAttribute(\"id\");\n    el.removeAttribute(attr);\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.style.position = \"absolute\";\n    el.style.margin = \"0\";\n    el.style.left = `${was.x}px`;\n    el.style.top = `${was.y}px`;\n    el.style.width = `${was.w}px`;\n    el.style.pointerEvents = \"none\";\n    ghosts.appendChild(el);\n    el.animate(\n      [\n        { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n        { opacity: 0, transform: \"scale(0.94)\", filter: \"blur(2px)\" },\n      ],\n      { duration: 180, easing: EASE, fill: \"forwards\" },\n    ).onfinish = () => el.remove();\n  });\n}\n\n/** The box of an element's letters (not its padding), so a copy can land letter on letter. */\nexport function textRect(el: HTMLElement): DOMRect {\n  const range = document.createRange();\n  range.selectNodeContents(el);\n  const r = range.getBoundingClientRect();\n  return r.width ? r : el.getBoundingClientRect();\n}\n\n/**\n * Carries a copy of `from` (a row's label) onto `to` (the chip's label), which stays hidden until it lands.\n * The copy lives on the page's body with the label's own type and colour, so nothing clips it on the way.\n */\nexport function fly(from: { rect: DOMRect; style: Partial<CSSStyleDeclaration>; text: string }, to: HTMLElement, duration = 460) {\n  const target = textRect(to);\n  if (!target.width) return;\n  const copy = document.createElement(\"span\");\n  copy.textContent = from.text;\n  copy.setAttribute(\"aria-hidden\", \"true\");\n  Object.assign(copy.style, from.style, {\n    position: \"fixed\",\n    left: `${from.rect.left}px`,\n    top: `${from.rect.top}px`,\n    margin: \"0\",\n    // The copy's box is exactly its letters' box, so it starts and lands letter on letter.\n    lineHeight: `${from.rect.height}px`,\n    whiteSpace: \"nowrap\",\n    pointerEvents: \"none\",\n    zIndex: \"70\",\n    transformOrigin: \"0 0\",\n  });\n  document.body.appendChild(copy);\n  to.style.opacity = \"0\";\n  const scale = target.height / Math.max(1, from.rect.height);\n  const flight = copy.animate(\n    [\n      { transform: \"none\", opacity: 1 },\n      { transform: `translate(${target.left - from.rect.left}px, ${target.top - from.rect.top}px) scale(${scale})`, opacity: 1 },\n    ],\n    { duration, easing: EASE, fill: \"forwards\" },\n  );\n  const land = () => {\n    to.style.opacity = \"\";\n    copy.remove();\n  };\n  flight.onfinish = land;\n  flight.oncancel = land;\n}\n"}],"css":{"@keyframes ui-scan":{"from":{"transform":"translateX(-100%)"},"to":{"transform":"translateX(250%)"}},"@keyframes ui-fade-in":{"from":{"opacity":"0"},"to":{"opacity":"1"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/combobox. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}