SearchCombobox
Single or multi-select combobox with client filtering or async search, keyboard nav and clear.
import { SearchCombobox, ComboboxOption }from "@/components/ui"
Single & multiple
Full source
The complete component, read from src/components/ui/search-combobox.tsx.
search-combobox.tsx
"use client";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronsUpDown, X, Loader2, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePopoverPosition, useMounted } from "@/hooks";
import { Field, controlBase, controlClasses } from "./field";
export interface ComboboxOption {
value: string;
label: string;
[key: string]: unknown;
}
interface SearchComboboxProps<T = ComboboxOption> {
id?: string;
label?: string;
hint?: string;
error?: string;
disabled?: boolean;
options?: T[];
onSearch?: (query: string, signal: AbortSignal) => Promise<T[]>;
multiple?: boolean;
value: T | T[] | null;
onChange: (value: T | T[] | null) => void;
getOptionValue?: (o: T) => string;
getOptionLabel?: (o: T) => string;
getOptionMeta?: (o: T) => string | undefined;
minChars?: number;
placeholder?: string;
}
export function SearchCombobox<T = ComboboxOption>({
id,
label,
hint,
error,
disabled,
options,
onSearch,
multiple,
value,
onChange,
getOptionValue = (o: T) => (o as ComboboxOption).value,
getOptionLabel = (o: T) => (o as ComboboxOption).label,
getOptionMeta,
minChars = 0,
placeholder = "Search\u2026",
}: SearchComboboxProps<T>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const [remote, setRemote] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const position = usePopoverPosition(boxRef, open, { matchWidth: true, preferredHeight: 240 });
const mounted = useMounted();
const isRemote = typeof onSearch === "function";
const belowMin = query.trim().length < minChars;
useEffect(() => {
if (!isRemote || !open || belowMin) { if (isRemote) setRemote([]); return; }
const controller = new AbortController();
let live = true;
const t = setTimeout(async () => {
setLoading(true); setFetchError(null);
try {
const items = await onSearch!(query, controller.signal);
if (live) setRemote(items);
} catch (err: unknown) {
if (controller.signal.aborted || (err instanceof DOMException && err.name === "AbortError")) return;
if (live) setFetchError(err instanceof Error ? err.message : "Unknown error");
} finally { if (live) setLoading(false); }
}, 300);
return () => { live = false; clearTimeout(t); controller.abort(); };
}, [isRemote, open, query, belowMin, nonce]);
const items = useMemo(() => {
if (isRemote) return remote;
const q = query.trim().toLowerCase();
return !q ? (options || []) : (options || []).filter((o) => getOptionLabel(o).toLowerCase().includes(q));
}, [isRemote, remote, options, query, getOptionLabel]);
useEffect(() => {
const h = (e: globalThis.MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) { setOpen(false); setQuery(""); }
};
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const valueArray = multiple ? (value as T[] || []) : value ? [value as T] : [];
const selectedValues = new Set(valueArray.map(getOptionValue));
const hasValue = multiple ? (value as T[] || []).length > 0 : Boolean(value);
const pick = (o: T) => {
if (multiple) {
const v = getOptionValue(o);
const arr = value as T[] || [];
const has = arr.some((s) => getOptionValue(s) === v);
onChange(has ? arr.filter((s) => getOptionValue(s) !== v) : [...arr, o]);
setQuery("");
} else { onChange(o); setQuery(""); setOpen(false); }
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, items.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && open && items[active]) { e.preventDefault(); pick(items[active]); }
else if (e.key === "Escape") { setOpen(false); setQuery(""); }
else if (e.key === "Backspace" && !query && multiple && (value as T[]).length) {
onChange((value as T[]).slice(0, -1));
}
};
return (
<Field label={label} hint={hint} error={error} htmlFor={id}>
<div ref={boxRef} className="relative">
<div
onClick={() => {
if (disabled) return;
setOpen(true);
inputRef.current?.focus();
}}
className={cn(controlBase, controlClasses(error, disabled), "flex min-h-10 flex-wrap items-center gap-1 px-2 py-1", !disabled && "cursor-text")}
>
{multiple && (value as T[] || []).map((s) => (
<span key={getOptionValue(s)} className="inline-flex items-center gap-1 rounded bg-accent-light px-1.5 py-0.5 text-xs font-medium text-accent">
{getOptionLabel(s)}
<button onClick={(e) => { e.stopPropagation(); onChange((value as T[]).filter((x) => getOptionValue(x) !== getOptionValue(s))); }} aria-label={`Remove ${getOptionLabel(s)}`} className="rounded-full hover:opacity-70">
<X size={11} aria-hidden />
</button>
</span>
))}
{!multiple && value && !query && (
<span className="pointer-events-none absolute left-3 truncate text-sm text-text">{getOptionLabel(value as T)}</span>
)}
<input
id={id} role="combobox" aria-expanded={open} value={query} disabled={disabled}
ref={inputRef}
placeholder={hasValue ? "" : placeholder}
onFocus={() => setOpen(true)}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onKeyDown={onKeyDown}
className="min-w-16 flex-1 cursor-text bg-transparent px-1 py-0.5 text-sm text-text outline-none"
/>
<span className="ml-auto flex items-center gap-1 pl-1">
{loading && <Loader2 size={13} className="animate-spin text-text-tertiary" aria-hidden />}
{hasValue && !disabled && (
<button onClick={(e) => { e.stopPropagation(); onChange(multiple ? [] : null); }} aria-label="Clear" className="rounded p-0.5 text-text-tertiary hover:text-text">
<X size={14} aria-hidden />
</button>
)}
<ChevronsUpDown size={14} className="text-text-tertiary" aria-hidden />
</span>
</div>
{open && position && mounted && createPortal(
<ComboboxDropdown
items={items} loading={loading} fetchError={fetchError} belowMin={belowMin}
minChars={minChars} query={query} active={active} selectedValues={selectedValues}
position={position} getOptionValue={getOptionValue} getOptionLabel={getOptionLabel}
getOptionMeta={getOptionMeta} onPick={pick} onSetActive={setActive} onRetry={() => setNonce((n) => n + 1)}
/>,
document.body
)}
</div>
</Field>
);
}
function ComboboxDropdown<T>({ items, loading, fetchError, belowMin, minChars, query, active, selectedValues, position, getOptionValue, getOptionLabel, getOptionMeta, onPick, onSetActive, onRetry }: {
items: T[]; loading: boolean; fetchError: string | null; belowMin: boolean; minChars: number;
query: string; active: number; selectedValues: Set<string>; position: { style: React.CSSProperties };
getOptionValue: (o: T) => string; getOptionLabel: (o: T) => string;
getOptionMeta?: (o: T) => string | undefined;
onPick: (o: T) => void; onSetActive: (i: number) => void; onRetry: () => void;
}) {
return (
<div role="listbox" className="z-40 overflow-auto rounded-md border border-border bg-surface py-1 shadow-lg" style={position.style}>
{loading ? (
<p className="flex items-center gap-2 px-3 py-4 text-sm text-text-secondary"><Loader2 size={14} className="animate-spin" aria-hidden /> Searching…</p>
) : fetchError ? (
<div className="px-3 py-4 text-center">
<p className="text-sm text-error">{fetchError}</p>
<button onClick={onRetry} className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-accent underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded">
<RotateCw size={12} aria-hidden /> Try again
</button>
</div>
) : belowMin ? (
<p className="px-3 py-4 text-center text-sm text-text-secondary">Type at least {minChars} characters to search.</p>
) : items.length === 0 ? (
<div className="px-3 py-4 text-center">
<p className="text-sm text-text">{query ? `No match for "${query}"` : "Nothing to choose from"}</p>
{query && <p className="mt-0.5 text-xs text-text-secondary">Check the spelling, or clear the search.</p>}
</div>
) : (
items.map((o, i) => {
const v = getOptionValue(o);
const on = selectedValues.has(v);
const meta = getOptionMeta?.(o);
return (
<div key={v} role="option" aria-selected={on} onMouseEnter={() => onSetActive(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => onPick(o)}
className={cn("flex cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm text-text transition-colors hover:bg-bg-hover", i === active && "bg-bg-secondary")}>
<span className={cn("flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border", on ? "border-accent bg-accent" : "border-border bg-transparent")}>
{on && <Check size={11} strokeWidth={3} className="text-on-accent" />}
</span>
<span className="flex-1 truncate">{getOptionLabel(o)}</span>
{meta && <span className="text-xs tabular-nums text-text-tertiary">{meta}</span>}
</div>
);
})
)}
</div>
);
}Dependencies
To use SearchCombobox you also need the following. Copy these into your project alongside it.
Recommended folder structure
project structure
src/components/ui/ ├─ field.tsx └─ search-combobox.tsx src/hooks/ ├─ use-mounted.ts └─ use-popover-position.ts src/lib/ └─ utils.ts