{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"combobox","type":"registry:ui","title":"Combobox","description":"Type-to-filter combobox: matched text lights up in volt, a create row offers what you typed, async results load behind a spinner, and an empty state wobbles in when nothing matches.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/field","@geturui/spinner"],"files":[{"path":"registry/geturui/ui/combobox.tsx","type":"registry:ui","target":"components/ui/combobox.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown, Plus, Search, SearchX, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Field, fieldWellClasses } from \"@/components/ui/field\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport {\n  Listbox,\n  ListboxPopover,\n  OptionMedia,\n  defaultFilter,\n  filterEntries,\n  flattenEntries,\n  fold,\n  listboxIconSize,\n  optionId,\n  useListboxNavigation,\n  useSafeId,\n  type ListboxEntry,\n  type ListboxIcon,\n  type ListboxOption,\n  type ListboxSize,\n} from \"@/components/ui/listbox\";\n\nexport type { ListboxEntry as ComboboxEntry, ListboxOption as ComboboxOption } from \"@/components/ui/listbox\";\n\nconst CREATE = \"\\u0000create\";\n\nexport interface ComboboxProps {\n  /** Options, groups (`{ heading, options }`) and separators (`{ separator: true }`). */\n  options: ListboxEntry[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null, option: ListboxOption | null) => void;\n  /** Text in the input (controlled). */\n  inputValue?: string;\n  defaultInputValue?: string;\n  /** Fires on every keystroke — fetch here for async results. */\n  onInputChange?: (text: string) => void;\n  /** Custom match test, or `false` when `options` are already filtered (async). */\n  filter?: false | ((option: ListboxOption, query: string) => boolean);\n  /** Shows a spinner in the well and a loading row while there are no results. */\n  loading?: boolean;\n  /** @default \"Searching…\" */\n  loadingText?: string;\n  /** Adds a \"Create “text”\" option when nothing matches exactly. */\n  onCreate?: (text: string) => void;\n  /** Label for the create option. @default (text) => `Create “${text}”` */\n  createLabel?: (text: string) => string;\n  /** Empty state title; the description below it says what to try. */\n  emptyText?: React.ReactNode;\n  emptyDescription?: React.ReactNode;\n  size?: ListboxSize;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  disabled?: boolean;\n  labelAside?: React.ReactNode;\n  id?: string;\n  /** @default \"Search\" */\n  placeholder?: string;\n  /** Leading icon in the well (replaced by the selected option's media). @default Search */\n  iconLeading?: ListboxIcon;\n  /** Open the list when the input gets focus. @default false */\n  openOnFocus?: boolean;\n  /** Form field name; the value is posted through a hidden input. */\n  name?: string;\n  \"aria-label\"?: string;\n  className?: string;\n}\n\n/** Type-to-filter combobox: matches light up in volt, a create row offers the typed text, and async results load behind a spinner. */\nexport function Combobox({\n  options,\n  value,\n  defaultValue = null,\n  onValueChange,\n  inputValue,\n  defaultInputValue,\n  onInputChange,\n  filter,\n  loading,\n  loadingText = \"Searching…\",\n  onCreate,\n  createLabel = (t) => `Create “${t}”`,\n  emptyText = \"No matches\",\n  emptyDescription = \"Try a shorter or different word.\",\n  size = \"md\",\n  label,\n  hint,\n  error,\n  required,\n  disabled,\n  labelAside,\n  id,\n  placeholder = \"Search\",\n  iconLeading: IconLeading = Search,\n  openOnFocus = false,\n  name,\n  \"aria-label\": ariaLabel,\n  className,\n}: ComboboxProps) {\n  const reduce = useReducedMotion();\n  const listId = useSafeId(\"cb\");\n  const wellRef = React.useRef<HTMLDivElement>(null);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const all = React.useMemo(() => flattenEntries(options), [options]);\n\n  const [ownValue, setOwnValue] = React.useState<string | null>(defaultValue);\n  const current = value === undefined ? ownValue : value;\n  const selected = all.find((o) => o.value === current) ?? null;\n\n  // True once the person types; until then the input mirrors the selected label and the list shows everything.\n  const [editing, setEditing] = React.useState(false);\n  const [ownText, setOwnText] = React.useState(defaultInputValue ?? selected?.label ?? \"\");\n  const text = inputValue ?? (editing ? ownText : (selected?.label ?? ownText));\n  const [isOpen, setIsOpen] = React.useState(false);\n  const open = isOpen && !disabled;\n\n  const query = editing ? text.trim() : \"\";\n  const visible = React.useMemo(() => {\n    let list = query && filter !== false ? filterEntries(options, (o) => (filter ? filter(o, query) : defaultFilter(o, query))) : options;\n    const exact = flattenEntries(list).some((o) => fold(o.label) === fold(query));\n    if (onCreate && query && !exact && !loading) {\n      const createRow: ListboxOption = { value: CREATE, label: createLabel(query), icon: Plus };\n      list = flattenEntries(list).length ? [...list, { separator: true }, createRow] : [createRow];\n    }\n    return list;\n  }, [options, query, filter, onCreate, createLabel, loading]);\n  const visibleFlat = React.useMemo(() => flattenEntries(visible), [visible]);\n  const nav = useListboxNavigation(visibleFlat, { autoActivateFirst: Boolean(query) });\n\n  const setText = (t: string) => {\n    if (inputValue === undefined) setOwnText(t);\n    onInputChange?.(t);\n  };\n\n  const openList = (target: \"selected\" | \"first\" | \"last\") => {\n    const enabled = visibleFlat.filter((o) => !o.disabled);\n    const pick = target === \"first\" ? enabled[0] : target === \"last\" ? enabled[enabled.length - 1] : (enabled.find((o) => o.value === current) ?? enabled[0]);\n    nav.setActive(pick?.value ?? null);\n    setIsOpen(true);\n  };\n\n  const commit = (next: string | null, option: ListboxOption | null) => {\n    if (value === undefined) setOwnValue(next);\n    onValueChange?.(next, option);\n  };\n\n  const choose = (option: ListboxOption) => {\n    if (option.disabled) return;\n    if (option.value === CREATE) {\n      onCreate?.(query);\n      setText(query);\n    } else {\n      commit(option.value, option);\n      setText(option.label);\n    }\n    setEditing(false);\n    setIsOpen(false);\n  };\n\n  // Leaving the field: an emptied input clears the value, anything else snaps back to the selected label.\n  const settle = () => {\n    if (!editing) return;\n    if (!text.trim()) {\n      if (current !== null) commit(null, null);\n    } else if (selected) {\n      setText(selected.label);\n    }\n    setEditing(false);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        if (!open) openList(e.altKey ? \"selected\" : current ? \"selected\" : \"first\");\n        else nav.step(1);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        if (!open) openList(\"last\");\n        else nav.step(-1);\n        return;\n      case \"PageDown\":\n      case \"PageUp\":\n        if (!open) return;\n        e.preventDefault();\n        nav.step(e.key === \"PageDown\" ? 10 : -10);\n        return;\n      case \"Enter\": {\n        if (!open) return;\n        const o = visibleFlat.find((x) => x.value === nav.active);\n        if (o) {\n          e.preventDefault();\n          choose(o);\n        }\n        return;\n      }\n      case \"Escape\":\n        if (open) {\n          e.preventDefault();\n          e.stopPropagation();\n          setIsOpen(false);\n        } else if (text) {\n          e.preventDefault();\n          setText(\"\");\n          setEditing(false);\n          commit(null, null);\n        }\n        return;\n      case \"Tab\":\n        setIsOpen(false);\n        return;\n    }\n  };\n\n  const activeId = open && nav.active ? optionId(listId, nav.active) : undefined;\n  const Leading = selected && !editing && (selected.icon || selected.avatar || selected.dot) ? null : IconLeading;\n\n  const empty = loading ? (\n    <div className=\"flex items-center justify-center gap-2 px-3 py-6 text-sm text-ink-subtle\">\n      <Spinner size=\"sm\" label={null} />\n      <span role=\"status\">{loadingText}</span>\n    </div>\n  ) : (\n    <div role=\"status\" className=\"grid justify-items-center gap-1 px-4 py-6 text-center\">\n      <motion.span\n        aria-hidden\n        initial={reduce ? false : { rotate: -12, scale: 0.8 }}\n        animate={{ rotate: 0, scale: 1 }}\n        transition={{ type: \"spring\", stiffness: 380, damping: 14 }}\n        className=\"mb-1 grid size-9 place-items-center rounded-full bg-surface-sunken text-ink-subtle\"\n      >\n        <SearchX className=\"size-4\" />\n      </motion.span>\n      <span className=\"text-sm font-medium text-ink\">\n        {emptyText}\n        {query && <> for “{query}”</>}\n      </span>\n      {emptyDescription && <span className=\"text-xs text-ink-subtle\">{emptyDescription}</span>}\n    </div>\n  );\n\n  return (\n    <Field label={label} hint={hint} error={error} required={required} labelAside={labelAside} id={id} disabled={disabled} className={className}>\n      {({ id: controlId, labelId, describedBy, invalid }) => (\n        <>\n          <div\n            ref={wellRef}\n            onPointerDown={(e) => {\n              if (e.target !== inputRef.current) {\n                e.preventDefault();\n                inputRef.current?.focus();\n              }\n            }}\n            className={cn(\n              fieldWellClasses({ size, invalid, disabled }),\n              \"cursor-text\",\n              open && !invalid && \"border-volt-ink shadow-[0_0_0_3px_var(--volt-soft)]\",\n            )}\n          >\n            {selected && !editing && !Leading ? (\n              <OptionMedia option={selected} size={size} />\n            ) : (\n              Leading && (\n                <span aria-hidden className=\"inline-flex shrink-0 text-ink-subtle\">\n                  <Leading className={listboxIconSize[size]} />\n                </span>\n              )\n            )}\n            <input\n              ref={inputRef}\n              id={controlId}\n              type=\"text\"\n              role=\"combobox\"\n              autoComplete=\"off\"\n              spellCheck={false}\n              aria-autocomplete=\"list\"\n              aria-expanded={open}\n              aria-controls={open ? listId : undefined}\n              aria-activedescendant={activeId}\n              aria-describedby={describedBy}\n              aria-invalid={invalid || undefined}\n              aria-required={required || undefined}\n              aria-label={label ? undefined : ariaLabel}\n              aria-busy={loading || undefined}\n              disabled={disabled}\n              placeholder={placeholder}\n              value={text}\n              onChange={(e) => {\n                setText(e.target.value);\n                setEditing(true);\n                nav.setActive(null);\n                setIsOpen(true);\n              }}\n              onFocus={() => {\n                if (openOnFocus) openList(\"selected\");\n              }}\n              onPointerDown={() => {\n                if (!open) openList(\"selected\");\n              }}\n              onBlur={(e) => {\n                const next = e.relatedTarget as Node | null;\n                if (!next || !wellRef.current?.contains(next)) settle();\n              }}\n              onKeyDown={onKeyDown}\n              className=\"min-w-0 flex-1 bg-transparent py-1 text-ink outline-none placeholder:text-ink-subtle\"\n            />\n            <AnimatePresence initial={false}>\n              {loading && (\n                <motion.span key=\"spin\" initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6 }} className=\"inline-flex\">\n                  <Spinner size=\"sm\" label={null} />\n                </motion.span>\n              )}\n              {!loading && text && !disabled && (\n                <motion.button\n                  key=\"clear\"\n                  type=\"button\"\n                  tabIndex={-1}\n                  aria-label=\"Clear\"\n                  initial={{ opacity: 0, scale: 0.6 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: 0.6 }}\n                  whileTap={{ scale: 0.9 }}\n                  onClick={() => {\n                    setText(\"\");\n                    setEditing(true);\n                    commit(null, null);\n                    nav.setActive(null);\n                    inputRef.current?.focus();\n                  }}\n                  className=\"grid size-5 shrink-0 place-items-center rounded-full text-ink-subtle hover:bg-surface-hover hover:text-ink\"\n                >\n                  <X aria-hidden className=\"size-3.5\" />\n                </motion.button>\n              )}\n            </AnimatePresence>\n            <button\n              type=\"button\"\n              tabIndex={-1}\n              aria-label={open ? \"Hide options\" : \"Show options\"}\n              disabled={disabled}\n              onClick={() => (open ? setIsOpen(false) : openList(\"selected\"))}\n              className=\"-mr-1 grid size-6 shrink-0 place-items-center rounded-full text-ink-subtle hover:text-ink\"\n            >\n              <motion.span\n                aria-hidden\n                animate={{ rotate: open ? 180 : 0 }}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 28 }}\n                className=\"inline-flex\"\n              >\n                <ChevronDown className={listboxIconSize[size]} />\n              </motion.span>\n            </button>\n          </div>\n          {name && <input type=\"hidden\" name={name} value={current ?? \"\"} />}\n          <ListboxPopover open={open} anchorRef={wellRef} onDismiss={() => setIsOpen(false)}>\n            <Listbox\n              id={listId}\n              entries={visible}\n              selected={current ? [current] : []}\n              active={nav.active}\n              onActiveChange={nav.setActive}\n              onPick={choose}\n              size={size}\n              query={query}\n              busy={loading}\n              aria-labelledby={label ? labelId : undefined}\n              aria-label={label ? undefined : (ariaLabel ?? \"Suggestions\")}\n              empty={empty}\n            />\n          </ListboxPopover>\n        </>\n      )}\n    </Field>\n  );\n}\n"},{"path":"registry/geturui/ui/listbox.tsx","type":"registry:ui","target":"components/ui/listbox.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/*\n * Shared listbox core for Select, Combobox and TagSelect.\n * Option model, filtering, APG keyboard navigation, the option list with a gliding\n * highlight, and a portalled panel that springs open and flips near the viewport edge.\n */\n\nexport type ListboxSize = \"sm\" | \"md\" | \"lg\";\nexport type ListboxTone = \"success\" | \"warning\" | \"danger\" | \"info\" | \"neutral\";\n/** Any icon component that takes a className (every lucide-react icon fits). */\nexport type ListboxIcon = React.ComponentType<{ className?: string }>;\n\nexport interface ListboxOption {\n  value: string;\n  /** Visible name; also used for typeahead and filtering. */\n  label: string;\n  /** Supporting line under the label. Strings are searchable. */\n  description?: React.ReactNode;\n  icon?: ListboxIcon;\n  /** Initials avatar. `true` uses the label; a string is the name to take initials from. */\n  avatar?: boolean | string;\n  /** Status dot before the label. */\n  dot?: ListboxTone;\n  /** Right-aligned text: a count, a price, a role. */\n  meta?: React.ReactNode;\n  /** Right-aligned key hint, e.g. \"⌘1\". */\n  shortcut?: string;\n  disabled?: boolean;\n  /** Extra words that match when filtering. */\n  keywords?: string[];\n}\n\nexport interface ListboxGroup {\n  heading?: string;\n  options: ListboxOption[];\n}\n\nexport interface ListboxSeparator {\n  separator: true;\n}\n\nexport type ListboxEntry = ListboxOption | ListboxGroup | ListboxSeparator;\n\nexport function isListboxGroup(entry: ListboxEntry): entry is ListboxGroup {\n  return \"options\" in entry;\n}\n\nexport function isListboxSeparator(entry: ListboxEntry): entry is ListboxSeparator {\n  return \"separator\" in entry;\n}\n\n/** Every option in visual order. */\nexport function flattenEntries(entries: readonly ListboxEntry[]): ListboxOption[] {\n  const out: ListboxOption[] = [];\n  for (const e of entries) {\n    if (isListboxSeparator(e)) continue;\n    if (isListboxGroup(e)) out.push(...e.options);\n    else out.push(e);\n  }\n  return out;\n}\n\n/** Keep options that pass `keep`; empty groups and dangling separators are dropped. */\nexport function filterEntries(entries: readonly ListboxEntry[], keep: (option: ListboxOption) => boolean): ListboxEntry[] {\n  const out: ListboxEntry[] = [];\n  for (const e of entries) {\n    if (isListboxSeparator(e)) {\n      if (out.length && !isListboxSeparator(out[out.length - 1])) out.push(e);\n      continue;\n    }\n    if (isListboxGroup(e)) {\n      const options = e.options.filter(keep);\n      if (options.length) out.push({ ...e, options });\n      continue;\n    }\n    if (keep(e)) out.push(e);\n  }\n  while (out.length && isListboxSeparator(out[out.length - 1])) out.pop();\n  return out;\n}\n\n/** Map every option, keeping the group structure. */\nexport function mapEntries(entries: readonly ListboxEntry[], fn: (option: ListboxOption) => ListboxOption): ListboxEntry[] {\n  return entries.map((e) => (isListboxSeparator(e) ? e : isListboxGroup(e) ? { ...e, options: e.options.map(fn) } : fn(e)));\n}\n\nconst foldChar = (c: string) => c.normalize(\"NFD\").replace(/[̀-ͯ]/g, \"\").toLowerCase();\n\n/** Case- and accent-insensitive form of a string. */\nexport function fold(text: string) {\n  return Array.from(text).map(foldChar).join(\"\");\n}\n\n/** Default filter: label, string description and keywords contain the query, ignoring case and accents. */\nexport function defaultFilter(option: ListboxOption, query: string) {\n  const q = fold(query.trim());\n  if (!q) return true;\n  const hay = [option.label, typeof option.description === \"string\" ? option.description : \"\", ...(option.keywords ?? [])].join(\" \");\n  return fold(hay).includes(q);\n}\n\n/** Start/end (in characters) of the query inside the label, or null. */\nexport function matchRange(label: string, query: string): [number, number] | null {\n  const q = fold(query.trim());\n  if (!q) return null;\n  const folded = Array.from(label).map(foldChar);\n  if (folded.some((f) => f.length !== 1)) return null;\n  const at = folded.join(\"\").indexOf(q);\n  return at < 0 ? null : [at, at + q.length];\n}\n\n/** Label with the matched part in volt. */\nexport function HighlightMatch({ text, query }: { text: string; query?: string }) {\n  const range = query ? matchRange(text, query) : null;\n  if (!range) return <>{text}</>;\n  const chars = Array.from(text);\n  return (\n    <>\n      {chars.slice(0, range[0]).join(\"\")}\n      <mark className=\"rounded-[3px] bg-transparent font-semibold text-volt-ink\">{chars.slice(range[0], range[1]).join(\"\")}</mark>\n      {chars.slice(range[1]).join(\"\")}\n    </>\n  );\n}\n\n/** DOM id of an option, safe for aria-activedescendant. */\nexport function optionId(listId: string, value: string) {\n  return `${listId}-o-${value.replace(/[^a-zA-Z0-9_-]/g, (c) => `_${c.charCodeAt(0).toString(36)}`)}`;\n}\n\n/** Strip characters React's useId adds that some selectors dislike. */\nexport function useSafeId(prefix: string) {\n  const raw = React.useId();\n  return `${prefix}${raw.replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n}\n\nconst noopSubscribe = () => () => {};\n\n/** True on Apple platforms (server renders the ⌘ form, the client corrects after hydration). */\nexport function useIsMac() {\n  return React.useSyncExternalStore(\n    noopSubscribe,\n    () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent),\n    () => true,\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Visual pieces                                                       */\n/* ------------------------------------------------------------------ */\n\nexport const listboxRowSize: Record<ListboxSize, string> = {\n  sm: \"min-h-8 px-2 py-1.5 gap-2 text-[13px] rounded-[8px]\",\n  md: \"min-h-9 px-2.5 py-2 gap-2.5 text-sm rounded-[10px]\",\n  lg: \"min-h-11 px-3 py-2.5 gap-3 text-[15px] rounded-xs\",\n};\nexport const listboxIconSize: Record<ListboxSize, string> = { sm: \"size-3.5\", md: \"size-4\", lg: \"size-[18px]\" };\nconst avatarSize: Record<ListboxSize, string> = { sm: \"size-5 text-[9px]\", md: \"size-6 text-[10px]\", lg: \"size-7 text-[11px]\" };\n/** List height cap per size, px. */\nexport const listboxMaxHeight: Record<ListboxSize, number> = { sm: 224, md: 288, lg: 336 };\n\nconst avatarTones = [\n  \"bg-volt-soft text-volt-ink\",\n  \"bg-ember-soft text-ember-ink\",\n  \"bg-info-soft text-info\",\n  \"bg-success-soft text-success\",\n  \"bg-warning-soft text-warning\",\n];\nconst dotTones: Record<ListboxTone, string> = {\n  success: \"bg-success shadow-[0_0_0_3px_var(--success-soft)]\",\n  warning: \"bg-warning shadow-[0_0_0_3px_var(--warning-soft)]\",\n  danger: \"bg-danger shadow-[0_0_0_3px_var(--danger-soft)]\",\n  info: \"bg-info shadow-[0_0_0_3px_var(--info-soft)]\",\n  neutral: \"bg-ink-subtle shadow-[0_0_0_3px_var(--surface-hover)]\",\n};\n\n/** Up to two initials from a name. */\nexport function initials(name: string) {\n  const words = name.trim().split(/\\s+/).filter(Boolean);\n  if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();\n  return (words[0] ?? \"\").slice(0, 2).toUpperCase();\n}\n\nfunction toneFor(seed: string) {\n  let h = 0;\n  for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;\n  return avatarTones[h % avatarTones.length];\n}\n\n/** Leading avatar, dot or icon for an option (used in rows, triggers and chips). */\nexport function OptionMedia({ option, size = \"md\", className }: { option: ListboxOption; size?: ListboxSize; className?: string }) {\n  if (option.avatar) {\n    const name = typeof option.avatar === \"string\" ? option.avatar : option.label;\n    return (\n      <span aria-hidden className={cn(\"grid shrink-0 place-items-center rounded-full font-semibold tracking-wide\", avatarSize[size], toneFor(name), className)}>\n        {initials(name)}\n      </span>\n    );\n  }\n  if (option.dot) {\n    return (\n      <span aria-hidden className={cn(\"grid shrink-0 place-items-center\", listboxIconSize[size], className)}>\n        <span className={cn(\"size-2 rounded-full\", dotTones[option.dot])} />\n      </span>\n    );\n  }\n  if (option.icon) {\n    const Icon = option.icon;\n    return (\n      <span aria-hidden className={cn(\"inline-flex shrink-0 text-ink-subtle\", className)}>\n        <Icon className={listboxIconSize[size]} />\n      </span>\n    );\n  }\n  return null;\n}\n\n/** Small key-hint chip. */\nexport function Kbd({ children, className }: { children: React.ReactNode; className?: string }) {\n  return (\n    <kbd\n      className={cn(\n        \"inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border border-border bg-surface px-1 font-mono text-[11px] font-medium leading-none text-ink-subtle\",\n        className,\n      )}\n    >\n      {children}\n    </kbd>\n  );\n}\n\nfunction CheckGlyph({ className }: { className?: string }) {\n  const reduce = useReducedMotion();\n  return (\n    <svg aria-hidden viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.2} strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <motion.path\n        d=\"M3.5 8.5l3 3 6-7\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ type: \"spring\", stiffness: 420, damping: 30 }}\n      />\n    </svg>\n  );\n}\n\nconst boxSize: Record<ListboxSize, string> = { sm: \"size-3.5 rounded-[4px]\", md: \"size-4 rounded-[5px]\", lg: \"size-[18px] rounded-[5px]\" };\n\n/** Checkbox look-alike for multi-select rows (not an input: the row is the option). */\nexport function CheckboxGlyph({ checked, size = \"md\" }: { checked: boolean; size?: ListboxSize }) {\n  const reduce = useReducedMotion();\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"relative grid shrink-0 place-items-center border transition-[background-color,border-color] duration-150\",\n        boxSize[size],\n        checked ? \"border-volt bg-volt text-on-volt\" : \"border-border-strong bg-surface-sunken\",\n      )}\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-[80%]\">\n        <motion.path\n          d=\"M3.5 8.5l3 3 6-7\"\n          initial={false}\n          animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 30 }}\n        />\n      </svg>\n    </span>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Keyboard navigation                                                 */\n/* ------------------------------------------------------------------ */\n\nexport interface ListboxNavigation {\n  /** Value of the highlighted option. */\n  active: string | null;\n  setActive: (value: string | null) => void;\n  /** Move by `delta` enabled options (clamped, no wrap). Returns the new value. */\n  step: (delta: number) => string | null;\n  /** Jump to the first or last enabled option. */\n  edge: (where: \"first\" | \"last\") => string | null;\n  /** Feed one printed character; matches labels by prefix (repeat a letter to cycle). */\n  typeahead: (char: string, timeStamp: number) => string | null;\n  enabled: ListboxOption[];\n}\n\n/** Active-option state and APG movement helpers for a flat option list. */\nexport function useListboxNavigation(options: readonly ListboxOption[], { autoActivateFirst = false }: { autoActivateFirst?: boolean } = {}): ListboxNavigation {\n  const [raw, setActive] = React.useState<string | null>(null);\n  const enabled = React.useMemo(() => options.filter((o) => !o.disabled), [options]);\n  const active = raw !== null && enabled.some((o) => o.value === raw) ? raw : autoActivateFirst ? (enabled[0]?.value ?? null) : null;\n  const buffer = React.useRef({ text: \"\", at: 0 });\n\n  const step = (delta: number) => {\n    if (!enabled.length) return null;\n    const i = enabled.findIndex((o) => o.value === active);\n    const next = i === -1 ? (delta > 0 ? enabled[0] : enabled[enabled.length - 1]) : enabled[Math.max(0, Math.min(enabled.length - 1, i + delta))];\n    setActive(next.value);\n    return next.value;\n  };\n  const edge = (where: \"first\" | \"last\") => {\n    const next = where === \"first\" ? enabled[0] : enabled[enabled.length - 1];\n    setActive(next?.value ?? null);\n    return next?.value ?? null;\n  };\n  const typeahead = (char: string, timeStamp: number) => {\n    const b = buffer.current;\n    b.text = timeStamp - b.at > 600 ? char : b.text + char;\n    b.at = timeStamp;\n    const q = fold(b.text);\n    const start = enabled.findIndex((o) => o.value === active);\n    const from = q.length > 1 ? Math.max(0, start) : start + 1;\n    const ordered = [...enabled.slice(from), ...enabled.slice(0, from)];\n    let match = ordered.find((o) => fold(o.label).startsWith(q));\n    if (!match && Array.from(q).every((c) => c === q[0])) match = ordered.find((o) => fold(o.label).startsWith(q[0]));\n    if (match) setActive(match.value);\n    return match?.value ?? null;\n  };\n  return { active, setActive, step, edge, typeahead, enabled };\n}\n\n/** True for a key that types one visible character (no modifiers). */\nexport function isPrintableKey(e: React.KeyboardEvent) {\n  return e.key.length === 1 && e.key !== \" \" && !e.ctrlKey && !e.metaKey && !e.altKey;\n}\n\n/* ------------------------------------------------------------------ */\n/* Option list                                                         */\n/* ------------------------------------------------------------------ */\n\nexport interface ListboxProps {\n  id: string;\n  entries: readonly ListboxEntry[];\n  selected: readonly string[];\n  active: string | null;\n  onActiveChange: (value: string) => void;\n  onPick: (option: ListboxOption) => void;\n  multiple?: boolean;\n  /** Selection mark: a drawn check on the right, a checkbox on the left, or none. */\n  indicator?: \"check\" | \"checkbox\" | \"none\";\n  size?: ListboxSize;\n  /** Highlight this text inside labels. */\n  query?: string;\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  /** Rendered (outside the listbox role) when there are no options: empty or loading state. */\n  empty?: React.ReactNode;\n  busy?: boolean;\n  maxHeight?: number;\n  className?: string;\n}\n\n/** Option list with group headings, separators, media slots and one volt highlight that glides between rows. */\nexport function Listbox({\n  id,\n  entries,\n  selected,\n  active,\n  onActiveChange,\n  onPick,\n  multiple,\n  indicator = \"check\",\n  size = \"md\",\n  query,\n  empty,\n  busy,\n  maxHeight,\n  className,\n  ...aria\n}: ListboxProps) {\n  const reduce = useReducedMotion();\n  const picked = React.useMemo(() => new Set(selected), [selected]);\n  const hasOptions = flattenEntries(entries).length > 0;\n\n  // Keep the active row in view (keyboard moves, filtering, first open).\n  React.useEffect(() => {\n    if (active) document.getElementById(optionId(id, active))?.scrollIntoView({ block: \"nearest\" });\n  }, [id, active]);\n\n  const renderOption = (o: ListboxOption) => {\n    const isSelected = picked.has(o.value);\n    const isActive = active === o.value;\n    return (\n      <div\n        key={o.value}\n        id={optionId(id, o.value)}\n        role=\"option\"\n        aria-selected={isSelected}\n        aria-disabled={o.disabled || undefined}\n        data-active={isActive || undefined}\n        onPointerMove={() => {\n          if (!o.disabled && !isActive) onActiveChange(o.value);\n        }}\n        onMouseDown={(e) => e.preventDefault()}\n        onClick={() => {\n          if (!o.disabled) onPick(o);\n        }}\n        className={cn(\n          \"relative flex cursor-pointer select-none items-center text-ink-muted transition-colors duration-100\",\n          listboxRowSize[size],\n          isActive && \"text-ink\",\n          isSelected && \"text-ink\",\n          o.disabled && \"cursor-not-allowed opacity-45\",\n        )}\n      >\n        {isActive && (\n          <motion.span\n            aria-hidden\n            layoutId={`${id}-highlight`}\n            transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 40, mass: 0.7 }}\n            className=\"absolute inset-0 rounded-[inherit] bg-volt-soft\"\n          />\n        )}\n        {indicator === \"checkbox\" && (\n          <span className=\"relative flex\">\n            <CheckboxGlyph checked={isSelected} size={size} />\n          </span>\n        )}\n        <OptionMedia option={o} size={size} className=\"relative\" />\n        <span className=\"relative grid min-w-0 flex-1\">\n          <span className={cn(\"truncate\", isSelected && \"font-medium\")}>\n            <HighlightMatch text={o.label} query={query} />\n          </span>\n          {o.description && <span className=\"truncate text-xs leading-4 text-ink-subtle\">{o.description}</span>}\n        </span>\n        {o.meta && <span className=\"relative shrink-0 text-xs tabular-nums text-ink-subtle\">{o.meta}</span>}\n        {o.shortcut && <Kbd className=\"relative\">{o.shortcut}</Kbd>}\n        {indicator === \"check\" && (\n          <span className={cn(\"relative grid shrink-0 place-items-center text-volt-ink\", listboxIconSize[size])}>\n            {isSelected && <CheckGlyph className=\"size-full\" />}\n          </span>\n        )}\n      </div>\n    );\n  };\n\n  let blocks = 0;\n  return (\n    <>\n      <motion.div\n        layoutScroll\n        data-lb-scroll=\"\"\n        id={id}\n        role=\"listbox\"\n        aria-multiselectable={multiple || undefined}\n        aria-busy={busy || undefined}\n        tabIndex={-1}\n        style={{ maxHeight: maxHeight ?? listboxMaxHeight[size] }}\n        className={cn(\"min-h-0 overflow-y-auto overscroll-contain outline-none\", hasOptions && \"p-1\", className)}\n        {...aria}\n      >\n        {entries.map((e, i) => {\n          if (isListboxSeparator(e)) return <div key={`sep-${i}`} aria-hidden className=\"-mx-1 my-1 h-px bg-border\" />;\n          const first = blocks++ === 0;\n          if (!isListboxGroup(e)) return renderOption(e);\n          const headingId = `${id}-g${i}`;\n          const prev = entries[i - 1];\n          return (\n            <div\n              key={`grp-${i}`}\n              role=\"group\"\n              aria-labelledby={e.heading ? headingId : undefined}\n              className={cn(!first && prev && !isListboxSeparator(prev) && \"-mx-1 mt-1 border-t border-border px-1 pt-1\")}\n            >\n              {e.heading && (\n                <div id={headingId} role=\"presentation\" className=\"px-2.5 pb-1 pt-2 text-[11px] font-medium uppercase tracking-[0.08em] text-ink-subtle\">\n                  {e.heading}\n                </div>\n              )}\n              {e.options.map(renderOption)}\n            </div>\n          );\n        })}\n      </motion.div>\n      {!hasOptions && empty}\n    </>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Popover panel                                                       */\n/* ------------------------------------------------------------------ */\n\nexport interface ListboxPopoverProps {\n  open: boolean;\n  /** Element the panel lines up with (usually the field well). */\n  anchorRef: React.RefObject<HTMLElement | null>;\n  /** Called on outside press or when focus leaves both anchor and panel. */\n  onDismiss: () => void;\n  children: React.ReactNode;\n  className?: string;\n  /** Gap to the anchor, px. @default 6 */\n  sideOffset?: number;\n  /** Minimum panel width; otherwise it matches the anchor. @default 220 */\n  minWidth?: number;\n}\n\ntype Pos = { left: number; top?: number; bottom?: number; width: number; avail: number; side: \"top\" | \"bottom\"; theme: string | null };\n\nconst EDGE = 8;\nconst r2 = (n: number) => Math.round(n * 100) / 100;\n\nfunction measure(anchor: HTMLElement, panel: HTMLElement, offset: number, minWidth: number): Pos {\n  const r = anchor.getBoundingClientRect();\n  const vw = window.innerWidth;\n  const vh = window.innerHeight;\n  const list = panel.querySelector<HTMLElement>(\"[data-lb-scroll]\");\n  const chrome = panel.offsetHeight - (list?.clientHeight ?? 0);\n  const listMax = list ? parseFloat(getComputedStyle(list).maxHeight) : NaN;\n  const natural = list ? chrome + Math.min(list.scrollHeight, Number.isFinite(listMax) ? listMax : list.scrollHeight) : panel.scrollHeight;\n  const below = vh - r.bottom - offset - EDGE;\n  const above = r.top - offset - EDGE;\n  const side = natural > below && above > below ? \"top\" : \"bottom\";\n  const width = Math.min(Math.max(r.width, minWidth), vw - EDGE * 2);\n  const left = Math.max(EDGE, Math.min(r.left, vw - EDGE - width));\n  return {\n    side,\n    left: r2(left),\n    width: r2(width),\n    avail: Math.max(140, r2(side === \"bottom\" ? below : above)),\n    top: side === \"bottom\" ? r2(r.bottom + offset) : undefined,\n    bottom: side === \"top\" ? r2(vh - r.top + offset) : undefined,\n    theme: anchor.closest(\"[data-theme]\")?.getAttribute(\"data-theme\") ?? null,\n  };\n}\n\nconst samePos = (a: Pos | null, b: Pos) =>\n  !!a && a.left === b.left && a.top === b.top && a.bottom === b.bottom && a.width === b.width && a.avail === b.avail && a.side === b.side && a.theme === b.theme;\n\nfunction Panel({ anchorRef, onDismiss, children, className, sideOffset = 6, minWidth = 220 }: Omit<ListboxPopoverProps, \"open\">) {\n  const reduce = useReducedMotion();\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [pos, setPos] = React.useState<Pos | null>(null);\n  const dismiss = React.useRef(onDismiss);\n  React.useEffect(() => {\n    dismiss.current = onDismiss;\n  });\n\n  React.useLayoutEffect(() => {\n    const panel = ref.current;\n    const anchor = anchorRef.current;\n    if (!panel || !anchor) return;\n    const update = () => {\n      const next = measure(anchor, panel, sideOffset, minWidth);\n      setPos((prev) => (samePos(prev, next) ? prev : next));\n    };\n    update();\n    const ro = new ResizeObserver(update);\n    ro.observe(panel);\n    ro.observe(anchor);\n    const list = panel.querySelector(\"[data-lb-scroll]\");\n    const mo = new MutationObserver(update);\n    if (list) mo.observe(list, { childList: true, subtree: true });\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      ro.disconnect();\n      mo.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [anchorRef, sideOffset, minWidth]);\n\n  React.useEffect(() => {\n    const inside = (t: EventTarget | null) => t instanceof Node && (!!ref.current?.contains(t) || !!anchorRef.current?.contains(t));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target)) dismiss.current();\n    };\n    const onFocus = (e: FocusEvent) => {\n      if (!inside(e.target)) dismiss.current();\n    };\n    document.addEventListener(\"pointerdown\", onDown, true);\n    document.addEventListener(\"focusin\", onFocus);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown, true);\n      document.removeEventListener(\"focusin\", onFocus);\n    };\n  }, [anchorRef]);\n\n  const side = pos?.side ?? \"bottom\";\n  const hidden = { opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : side === \"bottom\" ? -6 : 6, filter: reduce ? \"blur(0px)\" : \"blur(6px)\" };\n  return (\n    <motion.div\n      ref={ref}\n      data-theme={pos?.theme ?? undefined}\n      initial={hidden}\n      animate={pos ? { opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" } : hidden}\n      exit={{ ...hidden, transition: { duration: 0.12 } }}\n      transition={{ type: \"spring\", stiffness: 380, damping: 30, opacity: { duration: 0.14 } }}\n      style={{\n        position: \"fixed\",\n        left: pos?.left ?? 0,\n        top: pos ? pos.top : 0,\n        bottom: pos?.bottom,\n        width: pos?.width ?? minWidth,\n        maxHeight: pos?.avail,\n        transformOrigin: side === \"bottom\" ? \"50% 0%\" : \"50% 100%\",\n        pointerEvents: pos ? undefined : \"none\",\n      }}\n      className={cn(\"z-[90] flex flex-col overflow-hidden rounded-md border border-border bg-surface-raised text-ink shadow-lg\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n\n/** Portalled panel that lines up with its anchor, springs open, flips above when there is no room below and repositions on scroll. */\nexport function ListboxPopover({ open, ...props }: ListboxPopoverProps) {\n  const mounted = React.useSyncExternalStore(noopSubscribe, () => true, () => false);\n  if (!mounted) return null;\n  return createPortal(<AnimatePresence>{open && <Panel key=\"panel\" {...props} />}</AnimatePresence>, document.body);\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","combobox","autocomplete","search","select","form","async"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/combobox"}}