// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // // Combobox — Headless UI-backed typeahead select primitive. Phase 1 // closure for UX-M4 (~53 native HTML : // - typeahead filter narrows options as the operator types // - keyboard nav (Up/Down/Enter/Esc) handled by Headless UI // - aria-expanded / aria-activedescendant / aria-labelledby wired // for free // - styled to match the certctl .input + .card token palette // // Generic on the option value type T (string IDs are typical; arbitrary // objects work too — supply a `getKey` + `getLabel`). import { useState, useMemo } from 'react'; import { Combobox as HeadlessCombobox } from '@headlessui/react'; export interface ComboboxProps { /** The currently-selected option, or null if none. */ value: T | null; /** Fires when the operator picks an option. */ onChange: (next: T | null) => void; /** Full options list — Combobox filters internally on typed query. */ options: T[]; /** Stable string key per option (used for React `key` + filter equality). */ getKey: (option: T) => string; /** Human-readable label rendered in the input + dropdown row. */ getLabel: (option: T) => string; /** Optional placeholder when no value is selected. */ placeholder?: string; /** Optional `id` on the input element (label wiring). */ inputId?: string; /** Disabled state. */ disabled?: boolean; /** Extra className on the outer wrapper. */ className?: string; } export default function Combobox({ value, onChange, options, getKey, getLabel, placeholder, inputId, disabled, className = '', }: ComboboxProps) { const [query, setQuery] = useState(''); // Filter is local + case-insensitive substring against the label. // For >1000-option lists this should move to server-side; not Phase // 1's problem. const filtered = useMemo(() => { if (!query) return options; const needle = query.toLowerCase(); return options.filter((o) => getLabel(o).toLowerCase().includes(needle)); }, [options, query, getLabel]); return (
(o ? getLabel(o) : '')} onChange={(e) => setQuery(e.target.value)} /> {filtered.length === 0 && query !== '' && (
No matches.
)} {filtered.map((option) => ( `cursor-pointer px-3 py-2 text-sm ${ active ? 'bg-brand-50 text-brand-700' : 'text-ink' } ${selected ? 'font-semibold' : ''}` } > {getLabel(option)} ))}
); }