chore: release v0.26.4

This commit is contained in:
2026-08-22 08:35:49 +02:00
parent d48420485f
commit abfed4afa2
16 changed files with 864 additions and 23 deletions
+41 -4
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { Input } from './input';
import { cn } from '@/lib/utils';
@@ -7,6 +8,7 @@ import { cn } from '@/lib/utils';
// 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;
@@ -18,9 +20,21 @@ export function Combobox({
// 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);
useEffect(() => {
@@ -31,20 +45,22 @@ export function Combobox({
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const filtered = open
? options.filter((o) => o.toLowerCase().includes(query.toLowerCase())).slice(0, 60)
: [];
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
@@ -68,6 +84,7 @@ export function Combobox({
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
@@ -76,14 +93,34 @@ export function Combobox({
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); }
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>
)}
{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) => (