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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-7
@@ -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,42 +182,64 @@ 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();
|
||||||
|
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);
|
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
|
||||||
if (hit) pick(hit.value);
|
if (hit) pick(hit.value);
|
||||||
// Enter creates only when nothing matches at all: with matches
|
// Without an armed row, Enter creates only when nothing
|
||||||
// still listed, minting from a half-typed name is too easy.
|
// matches at all: minting from a half-typed name is too easy.
|
||||||
else if (!shown.length && creations.length === 1)
|
else if (!shown.length && creations.length === 1)
|
||||||
void runCreate(creations[0]);
|
void runCreate(creations[0]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{adornment && !open && (
|
{adornment && !open && (
|
||||||
<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}
|
||||||
|
|||||||
Reference in New Issue
Block a user