diff --git a/web/src/styles.css b/web/src/styles.css index 7acc859..69f092d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2076,6 +2076,7 @@ footer span:first-child { color: inherit; } .combo-option:hover, +.combo-option.active, .combo-option[aria-selected="true"] { background: #f0f7f4; } diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 690920e..fc86a82 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -141,6 +141,10 @@ export function Combobox({ const [createError, setCreateError] = useState(""); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + // Index into the interactive rows (matches first, then create rows); -1 + // means no row is armed and Enter falls back to exact/single-match logic. + const [active, setActive] = useState(-1); + const listID = useId(); const filter = query.trim().toLowerCase(); const matches = options.filter((o) => o.label.toLowerCase().includes(filter)); const exact = filter @@ -152,6 +156,8 @@ export function Combobox({ const selected = options.find((o) => o.value === value); const creations = create && filter && !exact && !disabled ? create(query.trim()) : []; + const total = shown.length + creations.length; + const cursor = active < total ? active : -1; const pick = (v: string) => { onChange(v); setOpen(false); @@ -176,29 +182,48 @@ export function Combobox({ role="combobox" aria-expanded={open} aria-autocomplete="list" + aria-controls={open ? listID : undefined} + aria-activedescendant={ + open && cursor >= 0 ? `${listID}-${cursor}` : undefined + } disabled={disabled} value={open ? query : (selected?.label ?? value)} placeholder={placeholder} onFocus={() => { setQuery(""); + setActive(-1); setOpen(true); }} onChange={(e) => { setQuery(e.target.value); setCreateError(""); + setActive(-1); setOpen(true); }} onBlur={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); + if ((e.key === "ArrowDown" || e.key === "ArrowUp") && open && total) { + e.preventDefault(); + setActive( + e.key === "ArrowDown" + ? (cursor + 1) % total + : (cursor <= 0 ? total : cursor) - 1, + ); + } if (e.key === "Enter" && open) { e.preventDefault(); - const hit = exact ?? (shown.length === 1 ? shown[0] : undefined); - if (hit) pick(hit.value); - // Enter creates only when nothing matches at all: with matches - // still listed, minting from a half-typed name is too easy. - else if (!shown.length && creations.length === 1) - void runCreate(creations[0]); + if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value); + else if (cursor >= shown.length) + void runCreate(creations[cursor - shown.length]); + else { + const hit = exact ?? (shown.length === 1 ? shown[0] : undefined); + if (hit) pick(hit.value); + // Without an armed row, Enter creates only when nothing + // matches at all: minting from a half-typed name is too easy. + else if (!shown.length && creations.length === 1) + void runCreate(creations[0]); + } } }} /> @@ -206,12 +231,15 @@ export function Combobox({ {adornment} )} {open && ( -