Files
OpsLog/frontend/src/components/ui/combobox.tsx
T
rouggy b421da165a feat: wheel over RST; PSK Reporter keeps the band's window
The mouse wheel steps the RST fields — one S-unit on an RST or RS, one
decibel on a digital report, up for a better one — in the entry strip and
in the QSO editor. The dropdown beside them lists the reports worth
having to hand, not all 41 decibels, so the wheel works on the value
rather than walking the list. The listener is native and non-passive:
React attaches onWheel passively, where preventDefault does nothing and
the panel scrolls away under the field being adjusted.

PSK Reporter, whole-band scope: clicking a decode reset the report count
to zero. The window there is the BAND's — every FTx report on it,
filtered by target only when the analysis is drawn — and it was emptied
on every target change, throwing away an hour of evidence at the moment
the operator asked the question it answers. The narrow scope still clears
it: there the window is one station's, and keeping it would answer the
new question with the old station's evidence.
2026-09-06 13:58:25 +02:00

197 lines
8.2 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, onWheelStep,
}: {
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;
// Wheel over the field steps the value. The control cannot know what a step
// means — a decibel here, an S-unit there — so it reports the direction and
// the owner decides.
onWheelStep?: (dir: 1 | -1) => void;
// 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 });
// A NATIVE, non-passive listener: React attaches onWheel passively, where
// preventDefault does nothing at all and the panel scrolls away under the
// field being adjusted.
useEffect(() => {
const el = ref.current;
if (!el || !onWheelStep) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
onWheelStep(e.deltaY < 0 ? 1 : -1); // up is a better report
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheelStep]);
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>
);
}