new classification ui
This commit is contained in:
+103
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user