import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ChevronDown } from 'lucide-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, showToggle = 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; // Draw a chevron that opens the full list on click. // // Without it this control is indistinguishable from a plain text box: it opens // only on a keystroke or ArrowDown, both of which have to be known about // first. That is fine for a field whose list is a convenience (RST), and wrong // for one whose list is the ANSWER — the COM ports actually present on this // machine, which nobody can be expected to recall. showToggle?: boolean; }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); // Opened by the chevron rather than by typing: the whole list is on offer, not // the part matching what is already in the field — a box holding COM7 would // otherwise "open" onto COM7 alone. const [browse, setBrowse] = useState(false); const ref = useRef(null); // Where the portalled menu goes. Measured from the field itself, and // re-measured while it is open, so scrolling the panel underneath does not // leave the list floating over the wrong row. const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 }); 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); }, []); useLayoutEffect(() => { if (!open) return; const place = () => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); // Opens UPWARD when there is not enough room below — which is exactly // where these fields tend to sit, at the bottom of a panel. const height = 240; const below = window.innerHeight - r.bottom; const top = below < height + 8 ? Math.max(4, r.top - height - 4) : r.bottom + 4; setMenuPos({ top, left: r.left, width: r.width }); }; place(); window.addEventListener('scroll', place, true); window.addEventListener('resize', place); return () => { window.removeEventListener('scroll', place, true); window.removeEventListener('resize', place); }; }, [open]); const filtered = !open ? [] : browse ? options.slice(0, 60) : options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60); function commit(v: string) { onChange(v); setQuery(v); setOpen(false); setBrowse(false); } function onBlur() { // Defer so a click on an option registers first. setTimeout(() => { setOpen(false); setBrowse(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); setBrowse(false); // typing filters again // 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} className={showToggle ? 'pr-7' : undefined} onKeyDown={(e) => { if ((e.key === 'ArrowDown' || e.key === 'Alt') && !open) { setOpen(true); setBrowse(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. }} /> {showToggle && ( )} {/* THE MENU IS PORTALLED to the body, and positioned from the field's own rectangle. As an absolutely-positioned child it was clipped by whichever scrolling or overflow-hidden box it happened to sit in: in the details panel it was cut off after the first row, and a list showing one entry of eight is worse than no list at all. */} {open && filtered.length > 0 && createPortal(
{filtered.map((o) => ( ))}
, document.body, )}
); }