import * as React from 'react'; import { clsx } from 'clsx'; import { Check, ChevronDown, X } from 'lucide-react'; export interface ComboboxOption { value: T; label: string; /** Optional secondary line. */ description?: string; /** Disables selection but keeps the row visible. */ disabled?: boolean; } export interface ComboboxProps { /** Available options. */ options: ComboboxOption[]; /** Controlled value. `null` represents no selection. */ value: T | null; /** Called when the user picks (or clears) a value. */ onChange: (next: T | null) => void; /** Placeholder shown when nothing is selected. */ placeholder?: string; /** Custom filter — defaults to case-insensitive label/value substring. */ filter?: (option: ComboboxOption, query: string) => boolean; /** Allow clearing the selection with an `x`. Default true. */ clearable?: boolean; /** Disable the entire control. */ disabled?: boolean; /** Accessible label. */ ariaLabel?: string; /** Max popover height in px. Default 240. */ maxListHeight?: number; className?: string; } const DEFAULT_FILTER = (opt: ComboboxOption, q: string) => { if (!q) return true; const needle = q.toLowerCase(); return ( opt.label.toLowerCase().includes(needle) || opt.value.toLowerCase().includes(needle) ); }; /** * `` — searchable select. Headless-ish: standard semantics * (`role="combobox"` + `aria-expanded` + roving `aria-activedescendant`) * with token-driven styling. * * Keyboard: * - **↓ / ↑** move highlight * - **Enter** pick the highlighted option * - **Esc** close * - **Tab** close + commit text query as no-op * * Closes the v3.0 audit gap where async-loading selects in * notes/fastgap/voice were each bespoke. */ export function Combobox({ options, value, onChange, placeholder = 'Select…', filter = DEFAULT_FILTER, clearable = true, disabled = false, ariaLabel, maxListHeight = 240, className, }: ComboboxProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); const [highlight, setHighlight] = React.useState(0); const rootRef = React.useRef(null); const listId = React.useId(); const filtered = React.useMemo( () => options.filter((opt) => filter(opt, query)), [options, query, filter], ); // Reset highlight when the visible list changes shape. React.useEffect(() => { setHighlight(0); }, [query, open]); // Keep the highlighted option visible during arrow-key navigation so it // never scrolls out of the popover viewport (ROADMAP V4 IMP-1). React.useEffect(() => { if (!open) return; const el = document.getElementById(`${listId}-opt-${highlight}`); el?.scrollIntoView({ block: 'nearest' }); }, [highlight, open, listId]); // Click-outside close. React.useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); const selectedOption = options.find((o) => o.value === value) ?? null; const displayText = open ? query : selectedOption?.label ?? ''; const commit = (opt: ComboboxOption) => { if (opt.disabled) return; onChange(opt.value); setQuery(''); setOpen(false); }; const onKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) setOpen(true); setHighlight((h) => Math.min(filtered.length - 1, h + 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight((h) => Math.max(0, h - 1)); return; } if (e.key === 'Enter') { if (!open) { setOpen(true); return; } e.preventDefault(); const opt = filtered[highlight]; if (opt) commit(opt); return; } if (e.key === 'Escape') { setOpen(false); setQuery(''); return; } }; return (
{ setQuery(e.target.value); if (!open) setOpen(true); }} onFocus={() => !disabled && setOpen(true)} onKeyDown={onKeyDown} placeholder={placeholder} disabled={disabled} className="h-9 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-[var(--bl-text-tertiary,#999)]" /> {clearable && value !== null && !disabled && ( )}
{open && (
    {filtered.length === 0 ? (
  • No matches
  • ) : ( filtered.map((opt, i) => { const active = i === highlight; const selected = opt.value === value; return (
  • setHighlight(i)} onMouseDown={(e) => { e.preventDefault(); // keep input focused commit(opt); }} className={clsx( 'flex cursor-pointer items-start justify-between gap-2 px-3 py-2 text-sm', active && 'bg-[var(--bl-surface-muted,rgba(0,0,0,0.05))]', opt.disabled && 'cursor-not-allowed opacity-50', )} >
    {opt.label} {opt.description && ( {opt.description} )}
    {selected && (
  • ); }) )}
)}
); } Combobox.displayName = 'Combobox';