import { useEffect, useRef, useState } from 'react'; import { Input } from './input'; import { cn } from '@/lib/utils'; // Searchable combobox: type to filter, click/Enter to pick. On blur it commits // only an exact (case-insensitive) match — otherwise it reverts, so the field // can't hold a typo'd value that isn't in the list. export function Combobox({ value, onChange, options, placeholder, className, allowFreeText = false, commitOnType = false, }: { value: string; onChange: (v: string) => void; options: string[]; placeholder?: string; className?: string; allowFreeText?: boolean; // Commit each keystroke to the parent immediately (not just on blur). Use for // fields read live by other actions — e.g. RST, so a CW macro sent without // leaving the field uses the value just typed. commitOnType?: boolean; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const ref = useRef(null); useEffect(() => { function onDoc(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, []); const filtered = open ? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60) : []; function commit(v: string) { onChange(v); setQuery(v); setOpen(false); } function onBlur() { // Defer so a click on an option registers first. setTimeout(() => { setOpen(false); const trimmed = query.trim(); const exact = options.find((o) => o.toLowerCase() === trimmed.toLowerCase()); // Only fire onChange when the value actually changed — committing an // unchanged value on a plain tab-through would wrongly flag the field as // "user-edited" (e.g. RST, which then blocks the CW/SSB 599↔59 default). if (exact) { if (exact !== value) onChange(exact); setQuery(exact); } else if (allowFreeText) { if (trimmed !== value) onChange(trimmed); } else { setQuery(value); } // revert typo }, 120); } return (
{ setQuery(value); e.currentTarget.select(); }} onChange={(e) => { const v = e.target.value; setQuery(v); setOpen(true); // Commit-on-type pushes the value live to the parent (so a CW macro sent // without leaving the field uses what was just typed). With free text that's // any input; a restricted field (allowFreeText=false) commits ONLY a value // that's actually in the list, so a half-typed or invalid report never // becomes the committed value — blur then reverts the leftover text. if (commitOnType && (allowFreeText || options.some((o) => o.toLowerCase() === v.trim().toLowerCase()))) onChange(v); }} onBlur={onBlur} onKeyDown={(e) => { if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); } else if (e.key === 'Enter' && open && filtered.length > 0) { e.preventDefault(); commit(filtered[0]); } else if (e.key === 'Escape') { setQuery(value); setOpen(false); } // Tab: just let it move on; onBlur commits/closes. Options are // tabIndex=-1 so a single Tab leaves the field. }} /> {open && filtered.length > 0 && (
{filtered.map((o) => ( ))}
)}
); }