{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"select","type":"registry:ui","title":"Select","description":"Single-choice select on the field well with avatars, status dots, icons, groups and an optional search box; a volt highlight glides between options and the panel springs open, flipping above near the viewport edge.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/field"],"files":[{"path":"registry/geturui/ui/select.tsx","type":"registry:ui","target":"components/ui/select.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown, Search } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Field, fieldWellClasses } from \"@/components/ui/field\";\nimport {\n  Kbd,\n  Listbox,\n  ListboxPopover,\n  OptionMedia,\n  defaultFilter,\n  filterEntries,\n  flattenEntries,\n  isPrintableKey,\n  listboxIconSize,\n  optionId,\n  useIsMac,\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 SelectEntry, ListboxOption as SelectOption, ListboxGroup as SelectGroup } from \"@/components/ui/listbox\";\n\nexport interface SelectProps {\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  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Well height 32 / 40 / 48. @default \"md\" */\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 \"Select an option\" */\n  placeholder?: string;\n  /** Icon shown in the trigger when the selected option has no media of its own. */\n  iconLeading?: ListboxIcon;\n  /** Adds a search box at the top of the panel. */\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  /** Shown when the search matches nothing. */\n  emptyText?: React.ReactNode;\n  /** Letter that opens the select with ⌘ (Ctrl elsewhere); shown as a hint in the trigger. */\n  shortcut?: string;\n  /** Form field name; the value is posted through a hidden input. */\n  name?: string;\n  /** Show the option's description next to the selected label. @default true */\n  showDescription?: boolean;\n  /** Accessible name when there is no visible label. */\n  \"aria-label\"?: string;\n  className?: string;\n  triggerClassName?: string;\n}\n\n/** Single-choice select on the field well: a gliding volt highlight, a check that draws itself and a value that cross-slides in the trigger. */\nexport function Select({\n  options,\n  value,\n  defaultValue = null,\n  onValueChange,\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  size = \"md\",\n  label,\n  hint,\n  error,\n  required,\n  disabled,\n  labelAside,\n  id,\n  placeholder = \"Select an option\",\n  iconLeading: IconLeading,\n  searchable,\n  searchPlaceholder = \"Search\",\n  emptyText = \"No matches\",\n  shortcut,\n  name,\n  showDescription = true,\n  \"aria-label\": ariaLabel,\n  className,\n  triggerClassName,\n}: SelectProps) {\n  const reduce = useReducedMotion();\n  const isMac = useIsMac();\n  const listId = useSafeId(\"lb\");\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const [ownValue, setOwnValue] = React.useState<string | null>(defaultValue);\n  const current = value === undefined ? ownValue : value;\n  const [ownOpen, setOwnOpen] = React.useState(defaultOpen);\n  const isOpen = (open ?? ownOpen) && !disabled;\n  const [query, setQuery] = React.useState(\"\");\n\n  const all = React.useMemo(() => flattenEntries(options), [options]);\n  const selected = all.find((o) => o.value === current) ?? null;\n  const visible = React.useMemo(() => (searchable && query.trim() ? filterEntries(options, (o) => defaultFilter(o, query)) : options), [options, searchable, query]);\n  const visibleFlat = React.useMemo(() => flattenEntries(visible), [visible]);\n  const nav = useListboxNavigation(visibleFlat, { autoActivateFirst: Boolean(query.trim()) });\n\n  const setOpen = (next: boolean, restoreFocus = true) => {\n    if (open === undefined) setOwnOpen(next);\n    onOpenChange?.(next);\n    if (!next) {\n      setQuery(\"\");\n      if (restoreFocus) triggerRef.current?.focus();\n    }\n  };\n\n  const openAt = (target: \"selected\" | \"first\" | \"last\") => {\n    const enabled = all.filter((o) => !o.disabled);\n    const pick =\n      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    setOpen(true);\n  };\n\n  const choose = (option: ListboxOption) => {\n    if (option.disabled) return;\n    if (value === undefined) setOwnValue(option.value);\n    onValueChange?.(option.value, option);\n    setOpen(false);\n  };\n\n  const chooseActive = () => {\n    const o = visibleFlat.find((x) => x.value === nav.active);\n    if (o) choose(o);\n  };\n\n  // Keys shared by the trigger (select-only) and the search box.\n  const onListKey = (e: React.KeyboardEvent, textInput: boolean) => {\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault();\n        nav.step(1);\n        return;\n      case \"ArrowUp\":\n        e.preventDefault();\n        if (e.altKey) chooseActive();\n        else nav.step(-1);\n        return;\n      case \"PageDown\":\n        e.preventDefault();\n        nav.step(10);\n        return;\n      case \"PageUp\":\n        e.preventDefault();\n        nav.step(-10);\n        return;\n      case \"Home\":\n      case \"End\":\n        if (textInput) return;\n        e.preventDefault();\n        nav.edge(e.key === \"Home\" ? \"first\" : \"last\");\n        return;\n      case \"Enter\":\n        e.preventDefault();\n        chooseActive();\n        return;\n      case \" \":\n        if (textInput) return;\n        e.preventDefault();\n        chooseActive();\n        return;\n      case \"Escape\":\n        e.preventDefault();\n        e.stopPropagation();\n        setOpen(false);\n        return;\n      case \"Tab\":\n        // APG select-only: Tab commits the highlighted option. From the search box, hand focus back to the trigger first.\n        if (!textInput) chooseActive();\n        else setOpen(false);\n        return;\n      default:\n        if (!textInput && isPrintableKey(e)) nav.typeahead(e.key, e.timeStamp);\n    }\n  };\n\n  const onTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (!isOpen) {\n      if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        openAt(\"selected\");\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        openAt(current ? \"selected\" : \"last\");\n      } else if (e.key === \"Home\" || e.key === \"End\") {\n        e.preventDefault();\n        openAt(e.key === \"Home\" ? \"first\" : \"last\");\n      } else if (isPrintableKey(e)) {\n        if (searchable) {\n          setQuery(e.key);\n          setOpen(true);\n        } else {\n          setOpen(true);\n          nav.typeahead(e.key, e.timeStamp);\n        }\n      }\n      return;\n    }\n    if (!searchable) onListKey(e, false);\n  };\n\n  // Global ⌘/Ctrl + letter opens the select.\n  const openRef = React.useRef(() => {});\n  React.useEffect(() => {\n    openRef.current = () => {\n      triggerRef.current?.focus();\n      openAt(\"selected\");\n    };\n  });\n  React.useEffect(() => {\n    if (!shortcut || disabled) return;\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === shortcut.toLowerCase()) {\n        e.preventDefault();\n        openRef.current();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, disabled]);\n\n  const activeId = isOpen && nav.active ? optionId(listId, nav.active) : undefined;\n  const LeadingIcon = selected && (selected.icon || selected.avatar || selected.dot) ? null : IconLeading;\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          <button\n            ref={triggerRef}\n            id={controlId}\n            type=\"button\"\n            role=\"combobox\"\n            aria-haspopup=\"listbox\"\n            aria-expanded={isOpen}\n            aria-controls={isOpen ? listId : undefined}\n            aria-activedescendant={!searchable ? activeId : undefined}\n            aria-labelledby={label ? `${labelId} ${controlId}-value` : undefined}\n            aria-label={label ? undefined : ariaLabel}\n            aria-describedby={describedBy}\n            aria-invalid={invalid || undefined}\n            aria-required={required || undefined}\n            disabled={disabled}\n            onClick={() => (isOpen ? setOpen(false) : openAt(\"selected\"))}\n            onKeyDown={onTriggerKeyDown}\n            className={cn(\n              fieldWellClasses({ size, invalid, disabled }),\n              \"cursor-pointer select-none text-left outline-none\",\n              isOpen && !invalid && \"border-volt-ink shadow-[0_0_0_3px_var(--volt-soft)]\",\n              triggerClassName,\n            )}\n          >\n            {LeadingIcon && (\n              <span aria-hidden className=\"inline-flex shrink-0 text-ink-subtle\">\n                <LeadingIcon className={listboxIconSize[size]} />\n              </span>\n            )}\n            <span id={`${controlId}-value`} className=\"relative flex min-w-0 flex-1 overflow-hidden\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={selected?.value ?? \"__placeholder\"}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: \"blur(4px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -10, filter: \"blur(4px)\" }}\n                  transition={{ type: \"spring\", stiffness: 420, damping: 32 }}\n                  className=\"flex min-w-0 flex-1 items-center gap-[inherit]\"\n                  style={{ gap: size === \"lg\" ? 10 : 8 }}\n                >\n                  {selected ? (\n                    <>\n                      <OptionMedia option={selected} size={size} />\n                      <span className=\"truncate text-ink\">{selected.label}</span>\n                      {showDescription && typeof selected.description === \"string\" && (\n                        <span className=\"hidden truncate text-ink-subtle sm:inline\">{selected.description}</span>\n                      )}\n                    </>\n                  ) : (\n                    <span className=\"truncate\">{placeholder}</span>\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            {shortcut && (\n              <Kbd className=\"hidden sm:inline-flex\">\n                <span className=\"sr-only\">Shortcut: </span>\n                {isMac ? \"⌘\" : \"Ctrl \"}\n                {shortcut.toUpperCase()}\n              </Kbd>\n            )}\n            <motion.span\n              aria-hidden\n              animate={{ rotate: isOpen ? 180 : 0 }}\n              transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 420, damping: 28 }}\n              className=\"inline-flex shrink-0 text-ink-subtle\"\n            >\n              <ChevronDown className={listboxIconSize[size]} />\n            </motion.span>\n          </button>\n          {name && <input type=\"hidden\" name={name} value={current ?? \"\"} />}\n          <ListboxPopover open={isOpen} anchorRef={triggerRef} onDismiss={() => setOpen(false, false)}>\n            {searchable && (\n              <div className=\"flex shrink-0 items-center gap-2 border-b border-border px-3\">\n                <Search aria-hidden className=\"size-4 shrink-0 text-ink-subtle\" />\n                <input\n                  autoFocus\n                  role=\"combobox\"\n                  aria-expanded\n                  aria-controls={listId}\n                  aria-autocomplete=\"list\"\n                  aria-activedescendant={activeId}\n                  aria-label={typeof label === \"string\" ? `Search ${label.toLowerCase()}` : \"Search options\"}\n                  value={query}\n                  placeholder={searchPlaceholder}\n                  onChange={(e) => {\n                    setQuery(e.target.value);\n                    nav.setActive(null);\n                  }}\n                  onKeyDown={(e) => onListKey(e, true)}\n                  className=\"h-10 min-w-0 flex-1 bg-transparent text-sm text-ink outline-none placeholder:text-ink-subtle\"\n                />\n              </div>\n            )}\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={searchable ? query : undefined}\n              aria-labelledby={label ? labelId : undefined}\n              aria-label={label ? undefined : (ariaLabel ?? \"Options\")}\n              empty={\n                <p role=\"status\" className=\"px-3 py-6 text-center text-sm text-ink-subtle\">\n                  {emptyText}\n                </p>\n              }\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","select","dropdown","listbox","form","picker","search"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/select"}}