import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { DropdownField } from "./DropdownField";
import { MultipleDropdownField } from "./MultipleDropdownField";
const choices = {
choiceLabels: ["Red", "Green", "Blue"],
choiceValues: ["red", "green", "blue"],
};
// ─── DropdownField (single-select) ───────────────────────────────────────────
describe("DropdownField - visibility", () => {
it("returns null when showWhen is false", () => {
const { container } = render(
);
expect(container.innerHTML).toBe("");
});
it("renders when showWhen is true (default)", () => {
render();
expect(screen.getByRole("button")).toBeInTheDocument();
});
});
describe("DropdownField - rendering", () => {
it("shows placeholder text when no value is selected", () => {
render();
expect(screen.getByText("Choose a color")).toBeInTheDocument();
});
it("shows default placeholder when no value and no placeholder prop", () => {
render();
expect(screen.getByText("Select...")).toBeInTheDocument();
});
it("shows selected value label", () => {
render();
expect(screen.getByText("Green")).toBeInTheDocument();
});
it("renders label when provided", () => {
render();
expect(screen.getByText("Favorite Color")).toBeInTheDocument();
});
it("renders instructions when provided", () => {
render();
expect(screen.getByText("Pick one color")).toBeInTheDocument();
});
});
describe("DropdownField - open/close", () => {
it("opens listbox when trigger is clicked", async () => {
const user = userEvent.setup();
render();
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await user.click(screen.getByRole("button"));
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
it("shows all options when open", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.getByRole("option", { name: "Red" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Green" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Blue" })).toBeInTheDocument();
});
it("trigger button has aria-expanded=false when closed", () => {
render();
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
});
it("trigger button has aria-expanded=true when open", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button", { expanded: true })).toBeInTheDocument();
});
});
describe("DropdownField - selection", () => {
it("calls onChange with selected value when an option is clicked", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: "Blue" }));
expect(onChange).toHaveBeenCalledWith("blue");
});
it("calls saveInto when onChange is not provided", async () => {
const user = userEvent.setup();
const saveInto = vi.fn();
render();
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: "Red" }));
expect(saveInto).toHaveBeenCalledWith("red");
});
it("closes the dropdown after selecting an option", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: "Green" }));
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
it("marks the selected option as aria-selected=true", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.getByRole("option", { name: "Red" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("option", { name: "Green" })).toHaveAttribute("aria-selected", "false");
});
});
describe("DropdownField - clear button", () => {
it("shows clear button when a value is selected", () => {
render();
// The X icon is rendered as an SVG inside the trigger area
const trigger = screen.getByRole("button");
expect(trigger.querySelector("svg")).toBeInTheDocument();
});
it("calls onChange with null when clear is clicked", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
// The X icon is the first SVG in the trigger
const svgs = screen.getByRole("button").querySelectorAll("svg");
await user.click(svgs[0]);
expect(onChange).toHaveBeenCalledWith(null);
});
});
describe("DropdownField - disabled", () => {
it("disables the trigger button when disabled=true", () => {
render();
expect(screen.getByRole("button")).toBeDisabled();
});
it("does not open when disabled", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
describe("DropdownField - validations", () => {
it("renders validation messages", () => {
render(
);
expect(screen.getByText("Required field")).toBeInTheDocument();
expect(screen.getByText("Invalid value")).toBeInTheDocument();
});
it("marks trigger as aria-invalid when validations are present", () => {
render();
expect(screen.getByRole("button")).toHaveAttribute("aria-invalid", "true");
});
it("renders requiredMessage when required and no value selected", () => {
render(
);
expect(screen.getByText("Please select a color")).toBeInTheDocument();
});
});
describe("DropdownField - search", () => {
it("shows search input when searchDisplay=ON", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("does not show search input when searchDisplay=OFF", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument();
});
it("filters options based on search term", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
await user.type(screen.getByPlaceholderText("Search..."), "blu");
expect(screen.getByRole("option", { name: "Blue" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Red" })).not.toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Green" })).not.toBeInTheDocument();
});
it("shows 'No results found' when search has no matches", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
await user.type(screen.getByPlaceholderText("Search..."), "zzz");
expect(screen.getByText("No results found")).toBeInTheDocument();
});
it("shows search automatically when there are more than 11 choices", async () => {
const user = userEvent.setup();
const manyLabels = Array.from({ length: 12 }, (_, i) => `Option ${i + 1}`);
const manyValues = Array.from({ length: 12 }, (_, i) => `opt${i + 1}`);
render(
);
await user.click(screen.getByRole("button"));
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
});
it("does not show search automatically when there are 11 or fewer choices", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
expect(screen.queryByPlaceholderText("Search...")).not.toBeInTheDocument();
});
});
// ─── MultipleDropdownField ────────────────────────────────────────────────────
describe("MultipleDropdownField - multi-select behavior", () => {
it("renders with no selection by default", () => {
render();
expect(screen.getByText("Select...")).toBeInTheDocument();
});
it("shows comma-separated labels for selected values", () => {
render();
expect(screen.getByText("Red, Blue")).toBeInTheDocument();
});
it("keeps dropdown open after selecting an option", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: /Red/ }));
expect(screen.getByRole("listbox")).toBeInTheDocument();
});
it("calls onChange with array containing selected value", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render();
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: /Green/ }));
expect(onChange).toHaveBeenCalledWith(["green"]);
});
it("calls onChange with value removed when already-selected option is clicked", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: /Red/ }));
expect(onChange).toHaveBeenCalledWith(["green"]);
});
it("calls onChange with null when last selected item is deselected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole("button"));
await user.click(screen.getByRole("option", { name: /Blue/ }));
expect(onChange).toHaveBeenCalledWith(null);
});
it("renders checkboxes inside options", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes).toHaveLength(3);
});
it("selected options have checked checkboxes", async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByRole("button"));
const checkboxes = screen.getAllByRole("checkbox");
expect(checkboxes[0]).toBeChecked(); // Red
expect(checkboxes[1]).not.toBeChecked(); // Green
expect(checkboxes[2]).not.toBeChecked(); // Blue
});
});