AutocompleteDropdown
Preview
Basic
Loading…
Preview
Code
ts
import AutocompleteDropdown from '@/components/form/AutocompleteDropdown';src/components/form/AutocompleteDropdown.tsx
AI prompt
text
Build a single-select autocomplete combobox (type-ahead, optional option groups) component in React + TypeScript + Tailwind CSS.
## Look
- ONE text input is the whole trigger (house input recipe, `pr-12`): closed it shows the selected option's label (or the placeholder); open it is the search box, emptied, with `searchPlaceholder`. Disabled: `opacity-60 cursor-not-allowed`.
- Right side (`absolute inset-y-0 right-0 pr-2 flex items-center`): a clear X button (14px, `p-1`, slate-400 → hover slate-600 / dark slate-300), only while a value is set; then a 14px lucide ChevronsUpDown in slate-400.
- Panel: absolute `z-50 w-full`, opaque floating surface, `p-1 text-xs`, max-height 15rem scrolling with a thin scrollbar, 200ms fade-in. Opens below (`mt-1`) or, when fewer than 280px of viewport remain under the input at focus time, above (`bottom-full mb-1`).
- Options (`space-y-0.5`): `relative py-1.5 pl-7 pr-3 rounded-lg cursor-pointer select-none transition-colors`, label truncating. The highlighted row is `bg-indigo-600 text-white`; others slate-700 / dark slate-200. The selected option has a 14px Check at `left-2`, vertically centred (it inherits the text colour, so white on the highlight).
- Groups: if any option carries `group`, render groups in first-appearance order under STICKY headings (`sticky top-0 z-10 bg-white dark:bg-slate-800 px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500`); otherwise a flat list.
- No matches for a non-empty query: `emptyText` in 11px slate-400, `px-3 py-2`.
## Behaviour
- Focus opens the panel, clears the query, and highlights the currently selected option (else the first) — so Enter is a no-op instead of silently reassigning the field.
- Filter: case-insensitive substring on the label OR the group, so typing a group name narrows to that group.
- Typing resets the highlight to the first match. ArrowUp/ArrowDown wrap, Home/End jump, the highlighted row scrolls into view (`block: 'nearest'`). Keyboard order is RENDER order (grouped). Hover moves the highlight too, so mouse and keyboard always agree on what Enter will pick.
- Enter commits the highlighted option with `preventDefault`, so it never submits a surrounding `<form>`. Arrow keys or Enter on a focused, closed input reopen it.
- Picking the already-selected option deselects it (`onChange(null)`). Picking closes and clears the query. Escape closes and blurs; Tab closes without committing.
## API
`options: { value: string; label: string; group?: string }[]`, `value: string | null`, `onChange(value: string | null)`, `placeholder`, `searchPlaceholder`, `emptyText` (all required strings), `className?`, `disabled = false`. Named export `AutocompleteDropdown`.
## Accessibility
Input `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded`, `autoComplete="off"`; list `role="listbox"`; rows `role="option"` with `aria-selected` on the chosen one; clear button "Clear selection".
## Demo
"Select a person" over people grouped by team — Engineering: Ada Lovelace, Grace Hopper, Barbara Liskov; Research: Alan Turing, Katherine J., Edsger D.; Design: Alan Kay, Margaret H. — search placeholder "Search people…", empty text "No one matches", selected value echoed below.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: marketing-stats (96S1), verbatim. */
import * as React from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useDismiss } from '@/lib/use-dismiss';
interface AutocompleteDropdownOption {
value: string;
label: string;
/**
* Optional heading this option sits under. When any option carries one, the list
* renders as sticky group headings with their options indented beneath, instead of a
* flat list — useful when the label alone is ambiguous (two agents can share a name
* across brands) or when the list is long enough that a flat scroll is hard to scan.
*
* Purely presentational: `value` is still what's selected, and search still matches on
* `label` AND `group`, so typing a group name narrows to that group's options.
*/
group?: string;
}
interface AutocompleteDropdownProps {
options: AutocompleteDropdownOption[];
value: string | null;
onChange: (value: string | null) => void;
placeholder: string;
searchPlaceholder: string;
emptyText: string;
className?: string;
disabled?: boolean;
}
export function AutocompleteDropdown({ options, value, onChange, placeholder, searchPlaceholder, emptyText, className = '', disabled = false }: AutocompleteDropdownProps) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const [position, setPosition] = React.useState<'top' | 'bottom'>('bottom');
const filteredOptions =
query === ''
? options
// Group is searchable too — otherwise grouping would hide the narrowing the
// caller previously got from a separate group-level picker.
: options.filter(option =>
option.label.toLowerCase().includes(query.toLowerCase())
|| (option.group ?? '').toLowerCase().includes(query.toLowerCase())
);
// Grouped rendering kicks in only when options actually carry groups, so every
// existing caller keeps its flat list untouched. Insertion order is preserved so the
// caller controls group order by sorting its options.
const isGrouped = filteredOptions.some(option => option.group);
const groupedOptions = React.useMemo(() => {
const map = new Map<string, AutocompleteDropdownOption[]>();
for (const option of filteredOptions) {
const key = option.group ?? '';
const list = map.get(key) ?? [];
list.push(option);
map.set(key, list);
}
return [...map.entries()];
}, [filteredOptions]);
// Flat list in RENDER order (grouped or not) — the sequence arrow keys walk, so
// keyboard order always matches what the eye sees.
const flatOptions = React.useMemo(
() => (isGrouped ? groupedOptions.flatMap(([, groupOptions]) => groupOptions) : filteredOptions),
[isGrouped, groupedOptions, filteredOptions]
);
// Index into flatOptions of the keyboard-highlighted row. Hovering syncs it, so
// mouse and keyboard can't disagree about what Enter would pick.
const [activeIndex, setActiveIndex] = React.useState(0);
const selectedLabel = options.find(option => option.value === value)?.label;
const containerRef = React.useRef<HTMLDivElement>(null);
const dropdownRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
useDismiss(containerRef, open, () => setOpen(false));
// Typing re-filters the list, so the highlight goes back to the best (first)
// match — the usual type-ahead behaviour.
React.useEffect(() => { setActiveIndex(0); }, [query]);
// Keep the highlighted row in view when arrowing past the visible window.
React.useEffect(() => {
if (!open) return;
listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'nearest' });
}, [activeIndex, open]);
// Focusing the field itself opens the dropdown and starts a fresh search —
// there's no separate nested search input anymore, this one field shows
// the selected label while closed and doubles as the filter text while open.
const handleFocus = () => {
if (dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
// Estimate dropdown height (max-h-60 is 15rem/240px + padding/input)
setPosition(spaceBelow < 280 ? 'top' : 'bottom');
}
setQuery('');
// Opening with a selection already made starts the highlight on that row
// rather than the top of the list, so Enter is a no-op instead of silently
// reassigning the field to whatever happens to sort first.
const selectedIndex = options.findIndex(option => option.value === value);
setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
setOpen(true);
};
const commitSelection = (option: AutocompleteDropdownOption) => {
onChange(option.value === value ? null : option.value);
setOpen(false);
setQuery('');
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setOpen(false);
e.currentTarget.blur();
return;
}
// Tab commits nothing and just lets focus leave — closing here stops an
// orphaned dropdown hanging over the next field.
if (e.key === 'Tab') {
setOpen(false);
return;
}
if (!open) {
// Arrow/Enter on a closed, focused field reopens it rather than doing nothing.
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') {
e.preventDefault();
handleFocus();
}
return;
}
if (flatOptions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => (i + 1) % flatOptions.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => (i - 1 + flatOptions.length) % flatOptions.length);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(flatOptions.length - 1);
break;
case 'Enter': {
// preventDefault matters beyond the dropdown: these fields sit inside
// <form> panels (BalanceForm, PaymentForm, …), where a bare Enter would
// submit the form instead of picking the highlighted option.
e.preventDefault();
const option = flatOptions[activeIndex];
if (option) commitSelection(option);
break;
}
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
<input
ref={dropdownRef}
type="text"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="open menu"
value={open ? query : (selectedLabel ?? '')}
onChange={(e) => setQuery(e.target.value)}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
placeholder={open ? searchPlaceholder : placeholder}
autoComplete="off"
className={`field-input pr-12 ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
disabled={disabled}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
{value && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(null);
}}
className="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
aria-label="Clear selection"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-slate-400" aria-hidden="true" />
</div>
{open && (
<div data-overlay="picker" className={`absolute z-50 w-full overflow-auto panel panel-solid p-1 text-xs animate-fade-in custom-scrollbar ${position === 'bottom' ? 'mt-1' : 'bottom-full mb-1'}`}
style={{ maxHeight: '15rem' }} // Equivalent to max-h-60
>
{filteredOptions.length === 0 && query !== '' ? (
<div className="px-3 py-2 text-[11px] text-slate-400">{emptyText}</div>
) : (
<div ref={listRef} role="listbox" className="space-y-0.5">
{(isGrouped ? groupedOptions : [['', filteredOptions] as const]).map(([group, groupOptions]) => (
<div key={group || '__ungrouped'}>
{group && (
// Sticky so the heading stays visible while scrolling a long group.
<div className="sticky top-0 z-10 bg-white dark:bg-slate-800 px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{group}
</div>
)}
{groupOptions.map(option => {
const isActive = flatOptions[activeIndex]?.value === option.value;
return (
<div
key={option.value}
role="option"
aria-selected={value === option.value}
data-active={isActive || undefined}
// Hover moves the highlight, so the mouse and the keyboard
// always agree on what Enter would commit.
onMouseEnter={() => setActiveIndex(flatOptions.findIndex(o => o.value === option.value))}
onClick={() => commitSelection(option)}
className={`relative cursor-pointer select-none py-1.5 pl-7 pr-3 rounded-lg transition-colors ${
isActive ? 'bg-indigo-600 text-white' : 'text-slate-700 dark:text-slate-200'
}`}
>
<span className="block truncate">{option.label}</span>
{value === option.value && <Check className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5" />}
</div>
);
})}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
options* | AutocompleteDropdownOption[] | — | |
value* | string | null | — | |
onChange* | (value: string | null) => void | — | |
placeholder* | string | — | |
searchPlaceholder* | string | — | |
emptyText* | string | — | |
className | string | '' | |
disabled | boolean | false |