The RST comboboxes allowed free text, so a report typed by hand that isn't in the mode's list (e.g. "600") was committed. Drop allowFreeText on the four RST fields (QSO entry + edit modal) and make commit-on-type push a value only when it's actually in the list; a leftover invalid entry reverts to the last valid value on blur. Programmatically filled values (FT8 SNR over UDP, presets) are unaffected — only manual typing is constrained.
105 lines
4.1 KiB
TypeScript
105 lines
4.1 KiB
TypeScript
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<HTMLDivElement>(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 (
|
|
<div ref={ref} className={cn('relative', className)}>
|
|
<Input
|
|
value={open ? query : value}
|
|
placeholder={placeholder}
|
|
// Focus selects the text so a keystroke replaces it — but does NOT
|
|
// open the list (so tabbing in doesn't pop the dropdown).
|
|
onFocus={(e) => { 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 && (
|
|
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card shadow-lg text-xs">
|
|
{filtered.map((o) => (
|
|
<button
|
|
key={o}
|
|
type="button"
|
|
tabIndex={-1}
|
|
className="block w-full text-left px-2 py-1 hover:bg-accent/40"
|
|
onMouseDown={(e) => { e.preventDefault(); commit(o); }}
|
|
>
|
|
{o}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|