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:
@@ -2076,6 +2076,7 @@ footer span:first-child {
|
||||
color: inherit;
|
||||
}
|
||||
.combo-option:hover,
|
||||
.combo-option.active,
|
||||
.combo-option[aria-selected="true"] {
|
||||
background: #f0f7f4;
|
||||
}
|
||||
|
||||
+40
-7
@@ -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,42 +182,64 @@ 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();
|
||||
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);
|
||||
// Enter creates only when nothing matches at all: with matches
|
||||
// still listed, minting from a half-typed name is too easy.
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{adornment && !open && (
|
||||
<span className="combo-adornment">{adornment}</span>
|
||||
)}
|
||||
{open && (
|
||||
<ul className="combo-options" role="listbox">
|
||||
{shown.map((o) => (
|
||||
<ul className="combo-options" role="listbox" id={listID}>
|
||||
{shown.map((o, i) => (
|
||||
<li key={o.value}>
|
||||
<button
|
||||
type="button"
|
||||
className="combo-option"
|
||||
id={`${listID}-${i}`}
|
||||
className={
|
||||
i === cursor ? "combo-option active" : "combo-option"
|
||||
}
|
||||
role="option"
|
||||
aria-selected={o.value === value}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
@@ -222,11 +250,16 @@ export function Combobox({
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{creations.map((c) => (
|
||||
{creations.map((c, i) => (
|
||||
<li key={c.key}>
|
||||
<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"
|
||||
aria-selected={false}
|
||||
disabled={creating}
|
||||
|
||||
Reference in New Issue
Block a user