{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "combobox",
  "type": "registry:ui",
  "title": "Combobox",
  "description": "An ARIA 1.2 autocomplete with match highlighting, groups, async search, multi-select chips and create-on-type.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/combobox.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface ComboboxOption {\n  value: string;\n  label: string;\n  /** Options sharing a group are rendered under one labelled heading (in first-seen order). */\n  group?: string;\n  /** Secondary text shown on the right of the option. */\n  description?: string;\n  disabled?: boolean;\n}\n\nexport interface ComboboxProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Static options, filtered on the client by case-insensitive substring. */\n  options?: ComboboxOption[];\n  /**\n   * Async source. Called (debounced) with the query and an AbortSignal that fires when a newer\n   * query supersedes it or the list closes. When set, `options` is ignored.\n   */\n  loadOptions?: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;\n  /** Debounce before `loadOptions` runs, in ms. */\n  debounce?: number;\n  /** Allow several values, rendered as removable chips. */\n  multiple?: boolean;\n  /** Offer a \"Create …\" option when the query matches nothing exactly. */\n  creatable?: boolean;\n  /** Turns a created label into an option (e.g. to assign an id). Defaults to `{ value: label, label }`. */\n  onCreate?: (label: string) => ComboboxOption | void;\n  /** Controlled selection. Single mode holds at most one value. */\n  value?: string[];\n  /** Initial selection when uncontrolled. */\n  defaultValue?: string[];\n  /** Called with the selected values and their options after every change. */\n  onChange?: (values: string[], options: ComboboxOption[]) => void;\n  /** Visible label, rendered as a real <label> for the input. */\n  label?: React.ReactNode;\n  /** Helper text below the field (linked via aria-describedby). */\n  hint?: React.ReactNode;\n  /** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */\n  error?: React.ReactNode;\n  placeholder?: string;\n  /** Text shown when nothing matches. */\n  emptyMessage?: string;\n  /** Focus ring, caret, match underline and highlight colour (any CSS colour). */\n  accent?: string;\n  disabled?: boolean;\n  /** When set, hidden inputs submit each selected value under this name in native forms. */\n  name?: string;\n  /** id for the input (generated when omitted). */\n  inputId?: string;\n}\n\ntype AsyncResult = { query: string; items: ComboboxOption[]; error: boolean };\n\nconst norm = (s: string) => s.trim().toLowerCase();\n\n/** Renders `text` with the first case-insensitive occurrence of `query` marked. */\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  const at = query ? text.toLowerCase().indexOf(query) : -1;\n  if (at === -1) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at)}\n      <mark className=\"bg-transparent font-semibold text-zinc-950 underline decoration-[var(--cb-accent)] decoration-2 underline-offset-[3px] dark:text-white\">\n        {text.slice(at, at + query.length)}\n      </mark>\n      {text.slice(at + query.length)}\n    </>\n  );\n}\n\n/**\n * An editable combobox following the ARIA 1.2 pattern: the input owns a listbox through\n * aria-controls/aria-expanded and tracks the highlighted option with aria-activedescendant, so\n * DOM focus never leaves the text field. Static options are filtered with match highlighting;\n * `loadOptions` adds debounced, cancellable async search with loading, error and empty states.\n */\nexport function Combobox({\n  options,\n  loadOptions,\n  debounce = 250,\n  multiple = false,\n  creatable = false,\n  onCreate,\n  value,\n  defaultValue,\n  onChange,\n  label = \"Search\",\n  hint,\n  error,\n  placeholder = \"Type to search…\",\n  emptyMessage = \"No matches\",\n  accent = \"#FF7448\",\n  disabled = false,\n  name,\n  inputId: inputIdProp,\n  className = \"\",\n  style,\n  ...props\n}: ComboboxProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const inputId = inputIdProp ?? `cb-${uid}`;\n  const listboxId = `${inputId}-listbox`;\n  const hintId = `${inputId}-hint`;\n  const errorId = `${inputId}-error`;\n  const optionId = (i: number) => `${inputId}-opt-${i}`;\n\n  const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);\n  const selected = value ?? inner;\n  const [text, setText] = React.useState(\"\");\n  /** True while the input holds a query typed by the user (single mode otherwise shows the selection). */\n  const [dirty, setDirty] = React.useState(false);\n  const [open, setOpen] = React.useState(false);\n  const [activeIndex, setActiveIndex] = React.useState(-1);\n  const [created, setCreated] = React.useState<ComboboxOption[]>([]);\n  const [known, setKnown] = React.useState<Record<string, ComboboxOption>>({});\n  const [asyncResult, setAsyncResult] = React.useState<AsyncResult | null>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const highlightRef = React.useRef<HTMLDivElement>(null);\n\n  const isAsync = typeof loadOptions === \"function\";\n  const invalid = Boolean(error);\n  const hasErrorText = invalid && error !== true;\n\n  const findOption = (v: string): ComboboxOption =>\n    options?.find((o) => o.value === v) ??\n    created.find((o) => o.value === v) ??\n    asyncResult?.items.find((o) => o.value === v) ??\n    known[v] ?? { value: v, label: v };\n  const selectedOptions = selected.map(findOption);\n  const singleLabel = !multiple && selectedOptions[0] ? selectedOptions[0].label : \"\";\n\n  const inputValue = multiple || dirty ? text : singleLabel;\n  const rawQuery = multiple || dirty ? text : \"\";\n  const query = norm(rawQuery);\n\n  /* ------------------------------- async search ------------------------------ */\n\n  const runLoad = React.useEffectEvent((q: string, signal: AbortSignal) => loadOptions?.(q, signal));\n\n  React.useEffect(() => {\n    if (!isAsync || !open || disabled) return;\n    const controller = new AbortController();\n    const timer = setTimeout(() => {\n      const pending = runLoad(rawQuery, controller.signal);\n      if (!pending) return;\n      pending.then(\n        (items) => {\n          if (!controller.signal.aborted) setAsyncResult({ query: rawQuery, items, error: false });\n        },\n        () => {\n          if (!controller.signal.aborted) setAsyncResult({ query: rawQuery, items: [], error: true });\n        },\n      );\n    }, debounce);\n    // A newer query (or closing) cancels both the pending debounce and the in-flight request.\n    return () => {\n      clearTimeout(timer);\n      controller.abort();\n    };\n  }, [isAsync, open, disabled, rawQuery, debounce]);\n\n  // Results are \"loading\" until a response for exactly this query has arrived.\n  const loading = isAsync && open && asyncResult?.query !== rawQuery;\n  const loadFailed = isAsync && !loading && asyncResult?.error === true;\n\n  /* ------------------------------ derived options ----------------------------- */\n\n  const source: ComboboxOption[] = isAsync\n    ? (asyncResult?.items ?? [])\n    : [...(options ?? []), ...created].filter((o) => !query || o.label.toLowerCase().includes(query));\n\n  const allLabels = isAsync ? source : [...(options ?? []), ...created];\n  const canCreate = creatable && query !== \"\" && !loading && !allLabels.some((o) => norm(o.label) === query);\n\n  // Group in first-seen order, keeping a flat index for keyboard navigation and ids.\n  const groups: { name: string | undefined; items: { option: ComboboxOption; index: number }[] }[] = [];\n  const flat: ComboboxOption[] = [];\n  for (const option of source) {\n    let g = groups.find((x) => x.name === option.group);\n    if (!g) {\n      g = { name: option.group, items: [] };\n      groups.push(g);\n    }\n    g.items.push({ option, index: -1 });\n  }\n  for (const g of groups) for (const item of g.items) item.index = flat.push(item.option) - 1;\n  const createIndex = canCreate ? flat.length : -1;\n  const count = flat.length + (canCreate ? 1 : 0);\n  const isEnabled = (i: number) => i === createIndex || (i >= 0 && i < flat.length && !flat[i].disabled);\n\n  const listOpen = open && !disabled;\n  const active = listOpen && activeIndex < count && isEnabled(activeIndex) ? activeIndex : -1;\n\n  /* --------------------------------- helpers -------------------------------- */\n\n  const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));\n\n  const commit = (next: string[], extra: ComboboxOption[] = []) => {\n    if (extra.length) setKnown((k) => ({ ...k, ...Object.fromEntries(extra.map((o) => [o.value, o])) }));\n    if (value === undefined) setInner(next);\n    const lookup = (v: string) => extra.find((o) => o.value === v) ?? findOption(v);\n    onChange?.(next, next.map(lookup));\n  };\n\n  const scrollToOption = (index: number) => {\n    const list = listRef.current;\n    const el = list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(index))}`);\n    if (!list || !el) return;\n    // Scroll the listbox only — scrollIntoView would also move the page.\n    if (el.offsetTop < list.scrollTop + 4) list.scrollTop = el.offsetTop - 4;\n    else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight - 4)\n      list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;\n  };\n\n  const setActive = (index: number) => {\n    setActiveIndex(index);\n    requestAnimationFrame(() => scrollToOption(index));\n  };\n\n  /** Next enabled index from `from` in `step` direction, wrapping around. */\n  const step = (from: number, delta: 1 | -1) => {\n    for (let n = 1; n <= count; n++) {\n      const i = (((from + delta * n) % count) + count) % count;\n      if (isEnabled(i)) return i;\n    }\n    return -1;\n  };\n  const edge = (end: \"first\" | \"last\") => (end === \"first\" ? step(-1, 1) : step(count, -1));\n\n  const openList = (highlight?: \"first\" | \"last\" | \"selected\") => {\n    if (disabled) return;\n    setOpen(true);\n    if (!highlight || count === 0) return;\n    const sel = highlight === \"selected\" ? flat.findIndex((o) => selected.includes(o.value)) : -1;\n    setActive(sel !== -1 && isEnabled(sel) ? sel : edge(highlight === \"last\" ? \"last\" : \"first\"));\n  };\n\n  const closeList = () => {\n    setOpen(false);\n    setActiveIndex(-1);\n  };\n\n  const chooseOption = (option: ComboboxOption) => {\n    if (multiple) {\n      const has = selected.includes(option.value);\n      commit(has ? selected.filter((v) => v !== option.value) : [...selected, option.value], [option]);\n      announce(`${option.label} ${has ? \"removed\" : \"added\"}. ${has ? selected.length - 1 : selected.length + 1} selected.`);\n      setText(\"\");\n      setDirty(false);\n    } else {\n      commit([option.value], [option]);\n      announce(`${option.label} selected.`);\n      setText(\"\");\n      setDirty(false);\n      closeList();\n    }\n  };\n\n  const createFromQuery = () => {\n    const labelText = rawQuery.trim();\n    if (!labelText) return;\n    const option = onCreate?.(labelText) ?? { value: labelText, label: labelText };\n    setCreated((c) => (c.some((o) => o.value === option.value) ? c : [...c, option]));\n    chooseOption(option);\n    if (multiple) setActiveIndex(-1);\n  };\n\n  const chooseIndex = (index: number) => {\n    if (index === createIndex) createFromQuery();\n    else if (flat[index] && !flat[index].disabled) chooseOption(flat[index]);\n  };\n\n  const removeValue = (v: string) => {\n    if (disabled) return;\n    const option = findOption(v);\n    const next = selected.filter((x) => x !== v);\n    commit(next);\n    announce(`${option.label} removed. ${next.length} selected.`);\n    inputRef.current?.focus();\n  };\n\n  /** Single mode: settle the field when focus leaves or Escape is pressed while closed. */\n  const settleSingle = () => {\n    if (multiple || !dirty) return;\n    const typed = norm(text);\n    if (!typed) {\n      if (selected.length) {\n        commit([]);\n        announce(\"Selection cleared.\");\n      }\n    } else {\n      const exact = [...(options ?? []), ...created, ...(asyncResult?.items ?? [])].find(\n        (o) => !o.disabled && norm(o.label) === typed,\n      );\n      if (exact && exact.value !== selected[0]) commit([exact.value], [exact]);\n    }\n    setText(\"\");\n    setDirty(false);\n  };\n\n  /* ------------------------------ highlight pill ------------------------------ */\n\n  // The highlight is one absolutely positioned layer that glides between options.\n  React.useLayoutEffect(() => {\n    const pill = highlightRef.current;\n    const list = listRef.current;\n    if (!pill || !list) return;\n    const el = active === -1 ? null : list.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`);\n    if (!el) {\n      pill.style.opacity = \"0\";\n      return;\n    }\n    const wasHidden = pill.style.opacity !== \"1\";\n    pill.style.opacity = \"1\";\n    // Appearing (or reduced motion) snaps into place; moving between options glides.\n    const controls = animate(\n      pill,\n      { y: el.offsetTop, height: el.offsetHeight },\n      reduceMotion || wasHidden ? { duration: 0 } : { type: \"spring\", stiffness: 700, damping: 46, mass: 0.6 },\n    );\n    return () => controls.stop();\n  });\n\n  /* --------------------------------- keyboard -------------------------------- */\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.nativeEvent.isComposing) return;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        if (!listOpen) openList(event.altKey ? undefined : \"selected\");\n        else if (!event.altKey && count) setActive(step(active, 1));\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        if (event.altKey) closeList();\n        else if (!listOpen) openList(\"last\");\n        else if (count) setActive(step(active === -1 ? 0 : active, -1));\n        break;\n      case \"Home\":\n      case \"End\":\n        // While navigating the list, Home/End jump to the first/last option; otherwise they move the caret.\n        if (listOpen && active !== -1) {\n          event.preventDefault();\n          setActive(edge(event.key === \"Home\" ? \"first\" : \"last\"));\n        }\n        break;\n      case \"PageDown\":\n      case \"PageUp\":\n        if (listOpen && count) {\n          event.preventDefault();\n          // Jump eight options (without wrapping), then settle on the nearest enabled one.\n          const dir = event.key === \"PageDown\" ? 1 : -1;\n          let i = Math.min(count - 1, Math.max(0, (active === -1 ? 0 : active) + dir * 8));\n          while (i >= 0 && i < count && !isEnabled(i)) i += dir;\n          setActive(i >= 0 && i < count ? i : edge(dir === 1 ? \"last\" : \"first\"));\n        }\n        break;\n      case \"Enter\":\n        if (listOpen && active !== -1) {\n          event.preventDefault();\n          chooseIndex(active);\n        } else if (listOpen && canCreate && count === 1) {\n          event.preventDefault();\n          createFromQuery();\n        }\n        break;\n      case \"Escape\":\n        if (listOpen) {\n          event.preventDefault();\n          closeList();\n        } else if (dirty || text) {\n          event.preventDefault();\n          setText(\"\");\n          setDirty(false);\n        } else if (!multiple && selected.length) {\n          // A second Escape on an untouched single field clears the selection.\n          event.preventDefault();\n          commit([]);\n          announce(\"Selection cleared.\");\n        }\n        break;\n      case \"Tab\":\n        // Tab accepts the highlighted option in single mode, then focus moves on as normal.\n        if (listOpen && active !== -1 && !multiple) chooseIndex(active);\n        closeList();\n        break;\n      case \"Backspace\":\n        if (multiple && !text && selected.length) {\n          event.preventDefault();\n          removeValue(selected[selected.length - 1]);\n        }\n        break;\n    }\n  };\n\n  /* ---------------------------------- render --------------------------------- */\n\n  const describedBy =\n    [hint ? hintId : null, hasErrorText ? errorId : null, props[\"aria-describedby\"]].filter(Boolean).join(\" \") || undefined;\n\n  const statusText = !listOpen\n    ? \"\"\n    : loading\n      ? \"Loading results.\"\n      : loadFailed\n        ? \"Results could not be loaded.\"\n        : `${flat.length} ${flat.length === 1 ? \"result\" : \"results\"}${canCreate ? `, or create ${rawQuery.trim()}` : \"\"}.`;\n\n  const transition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, stiffness: 520, damping: 36, mass: 0.7 };\n\n  return (\n    <div\n      className={`relative w-full ${className}`}\n      style={{ [\"--cb-accent\" as string]: accent, ...style }}\n      {...props}\n      aria-describedby={undefined}\n    >\n      <div className=\"mb-2 flex items-baseline justify-between gap-3\">\n        <label htmlFor={inputId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </label>\n        {multiple && (\n          <span aria-hidden=\"true\" className=\"font-mono text-[10px] tabular-nums tracking-[0.14em] text-zinc-400 dark:text-zinc-500\">\n            {selected.length} selected\n          </span>\n        )}\n      </div>\n\n      <div\n        onMouseDown={(event) => {\n          // Clicking empty space in the field focuses the input and toggles the list.\n          if (event.target === event.currentTarget) {\n            event.preventDefault();\n            inputRef.current?.focus();\n            if (listOpen) closeList();\n            else openList();\n          }\n        }}\n        className={`relative flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-white py-1.5 pl-2 pr-[4.25rem] transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${\n          invalid\n            ? \"border-rose-500/80 dark:border-rose-400/80\"\n            : \"border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--cb-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--cb-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30\"\n        } ${disabled ? \"pointer-events-none opacity-50\" : \"\"}`}\n      >\n        {multiple && selected.length > 0 && (\n          <ul role=\"list\" aria-label=\"Selected\" className=\"contents\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {selectedOptions.map((option) => (\n                <motion.li\n                  key={option.value}\n                  layout={reduceMotion ? false : \"position\"}\n                  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduceMotion ? { opacity: 0, pointerEvents: \"none\" } : { opacity: 0, scale: 0.7, pointerEvents: \"none\" }}\n                  transition={transition}\n                  className=\"flex\"\n                >\n                  <span className=\"inline-flex h-7 max-w-[14rem] items-center gap-1 rounded-lg border border-zinc-900/10 bg-zinc-900/[0.04] pl-2.5 pr-1 text-[13px] font-medium text-zinc-900 dark:border-white/10 dark:bg-white/[0.06] dark:text-zinc-100\">\n                    <span className=\"truncate\">{option.label}</span>\n                    <button\n                      type=\"button\"\n                      tabIndex={-1}\n                      disabled={disabled}\n                      aria-label={`Remove ${option.label}`}\n                      onMouseDown={(e) => e.preventDefault()}\n                      onClick={() => removeValue(option.value)}\n                      className=\"grid size-5 shrink-0 place-items-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-900/10 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white\"\n                    >\n                      <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                        <path d=\"M3 3l6 6M9 3l-6 6\" />\n                      </svg>\n                    </button>\n                  </span>\n                </motion.li>\n              ))}\n            </AnimatePresence>\n          </ul>\n        )}\n\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"text\"\n          role=\"combobox\"\n          aria-expanded={listOpen}\n          aria-controls={listboxId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={active !== -1 ? optionId(active) : undefined}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          autoComplete=\"off\"\n          autoCapitalize=\"off\"\n          spellCheck={false}\n          disabled={disabled}\n          value={inputValue}\n          placeholder={multiple && selected.length ? \"\" : placeholder}\n          onChange={(event) => {\n            setText(event.target.value);\n            setDirty(true);\n            setAnnouncement(\"\");\n            setOpen(true);\n            setActiveIndex(-1);\n          }}\n          onKeyDown={handleKeyDown}\n          onClick={() => {\n            if (!listOpen) openList();\n          }}\n          onBlur={() => {\n            closeList();\n            if (multiple) setText(\"\");\n            else settleSingle();\n          }}\n          className=\"h-8 min-w-[6ch] flex-1 bg-transparent px-1.5 text-[14px] text-zinc-950 caret-[var(--cb-accent)] outline-hidden placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500\"\n        />\n\n        <div className=\"absolute inset-y-0 right-1.5 flex items-center gap-0.5\">\n          {loading && (\n            <svg aria-hidden=\"true\" viewBox=\"0 0 16 16\" className=\"size-4 animate-spin text-[var(--cb-accent)] motion-reduce:animate-none\">\n              <circle cx=\"8\" cy=\"8\" r=\"6\" fill=\"none\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.6\" />\n              <path d=\"M14 8a6 6 0 0 0-6-6\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n            </svg>\n          )}\n          {!multiple && selected.length > 0 && !loading && (\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              aria-label=\"Clear selection\"\n              onMouseDown={(e) => e.preventDefault()}\n              onClick={() => {\n                commit([]);\n                setText(\"\");\n                setDirty(false);\n                announce(\"Selection cleared.\");\n                inputRef.current?.focus();\n              }}\n              className=\"grid size-7 place-items-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n            >\n              <svg aria-hidden=\"true\" viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\">\n                <path d=\"M3 3l6 6M9 3l-6 6\" />\n              </svg>\n            </button>\n          )}\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={listOpen ? \"Hide options\" : \"Show options\"}\n            aria-expanded={listOpen}\n            aria-controls={listboxId}\n            disabled={disabled}\n            onMouseDown={(e) => e.preventDefault()}\n            onClick={() => {\n              inputRef.current?.focus();\n              if (listOpen) closeList();\n              else openList();\n            }}\n            className=\"grid size-7 place-items-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white\"\n          >\n            <svg\n              aria-hidden=\"true\"\n              viewBox=\"0 0 12 12\"\n              className={`size-3 transition-transform duration-200 motion-reduce:transition-none ${listOpen ? \"rotate-180\" : \"\"}`}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"1.5\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <path d=\"M3 4.5l3 3 3-3\" />\n            </svg>\n          </button>\n        </div>\n\n        <AnimatePresence>\n          {listOpen && (\n            <motion.div\n              key=\"popup\"\n              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scaleY: 0.96 }}\n              animate={{ opacity: 1, y: 0, scaleY: 1 }}\n              exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, scaleY: 0.98, transition: { duration: 0.12 } }}\n              transition={reduceMotion ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 40, mass: 0.6 }}\n              style={{ transformOrigin: \"top\" }}\n              className=\"absolute inset-x-0 top-[calc(100%+6px)] z-30 overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900\"\n              onMouseDown={(e) => e.preventDefault()}\n            >\n              {/* Thin progress rail while a request is in flight. */}\n              <div aria-hidden=\"true\" className=\"relative h-px overflow-hidden bg-zinc-900/[0.06] dark:bg-white/[0.06]\">\n                {loading && (\n                  <div className=\"absolute inset-y-0 left-0 w-1/3 animate-[cb-rail_1s_ease-in-out_infinite] bg-[var(--cb-accent)] motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50\" />\n                )}\n              </div>\n\n              <div\n                ref={listRef}\n                id={listboxId}\n                role=\"listbox\"\n                aria-label={typeof label === \"string\" ? label : \"Options\"}\n                aria-multiselectable={multiple || undefined}\n                aria-busy={loading || undefined}\n                className={`relative max-h-64 overflow-y-auto overscroll-contain p-1 transition-opacity duration-150 ${loading && flat.length ? \"opacity-60\" : \"\"}`}\n              >\n                <div\n                  ref={highlightRef}\n                  aria-hidden=\"true\"\n                  style={{ opacity: 0 }}\n                  className=\"pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-[color-mix(in_srgb,var(--cb-accent)_12%,transparent)] ring-1 ring-inset ring-[color-mix(in_srgb,var(--cb-accent)_35%,transparent)]\"\n                />\n                {groups.map((g, gi) => {\n                  const items = g.items.map(({ option, index }) => {\n                    const isSelected = selected.includes(option.value);\n                    return (\n                      <div\n                        key={option.value}\n                        id={optionId(index)}\n                        role=\"option\"\n                        aria-selected={isSelected}\n                        aria-disabled={option.disabled || undefined}\n                        onClick={() => chooseIndex(index)}\n                        onPointerMove={(e) => {\n                          if (e.pointerType === \"mouse\" && index !== active && !option.disabled) setActiveIndex(index);\n                        }}\n                        className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] ${\n                          option.disabled\n                            ? \"cursor-not-allowed text-zinc-400 dark:text-zinc-600\"\n                            : index === active\n                              ? \"text-zinc-950 dark:text-white\"\n                              : \"text-zinc-700 dark:text-zinc-300\"\n                        }`}\n                      >\n                        <span\n                          aria-hidden=\"true\"\n                          className={`grid size-4 shrink-0 place-items-center ${multiple ? \"rounded border\" : \"\"} ${\n                            multiple\n                              ? isSelected\n                                ? \"border-[var(--cb-accent)] bg-[var(--cb-accent)] text-white dark:text-zinc-950\"\n                                : \"border-zinc-900/20 dark:border-white/20\"\n                              : \"text-[var(--cb-accent)]\"\n                          }`}\n                        >\n                          {isSelected && (\n                            <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                              <path d=\"M2.5 6.2l2.3 2.3 4.7-5\" />\n                            </svg>\n                          )}\n                        </span>\n                        <span className=\"min-w-0 flex-1 truncate\">\n                          <Highlight text={option.label} query={query} />\n                        </span>\n                        {option.description && (\n                          <span className=\"shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-400 dark:text-zinc-500\">\n                            {option.description}\n                          </span>\n                        )}\n                      </div>\n                    );\n                  });\n                  if (!g.name) return <React.Fragment key={`g-${gi}`}>{items}</React.Fragment>;\n                  const headingId = `${inputId}-group-${gi}`;\n                  return (\n                    <div key={`g-${gi}`} role=\"group\" aria-labelledby={headingId} className={gi > 0 ? \"mt-1 border-t border-zinc-900/[0.06] pt-1 dark:border-white/[0.06]\" : \"\"}>\n                      <div\n                        id={headingId}\n                        role=\"presentation\"\n                        className=\"px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500\"\n                      >\n                        {g.name}\n                      </div>\n                      {items}\n                    </div>\n                  );\n                })}\n\n                {canCreate && (\n                  <div\n                    id={optionId(createIndex)}\n                    role=\"option\"\n                    aria-selected={false}\n                    onClick={() => chooseIndex(createIndex)}\n                    onPointerMove={(e) => {\n                      if (e.pointerType === \"mouse\" && createIndex !== active) setActiveIndex(createIndex);\n                    }}\n                    className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] ${\n                      flat.length ? \"mt-1\" : \"\"\n                    } ${createIndex === active ? \"text-zinc-950 dark:text-white\" : \"text-zinc-700 dark:text-zinc-300\"}`}\n                  >\n                    <span aria-hidden=\"true\" className=\"grid size-4 shrink-0 place-items-center text-[var(--cb-accent)]\">\n                      <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n                        <path d=\"M6 2.5v7M2.5 6h7\" />\n                      </svg>\n                    </span>\n                    <span className=\"min-w-0 flex-1 truncate\">\n                      Create <span className=\"font-semibold\">“{rawQuery.trim()}”</span>\n                    </span>\n                  </div>\n                )}\n              </div>\n\n              {count === 0 && (\n                <p className=\"px-3.5 pb-3.5 pt-2.5 text-[13px] text-zinc-500 dark:text-zinc-400\">\n                  {loading ? \"Searching…\" : loadFailed ? \"Couldn’t load results. Keep typing to retry.\" : emptyMessage}\n                </p>\n              )}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      {hint && (\n        <p id={hintId} className=\"mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400\">\n          {hint}\n        </p>\n      )}\n\n      <div aria-live=\"polite\">\n        {hasErrorText && (\n          <p id={errorId} className=\"pt-1.5 text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400\">\n            {error}\n          </p>\n        )}\n      </div>\n\n      {/* Result counts and selection changes, announced politely. */}\n      <span className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement || statusText}\n      </span>\n\n      {name && selected.map((v) => <input key={v} type=\"hidden\" name={name} value={v} />)}\n\n      <style>{`@keyframes cb-rail{0%{transform:translateX(-100%)}100%{transform:translateX(300%)}}`}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/combobox"
}
