fix(webui): correct combobox navigation semantics

This commit is contained in:
Xubin Ren
2026-08-04 18:05:34 +08:00
parent 3b4a056947
commit 28ec8a1b47
3 changed files with 86 additions and 37 deletions
+54 -5
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
@@ -9,12 +9,12 @@ import {
const OPTIONS = ["Alpha", "Beta", "Gamma"];
function ComboboxHarness() {
function ComboboxHarness({ options = OPTIONS }: { options?: readonly string[] }) {
const [open, setOpen] = useState(true);
const [selected, setSelected] = useState("Beta");
const navigation = useComboboxNavigation({
open,
values: OPTIONS,
values: options,
selectedValue: selected,
onSelect: setSelected,
onClose: () => setOpen(false),
@@ -23,9 +23,12 @@ function ComboboxHarness() {
return (
<>
<input aria-label="Options" {...navigation.inputProps} />
{open ? (
<button type="button" onClick={() => setOpen((current) => !current)}>
{open ? "Close options" : "Open options"}
</button>
{open && options.length ? (
<div {...navigation.listProps} aria-label="Available options">
{OPTIONS.map((option) => (
{options.map((option) => (
<ComboboxOption key={option} {...navigation.getOptionProps(option)}>
{option}
</ComboboxOption>
@@ -49,6 +52,14 @@ describe("combobox navigation", () => {
);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: "Beta" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByRole("option", { name: "Gamma" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(input).toHaveAttribute(
"aria-activedescendant",
screen.getByRole("option", { name: "Gamma" }).id,
@@ -58,6 +69,44 @@ describe("combobox navigation", () => {
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma");
});
it("preserves native text editing keys", () => {
render(<ComboboxHarness />);
const input = screen.getByRole("combobox", { name: "Options" });
for (const key of ["Home", "End"]) {
const event = createEvent.keyDown(input, { key });
fireEvent(input, event);
expect(event.defaultPrevented).toBe(false);
}
});
it("restores the selected option after closing without a selection", () => {
render(<ComboboxHarness />);
const input = screen.getByRole("combobox", { name: "Options" });
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Open options" }));
const selectedOption = screen.getByRole("option", { name: "Beta" });
expect(input).toHaveAttribute("aria-activedescendant", selectedOption.id);
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Beta");
});
it("collapses the combobox when no options are available", () => {
render(<ComboboxHarness options={[]} />);
const input = screen.getByRole("combobox", { name: "Options" });
expect(input).toHaveAttribute("aria-expanded", "false");
expect(input).not.toHaveAttribute("aria-controls");
expect(input).not.toHaveAttribute("aria-activedescendant");
const event = createEvent.keyDown(input, { key: "ArrowDown" });
fireEvent(input, event);
expect(event.defaultPrevented).toBe(false);
});
it("closes the listbox on Escape", () => {
render(<ComboboxHarness />);