new classification ui

This commit is contained in:
Lars Nolden
2026-09-13 13:52:30 +02:00
parent 10314fb1cd
commit 62a7d6daf4
7 changed files with 494 additions and 115 deletions
+103
View File
@@ -98,6 +98,109 @@ export function Field({
);
}
export interface ComboOption {
value: string;
label: string;
icon?: ReactNode;
}
// Combobox is a free-text input that autocompletes against a fixed option
// list: typing filters by label, Enter takes the exact or only match, and
// picking an option reports its value. The caller keeps working with stable
// ids while the user only ever sees names.
export function Combobox({
options,
value,
onChange,
placeholder,
disabled = false,
required = false,
adornment,
emptyText = "No matches.",
}: {
options: ComboOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
adornment?: ReactNode;
emptyText?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter
? matches.find((o) => o.label.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = options.find((o) => o.value === value);
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
return (
<div className="combo">
<input
required={required}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={disabled}
value={open ? query : (selected?.label ?? value)}
placeholder={placeholder}
onFocus={() => {
setQuery("");
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Enter" && open) {
e.preventDefault();
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
}
}}
/>
{adornment && !open && (
<span className="combo-adornment">{adornment}</span>
)}
{open && (
<ul className="combo-options" role="listbox">
{shown.map((o) => (
<li key={o.value}>
<button
type="button"
className="combo-option"
role="option"
aria-selected={o.value === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o.value)}
>
{o.icon}
<span>{o.label}</span>
</button>
</li>
))}
{shown.length === 0 && <li className="combo-empty">{emptyText}</li>}
{matches.length > shown.length && (
<li className="combo-empty">
{matches.length - shown.length} more keep typing to narrow down.
</li>
)}
</ul>
)}
</div>
);
}
// Dates are handled as calendar days, never as instants: every helper works on
// the ISO string's integer parts so a browser time zone can never shift a
// booking date. "Sept" follows the four-letter form used in the journal UI.