{"$schema":"https://ui.artbloom.tech/schema/registry-item.json","name":"combobox","type":"registry:ui","title":"Combobox","description":"An autocomplete combobox with filtering, keyboard navigation and an ARIA listbox. Self-contained — its own class-merge helper, no external dependencies.","author":"@artbloom","dependencies":[],"registryDependencies":[],"files":[{"path":"components/ui/combobox.tsx","target":"components/ui/combobox.tsx","content":"'use client';\n\nimport {\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type KeyboardEvent,\n} from 'react';\n\n/**\n * An autocomplete combobox: type to filter a list, arrow to navigate, Enter to pick.\n *\n * The list is filtered by case-insensitive substring, then ranked by where the match\n * lands — a query at index 0 is a prefix match and sorts above one buried mid-word, so\n * \"re\" surfaces React before Preact. The matched run is marked in accent in every row.\n *\n * The single accent is violet: the theme's own `--accent` token is a neutral hover\n * grey, so the colour that means \"this is the choice\" comes from Tailwind's built-in\n * `violet` scale, shifted a step lighter in the dark to hold the same contrast.\n *\n * `compact` is the catalogue card. The same open list with the same highlighted match,\n * but nothing tabbable: the input is read-only and out of the tab order, the rows are\n * plain divs, and `touch-action: pan-y` lets a thumb scroll the page past the card\n * instead of getting caught selecting an option.\n */\n\n/** ~20 items, alphabetical so the no-query list reads as a tidy index. Frameworks,\n *  because the audience is developers and the sample query \"re\" ranks three prefix\n *  matches above one substring match — the ranking is visible in the demo, not asserted. */\nconst ITEMS = [\n  'Alpine.js',\n  'Angular',\n  'Astro',\n  'Backbone',\n  'Ember',\n  'Fresh',\n  'Gatsby',\n  'Lit',\n  'Meteor',\n  'Next.js',\n  'Nuxt',\n  'Preact',\n  'Qwik',\n  'React',\n  'Redwood',\n  'Remix',\n  'Solid',\n  'Svelte',\n  'SvelteKit',\n  'Vue',\n] as const;\n\n/** What the card and the freshly-mounted full widget open with, so neither is ever an\n *  empty box. Short enough to keep several matches on screen at once. */\nconst SAMPLE_QUERY = 're';\n\n/** Join truthy class fragments. Saves a clsx dependency for a component that needs no\n *  more than this. */\nfunction cn(...parts: Array<string | false | null | undefined>): string {\n  return parts.filter(Boolean).join(' ');\n}\n\n/** Rank order for the current query: substring matches only, earliest match first (so\n *  prefix matches lead), ties broken alphabetically. Empty query is the whole list. */\nfunction rank(query: string): string[] {\n  const q = query.trim().toLowerCase();\n  if (!q) return [...ITEMS];\n  return ITEMS.filter((item) => item.toLowerCase().includes(q)).sort((a, b) => {\n    const ai = a.toLowerCase().indexOf(q);\n    const bi = b.toLowerCase().indexOf(q);\n    if (ai !== bi) return ai - bi;\n    return a.localeCompare(b);\n  });\n}\n\n/** The option label with its matched run wrapped for highlighting. Splits on the first\n *  case-insensitive hit; a blank or absent match returns the plain label. */\nfunction highlight(label: string, query: string) {\n  const q = query.trim();\n  if (!q) return label;\n  const at = label.toLowerCase().indexOf(q.toLowerCase());\n  if (at === -1) return label;\n  return (\n    <>\n      {label.slice(0, at)}\n      <mark className=\"bg-transparent font-semibold text-violet-600 dark:text-violet-300\">\n        {label.slice(at, at + q.length)}\n      </mark>\n      {label.slice(at + q.length)}\n    </>\n  );\n}\n\nfunction SearchIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\">\n      <circle cx=\"11\" cy=\"11\" r=\"7\" />\n      <path d=\"m20 20-3.2-3.2\" />\n    </svg>\n  );\n}\n\nfunction XIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3.5\">\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\">\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nexport function Combobox({\n  compact = false,\n  className,\n}: {\n  compact?: boolean;\n  className?: string;\n}) {\n  const [query, setQuery] = useState<string>(SAMPLE_QUERY);\n  const [selected, setSelected] = useState<string | null>(null);\n  const [open, setOpen] = useState<boolean>(true);\n  const [activeIndex, setActiveIndex] = useState<number>(0);\n\n  const uid = useId();\n  const inputId = `${uid}-input`;\n  const labelId = `${uid}-label`;\n  const listId = `${uid}-list`;\n  const optionId = (i: number) => `${uid}-opt-${i}`;\n\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const listRef = useRef<HTMLUListElement | null>(null);\n\n  const results = useMemo(() => rank(query), [query]);\n\n  // The card is a fixed exhibit: always open, and its \"chosen\" row is the top match so\n  // the check has something to sit on. The live widget tracks a real selection instead.\n  const isOpen = compact ? true : open;\n  const selectedValue = compact ? results[0] ?? null : selected;\n\n  // A new query invalidates the old cursor; land it back on the strongest match.\n  useEffect(() => {\n    setActiveIndex(0);\n  }, [query]);\n\n  // Keep the cursored row in view as ↑/↓ walk past the visible window. `nearest` so a\n  // row already on screen doesn't jerk, and it scrolls the list, never the page.\n  useEffect(() => {\n    if (compact || !isOpen) return;\n    const row = listRef.current?.querySelector<HTMLElement>(`#${CSS.escape(optionId(activeIndex))}`);\n    row?.scrollIntoView({ block: 'nearest' });\n  });\n\n  function commit(value: string) {\n    setSelected(value);\n    setQuery(value);\n    setOpen(false);\n    inputRef.current?.focus();\n  }\n\n  function clear() {\n    setSelected(null);\n    setQuery('');\n    setOpen(true);\n    inputRef.current?.focus();\n  }\n\n  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {\n    if (compact) return;\n\n    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n      event.preventDefault();\n      if (!open) {\n        setOpen(true);\n        return;\n      }\n      if (results.length === 0) return;\n      const step = event.key === 'ArrowDown' ? 1 : -1;\n      // Wrap at both ends: (i + step + n) % n stays positive for step = -1.\n      setActiveIndex((i) => (i + step + results.length) % results.length);\n      return;\n    }\n\n    if (event.key === 'Enter') {\n      if (open && results[activeIndex]) {\n        event.preventDefault();\n        commit(results[activeIndex]);\n      }\n      return;\n    }\n\n    if (event.key === 'Escape') {\n      if (open) {\n        event.preventDefault();\n        setOpen(false);\n      }\n      return;\n    }\n  }\n\n  const activeDescendant =\n    isOpen && results.length > 0 && activeIndex < results.length\n      ? optionId(activeIndex)\n      : undefined;\n\n  return (\n    <div\n      className={cn(\n        'mx-auto w-full max-w-sm text-left',\n        compact && 'touch-pan-y select-none',\n        className,\n      )}\n      onBlur={(event) => {\n        if (compact) return;\n        if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;\n        setOpen(false);\n      }}\n    >\n      <label\n        id={labelId}\n        htmlFor={inputId}\n        className=\"mb-1.5 block text-sm font-medium text-foreground\"\n      >\n        Framework\n      </label>\n\n      <div className=\"relative\">\n        <span className=\"pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-muted-foreground\">\n          <SearchIcon />\n        </span>\n\n        <input\n          ref={inputRef}\n          id={inputId}\n          type=\"text\"\n          role=\"combobox\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          aria-labelledby={labelId}\n          aria-expanded={isOpen}\n          aria-controls={listId}\n          aria-autocomplete=\"list\"\n          aria-activedescendant={activeDescendant}\n          readOnly={compact}\n          tabIndex={compact ? -1 : undefined}\n          placeholder=\"Search frameworks…\"\n          value={query}\n          onChange={(event) => {\n            if (compact) return;\n            setQuery(event.target.value);\n            setOpen(true);\n          }}\n          onFocus={() => {\n            if (compact) return;\n            setOpen(true);\n          }}\n          onKeyDown={handleKeyDown}\n          className={cn(\n            'w-full rounded-lg border border-input bg-background py-2.5 pl-10 pr-10 text-sm text-foreground shadow-sm',\n            'outline-none transition-[color,border-color,box-shadow] motion-reduce:transition-none',\n            'placeholder:text-muted-foreground',\n            'focus:border-violet-500 focus:ring-4 focus:ring-violet-500/15',\n            'dark:focus:border-violet-400 dark:focus:ring-violet-400/20',\n            compact && 'cursor-default',\n          )}\n        />\n\n        {query && (\n          <button\n            type=\"button\"\n            aria-label=\"Clear selection\"\n            aria-hidden={compact}\n            tabIndex={compact ? -1 : undefined}\n            onMouseDown={(event) => event.preventDefault()}\n            onClick={() => {\n              if (compact) return;\n              clear();\n            }}\n            className={cn(\n              'absolute inset-y-0 right-0 my-1.5 mr-1.5 flex items-center rounded-md px-2 text-muted-foreground',\n              'transition-colors motion-reduce:transition-none hover:bg-muted hover:text-foreground',\n              'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/40',\n              compact && 'pointer-events-none',\n            )}\n          >\n            <XIcon />\n          </button>\n        )}\n\n        {isOpen && (\n          <ul\n            ref={listRef}\n            id={listId}\n            role=\"listbox\"\n            aria-labelledby={labelId}\n            className={cn(\n              'absolute z-50 mt-2 max-h-64 w-full origin-top overflow-y-auto rounded-xl border border-border bg-popover p-1.5 text-popover-foreground',\n              'shadow-lg shadow-black/5 ring-1 ring-black/[0.02] dark:shadow-black/40 dark:ring-white/[0.04]',\n              'motion-safe:animate-in motion-safe:fade-in-0 motion-safe:zoom-in-95 motion-safe:slide-in-from-top-1 motion-safe:duration-150',\n              compact && 'touch-pan-y',\n            )}\n          >\n            {results.length === 0 && (\n              <li\n                role=\"presentation\"\n                className=\"px-3 py-6 text-center text-sm text-muted-foreground\"\n              >\n                No frameworks found.\n              </li>\n            )}\n\n            {results.map((item, index) => {\n              const active = index === activeIndex;\n              const chosen = item === selectedValue;\n              return (\n                <li\n                  key={item}\n                  id={optionId(index)}\n                  role=\"option\"\n                  aria-selected={chosen}\n                  data-active={active || undefined}\n                  onMouseDown={(event) => event.preventDefault()}\n                  onMouseEnter={() => {\n                    if (compact) return;\n                    setActiveIndex(index);\n                  }}\n                  onClick={() => {\n                    if (compact) return;\n                    commit(item);\n                  }}\n                  className={cn(\n                    'flex select-none items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm text-popover-foreground/90',\n                    !compact && 'cursor-pointer',\n                    'transition-colors motion-reduce:transition-none',\n                    'data-[active]:bg-violet-500/10 data-[active]:text-foreground dark:data-[active]:bg-violet-400/15',\n                  )}\n                >\n                  <span className=\"truncate\">{highlight(item, query)}</span>\n                  {chosen && (\n                    <span className=\"shrink-0 text-violet-600 dark:text-violet-300\">\n                      <CheckIcon />\n                    </span>\n                  )}\n                </li>\n              );\n            })}\n          </ul>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Combobox;\n","type":"registry:ui"}],"meta":{"kind":"components","categories":["inputs","pickers"],"docs":"https://ui.artbloom.tech/artbloom/components/combobox"}}