/*
* Portions of this file are based on code from react-spectrum.
* Apache License Version 2.0, Copyright 2020 Adobe.
*
* Credits to the React Spectrum team:
* https://github.com/adobe/react-spectrum/blob/5c1920e50d4b2b80c826ca91aff55c97350bf9f9/packages/@react-spectrum/picker/test/Picker.test.js
*/
import { createPointerEvent, installPointerEvent } from "@kobalte/tests";
import { fireEvent, render, within } from "@solidjs/testing-library";
import { vi } from "vitest";
import { Show, createSignal } from "solid-js";
import * as Combobox from ".";
interface DataSourceItem {
key: string;
label: string;
textValue: string;
disabled: boolean;
}
const DATA_SOURCE: DataSourceItem[] = [
{ key: "1", label: "One", textValue: "One", disabled: false },
{ key: "2", label: "Two", textValue: "Two", disabled: false },
{ key: "3", label: "Three", textValue: "Three", disabled: false },
];
// Skipped: jsdom stub for pointerEvent issue with vitest
describe.skip("Combobox", () => {
installPointerEvent();
// structuredClone polyfill, kind of ^^'
global.structuredClone = (val: any) => JSON.parse(JSON.stringify(val));
const onValueChange = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
});
it("renders correctly", () => {
const { getByRole, getByText } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const root = getByRole("group");
expect(root).toBeInTheDocument();
expect(root).toBeInstanceOf(HTMLDivElement);
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-autocomplete", "list");
expect(input).not.toHaveAttribute("aria-controls");
expect(input).not.toHaveAttribute("aria-activedescendant");
expect(input).not.toBeDisabled();
const trigger = getByRole("button");
expect(trigger).toHaveAttribute("tabindex", "-1");
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
const label = getByText("Label");
expect(label).toBeVisible();
});
describe("option mapping", () => {
const CUSTOM_DATA_SOURCE_WITH_STRING_KEY = [
{
name: "Section 1",
items: [
{ id: "1", name: "One", valueText: "One", disabled: false },
{ id: "2", name: "Two", valueText: "Two", disabled: true },
{ id: "3", name: "Three", valueText: "Three", disabled: false },
],
},
];
it("supports string based option mapping for object options with string keys", async () => {
const { getByRole } = render(() => (
options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY}
optionValue="id"
optionTextValue="valueText"
optionLabel="name"
optionDisabled="disabled"
optionGroupChildren="items"
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.name}
)}
sectionComponent={(props) => (
{props.section.rawValue.name}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(
CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2],
);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("supports function based option mapping for object options with string keys", async () => {
const { getByRole } = render(() => (
options={CUSTOM_DATA_SOURCE_WITH_STRING_KEY}
optionValue={(option) => option.id}
optionTextValue={(option) => option.valueText}
optionLabel={(option) => option.name}
optionDisabled={(option) => option.disabled}
optionGroupChildren="items"
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.name}
)}
sectionComponent={(props) => (
{props.section.rawValue.name}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(
CUSTOM_DATA_SOURCE_WITH_STRING_KEY[0].items[2],
);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
const CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY = [
{
name: "Section 1",
items: [
{ id: 1, name: "One", valueText: "One", disabled: false },
{ id: 2, name: "Two", valueText: "Two", disabled: true },
{ id: 3, name: "Three", valueText: "Three", disabled: false },
],
},
];
it("supports string based option mapping for object options with number keys", async () => {
const { getByRole } = render(() => (
options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY}
optionValue="id"
optionTextValue="valueText"
optionLabel="name"
optionDisabled="disabled"
optionGroupChildren="items"
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.name}
)}
sectionComponent={(props) => (
{props.section.rawValue.name}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(
CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2],
);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("supports function based option mapping for object options with number keys", async () => {
const { getByRole } = render(() => (
options={CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY}
optionValue={(option) => option.id}
optionTextValue={(option) => option.valueText}
optionLabel={(option) => option.name}
optionDisabled={(option) => option.disabled}
optionGroupChildren="items"
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.name}
)}
sectionComponent={(props) => (
{props.section.rawValue.name}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(
CUSTOM_DATA_SOURCE_WITH_NUMBER_KEY[0].items[2],
);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("supports string options without mapping", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "One");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "Two");
expect(items[1]).not.toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "Three");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toBe("Three");
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("supports function based option mapping for string options", async () => {
const { getByRole } = render(() => (
option}
optionTextValue={(option) => option}
optionLabel={(option) => option}
optionDisabled={(option) => option === "Two"}
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[0]).toHaveAttribute("data-key", "One");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("Two");
expect(items[1]).toHaveAttribute("data-key", "Two");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("Three");
expect(items[2]).toHaveAttribute("data-key", "Three");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toBe("Three");
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("supports number options without mapping", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("1");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("2");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).not.toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("3");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toBe(3);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("3");
expect(document.activeElement).toBe(input);
});
it("supports function based option mapping for number options", async () => {
const { getByRole } = render(() => (
option}
optionTextValue={(option) => option}
optionLabel={(option) => option}
optionDisabled={(option) => option === 2}
placeholder="Placeholder"
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("1");
expect(items[0]).toHaveAttribute("data-key", "1");
expect(items[0]).not.toHaveAttribute("data-disabled");
expect(items[1]).toHaveTextContent("2");
expect(items[1]).toHaveAttribute("data-key", "2");
expect(items[1]).toHaveAttribute("data-disabled");
expect(items[2]).toHaveTextContent("3");
expect(items[2]).toHaveAttribute("data-key", "3");
expect(items[2]).not.toHaveAttribute("data-disabled");
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toBe(3);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("3");
expect(document.activeElement).toBe(input);
});
});
describe("opening", () => {
it("can be opened on mouse down", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
fireEvent.click(trigger);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(document.activeElement).toBe(input);
});
it("can be opened on touch up", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "touch",
}),
);
await Promise.resolve();
expect(queryByRole("listbox")).toBeNull();
fireEvent(
trigger,
createPointerEvent("pointerup", {
pointerId: 1,
pointerType: "touch",
clientX: 0,
clientY: 0,
}),
);
await Promise.resolve();
fireEvent.click(trigger);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(document.activeElement).toBe(input);
});
it("can be opened on ArrowDown key down and virtual focuses the first item", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const input = getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[0].id);
});
it("can be opened on ArrowUp key down and virtual focuses the last item", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const input = getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowUp" });
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[2].id);
});
it("can change item focus with arrow keys", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const input = getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[0].id);
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[2].id);
fireEvent.keyDown(input, { key: "ArrowUp" });
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
});
it("supports controlled open state", () => {
const onOpenChange = vi.fn();
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).not.toBeCalled();
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
});
it("supports default open state", async () => {
const onOpenChange = vi.fn();
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).not.toBeCalled();
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
});
});
describe("closing", () => {
it("can be closed by clicking on the button", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(trigger).toHaveAttribute("aria-controls", listbox.id);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
vi.runAllTimers();
expect(listbox).not.toBeVisible();
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger).not.toHaveAttribute("aria-controls");
expect(onOpenChange).toBeCalledTimes(2);
expect(onOpenChange).toHaveBeenCalledWith(false, "manual");
});
it("can be closed by clicking outside", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
fireEvent.click(trigger);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(trigger).toHaveAttribute("aria-controls", listbox.id);
fireEvent(
document.body,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
document.body,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
expect(listbox).not.toBeVisible();
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger).not.toHaveAttribute("aria-controls");
expect(onOpenChange).toBeCalledTimes(2);
expect(onOpenChange).toHaveBeenCalledWith(false, "manual");
});
it("can be closed by pressing the Escape key", async () => {
const onOpenChange = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
const input = getByRole("combobox");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, "manual");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
fireEvent.keyDown(input, { key: "Escape" });
await Promise.resolve();
expect(listbox).not.toBeVisible();
expect(input).toHaveAttribute("aria-expanded", "false");
expect(input).not.toHaveAttribute("aria-controls");
expect(onOpenChange).toBeCalledTimes(2);
expect(onOpenChange).toHaveBeenCalledWith(false, "manual");
vi.runAllTimers();
expect(document.activeElement).toBe(input);
});
it("does not close in controlled open state", async () => {
const onOpenChange = vi.fn();
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).not.toBeCalled();
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
fireEvent.keyDown(input, { key: "Escape" });
fireEvent.keyUp(input, { key: "Escape" });
await Promise.resolve();
expect(listbox).toBeVisible();
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false, "focus");
});
it("closes in default open state", async () => {
const onOpenChange = vi.fn();
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(onOpenChange).not.toBeCalled();
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-expanded", "true");
expect(input).toHaveAttribute("aria-controls", listbox.id);
fireEvent.keyDown(input, { key: "Escape" });
fireEvent.keyUp(input, { key: "Escape" });
await Promise.resolve();
expect(listbox).not.toBeVisible();
expect(input).toHaveAttribute("aria-expanded", "false");
expect(input).not.toHaveAttribute("aria-controls");
expect(onOpenChange).toBeCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false, "focus");
});
});
describe("labeling", () => {
it("supports labeling with a visible label", async () => {
const { getByRole, getAllByText } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-haspopup", "listbox");
const label = getAllByText("Label")[0];
expect(label).toHaveAttribute("id");
expect(input).toHaveAttribute("aria-labelledby", `${label.id}`);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
expect(listbox).toHaveAttribute(
"aria-labelledby",
`${label.id} ${listbox.id}`,
);
});
it("supports labeling via aria-labelledby", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-labelledby", "foo");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
});
it("supports labeling via aria-label and aria-labelledby", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveAttribute("aria-label", "bar");
expect(input).toHaveAttribute("aria-labelledby", `foo ${input.id}`);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
});
});
describe("help text", () => {
it("supports description", () => {
const { getByRole, getByText } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
Description
));
const input = getByRole("combobox");
const description = getByText("Description");
expect(description).toHaveAttribute("id");
expect(input).toHaveAttribute("aria-describedby", description.id);
});
it("supports error message", () => {
const { getByRole, getByText } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
ErrorMessage
));
const input = getByRole("combobox");
const errorMessage = getByText("ErrorMessage");
expect(errorMessage).toHaveAttribute("id");
expect(input).toHaveAttribute("aria-describedby", errorMessage.id);
});
});
describe("selection", () => {
it("can select items on press", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(getByRole("combobox")).toHaveAttribute(
"placeholder",
"Placeholder",
);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
fireEvent.click(trigger);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(document.activeElement).toBe(input);
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[2]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(input).toHaveValue("Three");
expect(document.activeElement).toBe(input);
});
it("can select items with the Enter key", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const input = getByRole("combobox");
expect(input).toHaveAttribute("placeholder", "Placeholder");
fireEvent.focus(input);
await Promise.resolve();
fireEvent.keyDown(input, { key: "ArrowUp" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[2].id);
fireEvent.keyDown(input, { key: "ArrowUp" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[1]);
expect(listbox).not.toBeVisible();
expect(input).toHaveValue("Two");
});
it("focuses items on hover", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveAttribute("placeholder", "Placeholder");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
fireEvent(
items[1],
createPointerEvent("pointermove", {
pointerId: 1,
pointerType: "mouse",
clientX: 0,
clientY: 0,
}),
);
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
fireEvent.keyDown(input, { key: "ArrowDown" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[2].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[2]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(input).toHaveValue("Three");
});
it("does not clear selection on escape closing the listbox", async () => {
const onOpenChangeSpy = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(getByRole("combobox")).toHaveAttribute(
"placeholder",
"Placeholder",
);
expect(onOpenChangeSpy).toHaveBeenCalledTimes(0);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
expect(onOpenChangeSpy).toHaveBeenCalledTimes(1);
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
const item1 = within(listbox).getByText("One");
const item2 = within(listbox).getByText("Two");
const item3 = within(listbox).getByText("Three");
expect(item1).toBeTruthy();
expect(item2).toBeTruthy();
expect(item3).toBeTruthy();
fireEvent(
item3,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
item3,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onOpenChangeSpy).toHaveBeenCalledTimes(2);
expect(queryByRole("listbox")).toBeNull();
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
expect(onOpenChangeSpy).toHaveBeenCalledTimes(3);
fireEvent.keyDown(input, { key: "Escape" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1); // still expecting it to have only been called once
expect(onOpenChangeSpy).toHaveBeenCalledTimes(4);
expect(queryByRole("listbox")).toBeNull();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(getByRole("combobox")).toHaveValue("Three");
});
it("clear selection on escape when listbox is not visible", async () => {
const onOpenChangeSpy = vi.fn();
const { getByRole, queryByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(getByRole("combobox")).toHaveAttribute(
"placeholder",
"Placeholder",
);
expect(onOpenChangeSpy).toHaveBeenCalledTimes(0);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
expect(onOpenChangeSpy).toHaveBeenCalledTimes(1);
const listbox = getByRole("listbox");
expect(listbox).toBeVisible();
const item1 = within(listbox).getByText("One");
const item2 = within(listbox).getByText("Two");
const item3 = within(listbox).getByText("Three");
expect(item1).toBeTruthy();
expect(item2).toBeTruthy();
expect(item3).toBeTruthy();
fireEvent(
item3,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
item3,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onOpenChangeSpy).toHaveBeenCalledTimes(2);
expect(queryByRole("listbox")).toBeNull();
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
expect(onOpenChangeSpy).toHaveBeenCalledTimes(3);
fireEvent.keyDown(input, { key: "Escape" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1); // still expecting it to have only been called once
expect(onOpenChangeSpy).toHaveBeenCalledTimes(4);
expect(queryByRole("listbox")).toBeNull();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(getByRole("combobox")).toHaveValue("Three");
fireEvent.keyDown(input, { key: "Escape" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(2);
expect(document.activeElement).toBe(input);
expect(getByRole("combobox")).toHaveValue("");
});
it("supports controlled selection", async () => {
render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveValue("Two");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
expect(items[1]).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(input, { key: "ArrowUp" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[0].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[0]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(input).toHaveValue("Two");
});
it("supports default selection", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveValue("Two");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
expect(items[1]).toHaveAttribute("aria-selected", "true");
fireEvent.keyDown(input, { key: "ArrowUp" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowUp" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[0].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[0]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(input).toHaveValue("One");
});
it("skips disabled items", async () => {
const dataSource = [
{ key: "1", label: "One", textValue: "One", disabled: false },
{ key: "2", label: "Two", textValue: "Two", disabled: true },
{ key: "3", label: "Three", textValue: "Three", disabled: false },
];
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveAttribute("placeholder", "Placeholder");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
expect(items[1]).toHaveAttribute("aria-disabled", "true");
fireEvent.keyDown(input, { key: "ArrowDown" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
fireEvent.keyDown(input, { key: "ArrowDown" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "ArrowDown" });
await Promise.resolve();
expect(input).toHaveAttribute("aria-activedescendant", items[2].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(dataSource[2]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(input).toHaveValue("Three");
});
it("does not deselect when pressing an already selected item when 'disallowEmptySelection' is true", async () => {
render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const trigger = getByRole("button");
const input = getByRole("combobox");
expect(input).toHaveValue("Two");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(input).toHaveAttribute("aria-activedescendant", items[1].id);
fireEvent.keyDown(input, { key: "Enter" });
await Promise.resolve();
fireEvent.keyUp(input, { key: "Enter" });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(DATA_SOURCE[1]);
expect(listbox).not.toBeVisible();
// run restore focus rAF
vi.runAllTimers();
expect(document.activeElement).toBe(input);
expect(input).toHaveValue("Two");
});
});
describe("multi-select", () => {
it("supports selecting multiple options", async () => {
const { getByRole, getByTestId } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
>
{(state) => (
<>
{state
.selectedOptions()
.map((option) => option.label)
.join(", ")}
>
)}
));
const trigger = getByRole("button");
expect(getByRole("combobox")).toHaveAttribute(
"placeholder",
"Placeholder",
);
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
trigger,
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
fireEvent.click(trigger);
await Promise.resolve();
vi.runAllTimers();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(listbox).toHaveAttribute("aria-multiselectable", "true");
expect(items.length).toBe(3);
expect(items[0]).toHaveTextContent("One");
expect(items[1]).toHaveTextContent("Two");
expect(items[2]).toHaveTextContent("Three");
fireEvent(
items[0],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[0],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(items[0]).toHaveAttribute("aria-selected", "true");
expect(items[2]).toHaveAttribute("aria-selected", "true");
expect(onValueChange).toBeCalledTimes(2);
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[0].key),
).toBeTruthy();
expect(
onValueChange.mock.calls[1][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[2].key),
).toBeTruthy();
// Does not close on multi-select
expect(listbox).toBeVisible();
expect(getByTestId("value")).toHaveTextContent("One, Three");
});
it("supports multiple defaultValue (uncontrolled)", async () => {
const defaultValue = [DATA_SOURCE[0], DATA_SOURCE[1]];
const { getByRole } = render(() => (
multiple
options={DATA_SOURCE}
optionValue="key"
optionTextValue="textValue"
optionDisabled="disabled"
placeholder="Placeholder"
defaultValue={defaultValue}
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.label}
)}
>
Label
>
{(state) => (
<>
{state
.selectedOptions()
.map((option) => option.label)
.join(", ")}
>
)}
));
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items[0]).toHaveAttribute("aria-selected", "true");
expect(items[1]).toHaveAttribute("aria-selected", "true");
// SelectBase a different option
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(items[2]).toHaveAttribute("aria-selected", "true");
expect(onValueChange).toBeCalledTimes(1);
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[0].key),
).toBeTruthy();
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[1].key),
).toBeTruthy();
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[2].key),
).toBeTruthy();
});
it("supports multiple value (controlled)", async () => {
const value = [DATA_SOURCE[0], DATA_SOURCE[1]];
render(() => (
multiple
options={DATA_SOURCE}
optionValue="key"
optionTextValue="textValue"
optionDisabled="disabled"
placeholder="Placeholder"
value={value}
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.label}
)}
>
Label
>
{(state) => (
<>
{state
.selectedOptions()
.map((option) => option.label)
.join(", ")}
>
)}
));
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items[0]).toHaveAttribute("aria-selected", "true");
expect(items[1]).toHaveAttribute("aria-selected", "true");
// SelectBase a different option
fireEvent(
items[2],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[2],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(items[2]).toHaveAttribute("aria-selected", "false");
expect(onValueChange).toBeCalledTimes(1);
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[2].key),
).toBeTruthy();
});
it("supports deselection", async () => {
const defaultValue = [DATA_SOURCE[0], DATA_SOURCE[1]];
const { getByRole } = render(() => (
multiple
options={DATA_SOURCE}
optionValue="key"
optionTextValue="textValue"
optionDisabled="disabled"
placeholder="Placeholder"
defaultValue={defaultValue}
onChange={onValueChange}
itemComponent={(props) => (
{props.item.rawValue.label}
)}
>
Label
>
{(state) => (
<>
{state
.selectedOptions()
.map((option) => option.label)
.join(", ")}
>
)}
));
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
const listbox = getByRole("listbox");
const items = within(listbox).getAllByRole("option");
expect(items[0]).toHaveAttribute("aria-selected", "true");
expect(items[1]).toHaveAttribute("aria-selected", "true");
// Deselect first option
fireEvent(
items[0],
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
fireEvent(
items[0],
createPointerEvent("pointerup", { pointerId: 1, pointerType: "mouse" }),
);
await Promise.resolve();
expect(items[0]).toHaveAttribute("aria-selected", "false");
expect(onValueChange).toBeCalledTimes(1);
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[0].key),
).toBeFalsy();
expect(
onValueChange.mock.calls[0][0]
.map((option: DataSourceItem) => option.key)
.includes(DATA_SOURCE[1].key),
).toBeTruthy();
});
});
describe("autofill", () => {
it("should have a hidden select element for form autocomplete", async () => {
const dataSource: DataSourceItem[] = [
{ key: "DE", label: "Germany", textValue: "Germany", disabled: false },
{ key: "FR", label: "France", textValue: "France", disabled: false },
{ key: "IT", label: "Italy", textValue: "Italy", disabled: false },
];
const { getByRole, getAllByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const input = getByRole("combobox");
expect(input).toHaveAttribute("placeholder", "Placeholder");
const hiddenSelectBase = getAllByRole("listbox", {
hidden: true,
})[0];
expect(hiddenSelectBase).toHaveAttribute("tabIndex", "-1");
expect(hiddenSelectBase).toHaveAttribute(
"autocomplete",
"address-level1",
);
const options = within(hiddenSelectBase).getAllByRole("option", {
hidden: true,
});
expect(options.length).toBe(4);
options.forEach(
(option, index) =>
index > 0 &&
expect(option).toHaveTextContent(dataSource[index - 1].label),
);
fireEvent.change(hiddenSelectBase, { target: { value: "FR" } });
await Promise.resolve();
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange.mock.calls[0][0]).toStrictEqual(dataSource[1]);
expect(input).toHaveValue("France");
});
it("should have a hidden input to marshall focus to the combobox input", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const hiddenInput = getByRole("textbox", { hidden: true }); // get the hidden ones
expect(hiddenInput).toHaveAttribute("tabIndex", "0");
expect(hiddenInput).toHaveAttribute("style", "font-size: 16px;");
expect(hiddenInput.parentElement).toHaveAttribute("aria-hidden", "true");
hiddenInput.focus();
await Promise.resolve();
const input = getByRole("combobox");
expect(document.activeElement).toBe(input);
expect(hiddenInput).toHaveAttribute("tabIndex", "-1");
fireEvent.blur(input);
await Promise.resolve();
expect(hiddenInput).toHaveAttribute("tabIndex", "0");
});
});
describe("disabled", () => {
it("disables the hidden select when disabled is true", async () => {
const { getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
const select = getByRole("textbox", { hidden: true });
expect(select).toBeDisabled();
});
it("does not open on mouse down when disabled is true", async () => {
const onOpenChange = vi.fn();
const { queryByRole, getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
fireEvent(
trigger,
createPointerEvent("pointerdown", {
pointerId: 1,
pointerType: "mouse",
}),
);
await Promise.resolve();
expect(queryByRole("listbox")).toBeNull();
expect(onOpenChange).toBeCalledTimes(0);
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
it("does not open on Space key press when disabled is true", async () => {
const onOpenChange = vi.fn();
const { queryByRole, getByRole } = render(() => (
(
{props.item.rawValue.label}
)}
>
Label
));
expect(queryByRole("listbox")).toBeNull();
const trigger = getByRole("button");
fireEvent.keyDown(trigger, { key: " " });
await Promise.resolve();
fireEvent.keyUp(trigger, { key: " " });
await Promise.resolve();
expect(queryByRole("listbox")).toBeNull();
expect(onOpenChange).toBeCalledTimes(0);
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(document.activeElement).not.toBe(trigger);
});
});
describe("form", () => {
it("Should submit empty option by default", async () => {
let value: {};
const onSubmit = vi.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = Object.fromEntries(formData).test; // same name as the select "name" prop
});
const { getByTestId } = render(() => (
));
fireEvent.submit(getByTestId("form"));
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledTimes(1);
// @ts-ignore
expect(value).toBe("");
});
it("Should submit default option", async () => {
let value: {};
const onSubmit = vi.fn((e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
value = Object.fromEntries(formData).test; // same name as the select "name" prop
});
const { getByTestId } = render(() => (
));
fireEvent.submit(getByTestId("form"));
await Promise.resolve();
expect(onSubmit).toHaveBeenCalledTimes(1);
// @ts-ignore
expect(value).toEqual("1");
});
});
});