mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
fix(webui): correct combobox navigation semantics
This commit is contained in:
parent
3b4a056947
commit
28ec8a1b47
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 />);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user