Saltar al contenido
ssntl/ui

Combobox

Una lista con búsqueda para conjuntos largos de opciones: escribir para filtrar en lugar de desplazarse.

Instalación

$ pnpm dlx shadcn@latest add @ssntl/combobox

Uso

tsx
import { Combobox } from "@/components/ui/combobox"

<Combobox
  id="departamento"
  options={[{ value: "peten", label: "Petén" }, …]}
  placeholder="Buscar departamento"
  emptyText="Sin resultados"
  clearLabel="Borrar"
  onValueChange={setDepartamento}
/>
  • Buscar en lugar de desplazarse: con más de una docena de opciones, un campo de búsqueda gana a una lista larga.
  • El filtro ignora mayúsculas y acentos, así “peten” encuentra Petén.
  • Las flechas recorren los resultados, Enter elige uno y Escape cierra y restaura el valor (patrón ARIA combobox).
  • Úsalo con un FieldLabel; define emptyText y clearLabel en el idioma de la persona.

Código

components/ui/combobox.tsx
"use client"

import * as React from "react"
import { Check, Search, X } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ComboboxOption {
  value: string
  label: string
}

// Search over scrolling: past a dozen options, typing beats scanning. The
// field filters as you type (ignoring case and accents), arrows move through
// the matches, Enter picks one, Escape closes. It follows the ARIA combobox
// pattern, so screen readers announce the list and the active option.
const normalize = (text: string) =>
  text
    .normalize("NFD")
    .replace(/\p{Diacritic}/gu, "")
    .toLowerCase()

function Combobox({
  options,
  value,
  defaultValue,
  onValueChange,
  placeholder,
  emptyText = "No matches",
  clearLabel = "Clear",
  className,
  ...props
}: Omit<
  React.ComponentProps<"input">,
  "value" | "defaultValue" | "onChange"
> & {
  options: ComboboxOption[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  /** Shown when the search has no match. */
  emptyText?: string
  /** The clear button's accessible name. */
  clearLabel?: string
}) {
  const listId = React.useId()
  const [selected, setSelected] = React.useState(defaultValue ?? "")
  const current = value ?? selected
  const label = options.find((o) => o.value === current)?.label ?? ""
  const [query, setQuery] = React.useState(label)
  const [open, setOpen] = React.useState(false)
  const [active, setActive] = React.useState(0)
  const inputRef = React.useRef<HTMLInputElement>(null)

  const matches = React.useMemo(() => {
    const q = normalize(query.trim())
    if (!q || query === label) return options
    return options.filter((o) => normalize(o.label).includes(q))
  }, [options, query, label])

  const choose = (option: ComboboxOption) => {
    setSelected(option.value)
    onValueChange?.(option.value)
    setQuery(option.label)
    setOpen(false)
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault()
      if (!open) return setOpen(true)
      const step = event.key === "ArrowDown" ? 1 : -1
      setActive(
        (i) => (i + step + matches.length) % Math.max(matches.length, 1)
      )
    } else if (event.key === "Enter" && open && matches[active]) {
      event.preventDefault()
      choose(matches[active])
    } else if (event.key === "Escape") {
      setOpen(false)
      setQuery(label)
    }
  }

  return (
    <div data-slot="combobox" className={cn("relative", className)}>
      <div className="flex h-12 items-center gap-3 rounded-sm border border-input bg-card ps-4 pe-1 transition-[border-color] duration-(--duration-fast) ease-out focus-within:border-foreground focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring hover:border-muted-foreground">
        <Search
          aria-hidden
          strokeWidth={1.6}
          className="size-5 shrink-0 text-muted-foreground"
        />
        <input
          ref={inputRef}
          role="combobox"
          aria-expanded={open}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={
            open && matches[active] ? `${listId}-${active}` : undefined
          }
          autoComplete="off"
          value={query}
          placeholder={placeholder}
          onChange={(event) => {
            setQuery(event.target.value)
            setActive(0)
            setOpen(true)
          }}
          onFocus={() => setOpen(true)}
          onBlur={() => {
            setOpen(false)
            setQuery(label)
          }}
          onKeyDown={onKeyDown}
          className="h-full min-w-0 flex-1 bg-transparent text-body outline-none placeholder:text-muted-foreground"
          {...props}
        />
        {query && (
          <button
            type="button"
            aria-label={clearLabel}
            onMouseDown={(event) => event.preventDefault()}
            onClick={() => {
              setQuery("")
              setOpen(true)
              inputRef.current?.focus()
            }}
            className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-xs text-muted-foreground transition-[background-color,color] duration-(--duration-fast) ease-out hover:bg-secondary hover:text-foreground"
          >
            <X aria-hidden strokeWidth={1.6} className="size-4" />
          </button>
        )}
      </div>

      <ul
        id={listId}
        role="listbox"
        hidden={!open}
        className="absolute inset-x-0 top-full z-10 mt-2 max-h-72 overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-float"
      >
        {matches.map((option, i) => (
          <li
            key={option.value}
            id={`${listId}-${i}`}
            role="option"
            aria-selected={option.value === current}
            data-active={i === active || undefined}
            onMouseDown={(event) => event.preventDefault()}
            onMouseMove={() => setActive(i)}
            onClick={() => choose(option)}
            className="flex min-h-11 cursor-pointer items-center justify-between gap-3 rounded-sm px-3 text-body data-active:bg-secondary"
          >
            {option.label}
            {option.value === current && (
              <Check
                aria-hidden
                strokeWidth={1.6}
                className="size-5 shrink-0"
              />
            )}
          </li>
        ))}
        {matches.length === 0 && (
          <li
            role="presentation"
            className="px-3 py-3 text-caption text-muted-foreground"
          >
            {emptyText}
          </li>
        )}
      </ul>
    </div>
  )
}

export { Combobox }