Combobox
ProntoSelect com busca embutida. Ideal para listas grandes (50+ opções) ou quando velocidade de digitação supera scroll.
Usar quando
Buscar tribunal, comarca, advogado, contato em listas grandes. Quando usuário sabe o que procura.
Não usar quando
Lista pequena (<10, use Select). Lista exibida sempre (use lista filtrada inline).
Variantes
Estados
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Open dropdown | focus no input | Listbox abre com fade | 200ms cubic-bezier(0.32,0.72,0,1) |
| Filter live | onChange do input | Filtra opções incrementalmente | instant (sync) ou 300ms debounce (async) |
| Highlight termo | busca tem matches | Substring buscada fica destacada com brass-tint | instant |
| Chip add (multi) | select item | Chip aparece com scale 0.8→1 | 200ms |
| Chip remove | click X no chip | Chip diminui horizontalmente + opacity 1→0 | 150ms |
| Loading shimmer | async fetch in flight | Skeletons brilhando no listbox | 1500ms loop |
| Empty result | busca sem matches | Mostra "Sem resultados para X" + sugestão limpar | instant |
Acessibilidade
Acessibilidade — checklist
Teclado
| Tab | Foco no input |
| Digitar | Filtra opções |
| ↓ / ↑ | Navega resultados |
| Enter | Seleciona em foco |
| Esc | Fecha sem alterar |
| Backspace (multi, input vazio) | Remove último chip |
ARIA esperado
- role="combobox" aria-haspopup="listbox" aria-expanded
- aria-autocomplete="list"
- aria-controls="listbox-id"
- aria-activedescendant="opt-id"
- <ul role="listbox"><li role="option" aria-selected />
Notas
- Multi-select: use chips visíveis no input + Backspace remove último.
- Async: debounce 300ms entre keystroke e fetch.
- Loading: mostre skeletons no listbox, NÃO bloqueie input.
- Empty result com sugestão (típico: "Limpar busca" ou "Criar X com este nome").
Código
'use client';
import { useState, useMemo } from 'react';
import { Check } from 'lucide-react';
import { T, TYPE, SP, RADIUS, transition } from '@/lib/tokens';
interface ComboboxProps<T extends { value: string; label: string }> {
options: T[];
value: string | null;
onChange: (v: string | null) => void;
placeholder?: string;
}
export function Combobox<T extends { value: string; label: string }>({
options, value, onChange, placeholder
}: ComboboxProps<T>) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const filtered = useMemo(
() => options.filter(o => o.label.toLowerCase().includes(query.toLowerCase())),
[options, query]
);
const selected = options.find(o => o.value === value);
return (
<div style={{ position: 'relative' }}>
<input
type="text"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
value={open ? query : selected?.label ?? ''}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 200)}
placeholder={placeholder}
// ... styles
/>
{open && (
<ul role="listbox">
{filtered.map(o => (
<li key={o.value} role="option" aria-selected={o.value === value}
onMouseDown={() => { onChange(o.value); setOpen(false); setQuery(''); }}>
{o.label}
{o.value === value && <Check size={14} />}
</li>
))}
{filtered.length === 0 && <li>Sem resultados para "{query}"</li>}
</ul>
)}
</div>
);
}Regras
Faça
- ✓Use para listas com 50+ opções ou onde busca acelera.
- ✓Filter live (sync) se lista <500; async com debounce 300ms se >500.
- ✓Multi: use chips com X para remover.
- ✓Highlight do termo buscado no resultado (brass-tint).
- ✓Empty result com mensagem amigável + ação ("Criar X com esse nome").
- ✓Loading: skeleton no listbox, input continua editável.
Não faça
- ✗Não use para 5-50 opções fixas (use Select).
- ✗Não bloqueie digitação durante async (deixe usuário digitar adiante).
- ✗Não force scroll infinito sem feedback visual.
- ✗Não use modal pra esconder o combobox — ele já é compacto.
- ✗Não esconda chips quando focou no input (multi).
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.surface | #FFFFFF | background |
T.surfaceHover | #F7F5EC | hover de item |
T.brassTint | rgba(164,124,43,0.08) | background do chip + highlight |
T.mark | rgba(164,124,43,0.22) | highlight de termo buscado |
RADIUS.md | 12 | borderRadius do input |
RADIUS.pill | 980 | chip |