TWO FAULTS, ONE SYMPTOM — 'it refreshes ten times a second and the buttons cannot be pressed'. Preferences is a child of the main view, so every cluster spot, CAT push and decode re-rendered the entire panel. On a busy evening that is several times a second, and the panel is large enough that the rebuild outlasts the gap between them: buttons missed their clicks because the element under the pointer was replaced between the press and the release. It is memoised now, and the callbacks App hands it hold their identity — without that the memo compares unequal every time and buys nothing. The dropdown menu was an absolutely-positioned child, so it was clipped by whichever scrolling or overflow-hidden box it sat in: the satellite list showed one entry of eight. It is portalled to the body now, positioned from the field's rectangle, re-measured while open, and opens upward when the field is near the bottom of the screen — which is exactly where these fields tend to be.
178 lines
7.4 KiB
TypeScript
178 lines
7.4 KiB
TypeScript
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<HTMLDivElement>(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 (
|
|
<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);
|
|
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 && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
aria-label="Show list"
|
|
className="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-muted-foreground hover:text-foreground"
|
|
// mousedown, not click: the input's blur fires first otherwise and
|
|
// closes the list the same instant this opens it.
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
if (open) { setOpen(false); setBrowse(false); return; }
|
|
setQuery(value);
|
|
setBrowse(true);
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
<ChevronDown className="size-3.5" />
|
|
</button>
|
|
)}
|
|
{/* 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(
|
|
<div
|
|
style={{ position: 'fixed', top: menuPos.top, left: menuPos.left, width: menuPos.width }}
|
|
className="z-[100] max-h-60 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>,
|
|
document.body,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|