fix(webui): correct combobox navigation semantics

This commit is contained in:
Xubin Ren 2026-08-04 15:52:07 +08:00
parent 3b4a056947
commit 28ec8a1b47
3 changed files with 86 additions and 37 deletions

View File

@ -8955,14 +8955,14 @@ function TimezonePicker({
/> />
</div> </div>
</div> </div>
<div {filteredOptions.length ? (
{...navigation.listProps} <div
aria-label={tx("settings.timezone.select", "Select timezone")} {...navigation.listProps}
className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent" aria-label={tx("settings.timezone.select", "Select timezone")}
data-testid="timezone-picker-list" className="mt-1 max-h-[18rem] overflow-y-auto pr-0.5 scrollbar-thin scrollbar-track-transparent"
> data-testid="timezone-picker-list"
{filteredOptions.length ? ( >
filteredOptions.map((option) => { {filteredOptions.map((option) => {
const selected = option.name === value; const selected = option.name === value;
return ( return (
<ComboboxOption <ComboboxOption
@ -8982,13 +8982,17 @@ function TimezonePicker({
</span> </span>
</ComboboxOption> </ComboboxOption>
); );
}) })}
) : ( </div>
<div className="px-3 py-5 text-center text-[12px] text-muted-foreground"> ) : (
{tx("settings.timezone.empty", "No matching timezones.")} <div
</div> role="status"
)} className="px-3 py-5 text-center text-[12px] text-muted-foreground"
</div> data-testid="timezone-picker-list"
>
{tx("settings.timezone.empty", "No matching timezones.")}
</div>
)}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View File

@ -25,7 +25,10 @@ export function useComboboxNavigation({
const [activeValue, setActiveValue] = React.useState<string | null>(null); const [activeValue, setActiveValue] = React.useState<string | null>(null);
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) {
setActiveValue(null);
return;
}
setActiveValue((current) => { setActiveValue((current) => {
if (current && values.includes(current)) return current; if (current && values.includes(current)) return current;
if (selectedValue && values.includes(selectedValue)) return selectedValue; if (selectedValue && values.includes(selectedValue)) return selectedValue;
@ -54,23 +57,15 @@ export function useComboboxNavigation({
if (event.nativeEvent.isComposing) return; if (event.nativeEvent.isComposing) return;
switch (event.key) { switch (event.key) {
case "ArrowDown": case "ArrowDown":
event.preventDefault();
move(1);
break;
case "ArrowUp":
event.preventDefault();
move(-1);
break;
case "Home":
if (values.length) { if (values.length) {
event.preventDefault(); event.preventDefault();
setActiveValue(values[0]); move(1);
} }
break; break;
case "End": case "ArrowUp":
if (values.length) { if (values.length) {
event.preventDefault(); event.preventDefault();
setActiveValue(values[values.length - 1]); move(-1);
} }
break; break;
case "Enter": case "Enter":
@ -86,12 +81,13 @@ export function useComboboxNavigation({
} }
}; };
const expanded = open && values.length > 0;
const inputProps = { const inputProps = {
role: "combobox" as const, role: "combobox" as const,
"aria-autocomplete": "list" as const, "aria-autocomplete": "list" as const,
"aria-controls": listboxId, "aria-controls": expanded ? listboxId : undefined,
"aria-expanded": open, "aria-expanded": expanded,
"aria-activedescendant": activeOptionId, "aria-activedescendant": expanded ? activeOptionId : undefined,
onKeyDown: onInputKeyDown, onKeyDown: onInputKeyDown,
}; };
@ -105,7 +101,7 @@ export function useComboboxNavigation({
return { return {
id: `${listboxId}-option-${index}`, id: `${listboxId}-option-${index}`,
role: "option" as const, role: "option" as const,
"aria-selected": value === selectedValue, "aria-selected": value === activeValue,
"data-highlighted": value === activeValue ? "" : undefined, "data-highlighted": value === activeValue ? "" : undefined,
tabIndex: -1, tabIndex: -1,
onPointerMove: () => setActiveValue(value), onPointerMove: () => setActiveValue(value),
@ -126,7 +122,7 @@ const ComboboxOption = React.forwardRef<
className={cn( className={cn(
floatingItemClassName, floatingItemClassName,
floatingItemFocusClassName, floatingItemFocusClassName,
"w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground aria-selected:bg-muted/80", "w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground",
className, className,
)} )}
{...props} {...props}

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 { useState } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
@ -9,12 +9,12 @@ import {
const OPTIONS = ["Alpha", "Beta", "Gamma"]; const OPTIONS = ["Alpha", "Beta", "Gamma"];
function ComboboxHarness() { function ComboboxHarness({ options = OPTIONS }: { options?: readonly string[] }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [selected, setSelected] = useState("Beta"); const [selected, setSelected] = useState("Beta");
const navigation = useComboboxNavigation({ const navigation = useComboboxNavigation({
open, open,
values: OPTIONS, values: options,
selectedValue: selected, selectedValue: selected,
onSelect: setSelected, onSelect: setSelected,
onClose: () => setOpen(false), onClose: () => setOpen(false),
@ -23,9 +23,12 @@ function ComboboxHarness() {
return ( return (
<> <>
<input aria-label="Options" {...navigation.inputProps} /> <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"> <div {...navigation.listProps} aria-label="Available options">
{OPTIONS.map((option) => ( {options.map((option) => (
<ComboboxOption key={option} {...navigation.getOptionProps(option)}> <ComboboxOption key={option} {...navigation.getOptionProps(option)}>
{option} {option}
</ComboboxOption> </ComboboxOption>
@ -49,6 +52,14 @@ describe("combobox navigation", () => {
); );
fireEvent.keyDown(input, { key: "ArrowDown" }); 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( expect(input).toHaveAttribute(
"aria-activedescendant", "aria-activedescendant",
screen.getByRole("option", { name: "Gamma" }).id, screen.getByRole("option", { name: "Gamma" }).id,
@ -58,6 +69,44 @@ describe("combobox navigation", () => {
expect(screen.getByRole("status", { name: "Selection" })).toHaveTextContent("Gamma"); 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", () => { it("closes the listbox on Escape", () => {
render(<ComboboxHarness />); render(<ComboboxHarness />);