Mats Bosson 5bc9ac7b61 Listbox, Select, and list navigation
createListNavigation is the core primitive for all collection components
(Listbox, Select, Combobox, Menu). It provides value-based keyboard
navigation, selection/activation modes, typeahead, and aria-activedescendant
virtual focus.
Listbox: standalone selectable list with single/multiple selection.
Select: floating dropdown with trigger, keyboard navigation, form integration.
Also fixes exactOptionalPropertyTypes compatibility in createDisclosureState
and createListNavigation interfaces.
2026-03-29 19:12:05 +07:00

146 lines
4.7 KiB
TypeScript

import { fireEvent, render, screen } from "@solidjs/testing-library";
import { describe, expect, it, vi } from "vitest";
import { Select } from "../../../src/components/select/index";
describe("Select — roles", () => {
it("trigger has role=combobox", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("content has role=listbox when open", () => {
render(() => (
<Select items={["a", "b"]} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("aria-selected on selected item", () => {
render(() => (
<Select items={["a", "b"]} defaultValue="b" defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
});
describe("Select — open and close", () => {
it("click trigger opens content", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("combobox"));
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("Escape closes without selecting", () => {
render(() => (
<Select items={["a", "b"]} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
fireEvent.keyDown(screen.getByRole("listbox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
});
describe("Select — keyboard navigation", () => {
it("ArrowDown on trigger opens and highlights first", () => {
render(() => (
<Select items={["a", "b"]}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
fireEvent.keyDown(screen.getByRole("combobox"), { key: "ArrowDown" });
expect(screen.getByRole("listbox")).toBeTruthy();
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("data-highlighted")).toBe("");
});
it("Enter selects highlighted item and closes", () => {
const onChange = vi.fn();
render(() => (
<Select items={["a", "b"]} onValueChange={onChange} defaultOpen>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
const listbox = screen.getByRole("listbox");
fireEvent.keyDown(listbox, { key: "ArrowDown" });
fireEvent.keyDown(listbox, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("a");
expect(screen.queryByRole("listbox")).toBeNull();
});
});
describe("Select — controlled and form", () => {
it("controlled mode", () => {
render(() => (
<Select items={["a", "b"]} value="a" onValueChange={() => {}}>
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
<Select.Item value="b">B</Select.Item>
</Select.Content>
</Select>
));
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("hidden input rendered when name provided", () => {
render(() => (
<Select items={["a", "b"]} name="fruit" defaultValue="a">
<Select.Trigger>Pick one</Select.Trigger>
<Select.Content>
<Select.Item value="a">A</Select.Item>
</Select.Content>
<Select.HiddenSelect />
</Select>
));
const hidden = document.querySelector(
"input[name='fruit']",
) as HTMLInputElement;
expect(hidden).toBeTruthy();
expect(hidden.value).toBe("a");
});
});