Arrow-key navigation for the combobox

Arrow keys cycle through the listed matches and create rows with
aria-activedescendant tracking, and Enter activates the armed row.
This closes a keyboard-only gap in quick-add: with matches still
listed, Enter deliberately refuses to mint from a half-typed name,
which left the create row reachable only by mouse.
This commit is contained in:
Lars Nolden
2026-09-14 12:03:54 +02:00
parent 671cbb8ef3
commit 46cf578779
2 changed files with 45 additions and 11 deletions
+1
View File
@@ -2076,6 +2076,7 @@ footer span:first-child {
color: inherit; color: inherit;
} }
.combo-option:hover, .combo-option:hover,
.combo-option.active,
.combo-option[aria-selected="true"] { .combo-option[aria-selected="true"] {
background: #f0f7f4; background: #f0f7f4;
} }
+44 -11
View File
@@ -141,6 +141,10 @@ export function Combobox({
const [createError, setCreateError] = useState(""); const [createError, setCreateError] = useState("");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); 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 filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter)); const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter const exact = filter
@@ -152,6 +156,8 @@ export function Combobox({
const selected = options.find((o) => o.value === value); const selected = options.find((o) => o.value === value);
const creations = const creations =
create && filter && !exact && !disabled ? create(query.trim()) : []; create && filter && !exact && !disabled ? create(query.trim()) : [];
const total = shown.length + creations.length;
const cursor = active < total ? active : -1;
const pick = (v: string) => { const pick = (v: string) => {
onChange(v); onChange(v);
setOpen(false); setOpen(false);
@@ -176,29 +182,48 @@ export function Combobox({
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
aria-autocomplete="list" aria-autocomplete="list"
aria-controls={open ? listID : undefined}
aria-activedescendant={
open && cursor >= 0 ? `${listID}-${cursor}` : undefined
}
disabled={disabled} disabled={disabled}
value={open ? query : (selected?.label ?? value)} value={open ? query : (selected?.label ?? value)}
placeholder={placeholder} placeholder={placeholder}
onFocus={() => { onFocus={() => {
setQuery(""); setQuery("");
setActive(-1);
setOpen(true); setOpen(true);
}} }}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setCreateError(""); setCreateError("");
setActive(-1);
setOpen(true); setOpen(true);
}} }}
onBlur={() => setOpen(false)} onBlur={() => setOpen(false)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false); 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) { if (e.key === "Enter" && open) {
e.preventDefault(); e.preventDefault();
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined); if (cursor >= 0 && cursor < shown.length) pick(shown[cursor].value);
if (hit) pick(hit.value); else if (cursor >= shown.length)
// Enter creates only when nothing matches at all: with matches void runCreate(creations[cursor - shown.length]);
// still listed, minting from a half-typed name is too easy. else {
else if (!shown.length && creations.length === 1) const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
void runCreate(creations[0]); 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({
<span className="combo-adornment">{adornment}</span> <span className="combo-adornment">{adornment}</span>
)} )}
{open && ( {open && (
<ul className="combo-options" role="listbox"> <ul className="combo-options" role="listbox" id={listID}>
{shown.map((o) => ( {shown.map((o, i) => (
<li key={o.value}> <li key={o.value}>
<button <button
type="button" type="button"
className="combo-option" id={`${listID}-${i}`}
className={
i === cursor ? "combo-option active" : "combo-option"
}
role="option" role="option"
aria-selected={o.value === value} aria-selected={o.value === value}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
@@ -222,11 +250,16 @@ export function Combobox({
</button> </button>
</li> </li>
))} ))}
{creations.map((c) => ( {creations.map((c, i) => (
<li key={c.key}> <li key={c.key}>
<button <button
type="button" type="button"
className="combo-option create" id={`${listID}-${shown.length + i}`}
className={
shown.length + i === cursor
? "combo-option create active"
: "combo-option create"
}
role="option" role="option"
aria-selected={false} aria-selected={false}
disabled={creating} disabled={creating}