mirror of
https://github.com/carbon-design-system/carbon-components-svelte.git
synced 2025-09-14 18:01:06 +00:00
541 lines
15 KiB
TypeScript
541 lines
15 KiB
TypeScript
import { render, screen } from "@testing-library/svelte";
|
|
import { user } from "../setup-tests";
|
|
import MultiSelect from "./MultiSelect.test.svelte";
|
|
import MultiSelectSlot from "./MultiSelectSlot.test.svelte";
|
|
|
|
const items = [
|
|
{ id: "0", text: "Slack" },
|
|
{ id: "1", text: "Email" },
|
|
{ id: "2", text: "Fax" },
|
|
] as const;
|
|
|
|
describe("MultiSelect", () => {
|
|
const openMenu = async () =>
|
|
await user.click(
|
|
await screen.findByLabelText("Open menu", {
|
|
selector: `[role="button"]`,
|
|
}),
|
|
);
|
|
|
|
const closeMenu = async () =>
|
|
await user.click(
|
|
await screen.findByLabelText("Close menu", {
|
|
selector: `[role="button"]`,
|
|
}),
|
|
);
|
|
|
|
const toggleOption = async (optionText: string) =>
|
|
await user.click(
|
|
await screen.findByText((text) => text.trim() === optionText),
|
|
);
|
|
|
|
const nthRenderedOptionText = (index: number) =>
|
|
screen.queryAllByRole("option").at(index)?.textContent?.trim();
|
|
|
|
it("renders with default props", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
titleText: "Contact methods",
|
|
},
|
|
});
|
|
|
|
expect(screen.getByText("Contact methods")).toBeInTheDocument();
|
|
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
expect(screen.getByRole("button")).toHaveAttribute(
|
|
"aria-expanded",
|
|
"false",
|
|
);
|
|
});
|
|
|
|
it("renders default slot", () => {
|
|
render(MultiSelectSlot, { items });
|
|
expect(screen.getByText("1 Email 0")).toBeInTheDocument();
|
|
expect(screen.getByText("2 Fax 1")).toBeInTheDocument();
|
|
expect(screen.getByText("0 Slack 2")).toBeInTheDocument();
|
|
});
|
|
|
|
describe("selection behavior", () => {
|
|
it("handles item selection", async () => {
|
|
const consoleLog = vi.spyOn(console, "log");
|
|
render(MultiSelect, {
|
|
props: { items },
|
|
});
|
|
|
|
await openMenu();
|
|
await toggleOption("Slack");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: ["0"],
|
|
selected: [{ id: "0", text: "Slack", checked: true }],
|
|
unselected: [
|
|
{ id: "1", text: "Email", checked: false },
|
|
{ id: "2", text: "Fax", checked: false },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("handles multiple selections", async () => {
|
|
const consoleLog = vi.spyOn(console, "log");
|
|
render(MultiSelect, {
|
|
props: { items },
|
|
});
|
|
|
|
await openMenu();
|
|
await toggleOption("Slack");
|
|
await toggleOption("Email");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: ["0"],
|
|
selected: [{ id: "0", text: "Slack", checked: true }],
|
|
unselected: [
|
|
{ id: "1", text: "Email", checked: false },
|
|
{ id: "2", text: "Fax", checked: false },
|
|
],
|
|
});
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: ["1", "0"],
|
|
selected: [
|
|
{ id: "1", text: "Email", checked: true },
|
|
{ id: "0", text: "Slack", checked: true },
|
|
],
|
|
unselected: [{ id: "2", text: "Fax", checked: false }],
|
|
});
|
|
});
|
|
|
|
it("handles item deselection", async () => {
|
|
const consoleLog = vi.spyOn(console, "log");
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
selectedIds: ["0"],
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
await toggleOption("Slack");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: [],
|
|
selected: [],
|
|
unselected: [
|
|
{ id: "0", text: "Slack", checked: false },
|
|
{ id: "1", text: "Email", checked: false },
|
|
{ id: "2", text: "Fax", checked: false },
|
|
],
|
|
});
|
|
|
|
await toggleOption("Slack");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: [],
|
|
selected: [],
|
|
unselected: [
|
|
{ id: "0", text: "Slack", checked: false },
|
|
{ id: "1", text: "Email", checked: false },
|
|
{ id: "2", text: "Fax", checked: false },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("filtering behavior", () => {
|
|
it("filters items based on input", async () => {
|
|
const consoleLog = vi.spyOn(console, "log");
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
filterable: true,
|
|
placeholder: "Filter items...",
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const input = screen.getByPlaceholderText("Filter items...");
|
|
await user.type(input, "em");
|
|
|
|
expect(screen.queryByText("Slack")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Email")).toBeInTheDocument();
|
|
expect(screen.queryByText("Fax")).not.toBeInTheDocument();
|
|
|
|
await user.keyboard("{ArrowDown}{Enter}");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: ["1"],
|
|
selected: [{ id: "1", text: "Email", checked: true }],
|
|
unselected: [
|
|
{ id: "2", text: "Fax", checked: false },
|
|
{ id: "0", text: "Slack", checked: false },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("uses custom filter function", async () => {
|
|
const consoleLog = vi.spyOn(console, "log");
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
filterable: true,
|
|
filterItem: (item, value) => {
|
|
return item.text.toLowerCase().startsWith(value.toLowerCase());
|
|
},
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const input = screen.getByRole("combobox");
|
|
await user.type(input, "e");
|
|
|
|
expect(screen.queryByText("Slack")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Email")).toBeInTheDocument();
|
|
expect(screen.queryByText("Fax")).not.toBeInTheDocument();
|
|
|
|
await user.keyboard("{ArrowDown}{Enter}");
|
|
expect(consoleLog).toHaveBeenCalledWith("select", {
|
|
selectedIds: ["1"],
|
|
selected: [{ id: "1", text: "Email", checked: true }],
|
|
unselected: [
|
|
{ id: "2", text: "Fax", checked: false },
|
|
{ id: "0", text: "Slack", checked: false },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sorting behavior", () => {
|
|
it("initially sorts items alphabetically", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "1", text: "C" },
|
|
{ id: "3", text: "A" },
|
|
{ id: "2", text: "B" },
|
|
],
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
expect(nthRenderedOptionText(1)).toBe("B");
|
|
expect(nthRenderedOptionText(2)).toBe("C");
|
|
});
|
|
|
|
it("moves selected items to top with selectionFeedback: top", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "3", text: "C" },
|
|
{ id: "1", text: "A" },
|
|
{ id: "2", text: "B" },
|
|
],
|
|
selectionFeedback: "top",
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
|
|
await toggleOption("C");
|
|
expect(nthRenderedOptionText(0)).toBe("C");
|
|
|
|
await toggleOption("C");
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
});
|
|
|
|
it("sorts after reopen with selectionFeedback: top-after-reopen", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "3", text: "C" },
|
|
{ id: "1", text: "A" },
|
|
{ id: "2", text: "B" },
|
|
],
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
|
|
await toggleOption("C");
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
|
|
await closeMenu();
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("C");
|
|
});
|
|
|
|
it("maintains order with selectionFeedback: fixed", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "3", text: "C" },
|
|
{ id: "1", text: "A" },
|
|
{ id: "2", text: "B" },
|
|
],
|
|
selectionFeedback: "fixed",
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
|
|
await toggleOption("C");
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
|
|
await closeMenu();
|
|
await openMenu();
|
|
expect(nthRenderedOptionText(0)).toBe("A");
|
|
});
|
|
});
|
|
|
|
describe("variants and states", () => {
|
|
it("renders in light variant", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
light: true,
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const listBox = screen.getByRole("listbox").closest(".bx--list-box");
|
|
expect(listBox).toHaveClass("bx--list-box--light");
|
|
});
|
|
|
|
it("renders in inline variant", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
type: "inline",
|
|
},
|
|
});
|
|
|
|
const wrapper = screen
|
|
.getByRole("button")
|
|
.closest(".bx--multi-select__wrapper");
|
|
expect(wrapper).toHaveClass("bx--multi-select__wrapper--inline");
|
|
});
|
|
|
|
it("handles invalid state", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
invalid: true,
|
|
invalidText: "Invalid selection",
|
|
},
|
|
});
|
|
|
|
expect(screen.getByText("Invalid selection")).toBeInTheDocument();
|
|
const wrapper = screen.getByRole("button").closest(".bx--list-box");
|
|
expect(wrapper).toHaveClass("bx--multi-select--invalid");
|
|
});
|
|
|
|
it("handles warning state", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
warn: true,
|
|
warnText: "Warning message",
|
|
},
|
|
});
|
|
|
|
expect(screen.getByText("Warning message")).toBeInTheDocument();
|
|
const wrapper = screen.getByRole("button").closest(".bx--list-box");
|
|
expect(wrapper).toHaveClass("bx--list-box--warning");
|
|
});
|
|
|
|
it("handles disabled state", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
disabled: true,
|
|
},
|
|
});
|
|
|
|
const field = screen.getByRole("button");
|
|
expect(field).toHaveAttribute("aria-disabled", "true");
|
|
expect(field).toHaveAttribute("tabindex", "-1");
|
|
expect(field.closest(".bx--multi-select")).toHaveAttribute(
|
|
"tabindex",
|
|
"-1",
|
|
);
|
|
});
|
|
|
|
it("handles disabled items", async () => {
|
|
const itemsWithDisabled = [
|
|
{ id: "0", text: "Slack" },
|
|
{ id: "1", text: "Email", disabled: true },
|
|
{ id: "2", text: "Fax" },
|
|
];
|
|
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: itemsWithDisabled,
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const emailOption = screen
|
|
.getByText("Email")
|
|
.closest(".bx--list-box__menu-item");
|
|
expect(emailOption).toHaveAttribute("disabled");
|
|
});
|
|
});
|
|
|
|
describe("accessibility", () => {
|
|
it("handles hidden label", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
titleText: "Contact methods",
|
|
hideLabel: true,
|
|
},
|
|
});
|
|
|
|
const label = screen.getByText("Contact methods");
|
|
expect(label).toHaveClass("bx--visually-hidden");
|
|
});
|
|
});
|
|
|
|
describe("custom formatting", () => {
|
|
it("handles custom itemToString", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
selectedIds: ["0"],
|
|
itemToString: (item) => `${item.text} (${item.id})`,
|
|
},
|
|
});
|
|
|
|
expect(screen.getByText("Slack (0)")).toBeInTheDocument();
|
|
});
|
|
|
|
it("handles custom itemToInput", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
itemToInput: (item) => ({
|
|
name: `contact_${item.id}`,
|
|
value: item.text.toLowerCase(),
|
|
}),
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const checkbox = screen.getByText("Slack");
|
|
const checkboxWrapper = checkbox.closest(".bx--checkbox-wrapper");
|
|
const checkboxInput = checkboxWrapper?.querySelector("input");
|
|
expect(checkboxInput).toHaveAttribute("name", "contact_0");
|
|
expect(checkboxInput).toHaveAttribute("value", "slack");
|
|
});
|
|
});
|
|
|
|
it("does not show helper text if warn is true", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
helperText: "Help",
|
|
warn: true,
|
|
},
|
|
});
|
|
expect(screen.queryByText("Help")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show helper text if invalid is true", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
helperText: "Help",
|
|
invalid: true,
|
|
},
|
|
});
|
|
expect(screen.queryByText("Help")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show helper text if inline is true", () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
helperText: "Help",
|
|
type: "inline",
|
|
},
|
|
});
|
|
expect(screen.queryByText("Help")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("clears all selections when clear button is clicked", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
selectedIds: ["0", "1"],
|
|
},
|
|
});
|
|
|
|
await openMenu();
|
|
const options = screen.getAllByRole("option");
|
|
expect(options[0]).toHaveAttribute("aria-selected", "true");
|
|
expect(options[1]).toHaveAttribute("aria-selected", "true");
|
|
expect(options[2]).toHaveAttribute("aria-selected", "false");
|
|
|
|
await closeMenu();
|
|
const clearButton = screen.getByRole("button", {
|
|
name: /clear all selected items/i,
|
|
});
|
|
await user.click(clearButton);
|
|
|
|
await openMenu();
|
|
const updatedOptions = screen.getAllByRole("option");
|
|
expect(updatedOptions[0]).toHaveAttribute("aria-selected", "false");
|
|
expect(updatedOptions[1]).toHaveAttribute("aria-selected", "false");
|
|
expect(updatedOptions[2]).toHaveAttribute("aria-selected", "false");
|
|
});
|
|
|
|
it("skips disabled items during keyboard navigation", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "1", text: "Aa" },
|
|
{ id: "2", text: "Ba", disabled: true },
|
|
{ id: "3", text: "Ca" },
|
|
],
|
|
filterable: true,
|
|
placeholder: "Filter...",
|
|
},
|
|
});
|
|
await user.click(screen.getByRole("button"));
|
|
const input = screen.getByPlaceholderText("Filter...");
|
|
|
|
await user.type(input, "a");
|
|
await user.keyboard("{ArrowDown}");
|
|
await user.keyboard("{ArrowDown}");
|
|
await user.keyboard("{Enter}");
|
|
|
|
const options = screen.getAllByRole("option");
|
|
expect(options[2]).toHaveAttribute("aria-selected", "true");
|
|
});
|
|
|
|
it("focuses input when filterable and menu is opened", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items,
|
|
filterable: true,
|
|
placeholder: "Filter...",
|
|
},
|
|
});
|
|
await user.click(screen.getByRole("button"));
|
|
const input = screen.getByPlaceholderText("Filter...");
|
|
expect(input).toHaveFocus();
|
|
});
|
|
|
|
it("does not select disabled items when clicked", async () => {
|
|
render(MultiSelect, {
|
|
props: {
|
|
items: [
|
|
{ id: "1", text: "A" },
|
|
{ id: "2", text: "B", disabled: true },
|
|
{ id: "3", text: "C" },
|
|
],
|
|
},
|
|
});
|
|
await user.click(screen.getByRole("button"));
|
|
const disabledOption = screen.getByText("B").closest("[role='option']");
|
|
|
|
await user.click(disabledOption!);
|
|
expect(disabledOption).toHaveAttribute("aria-selected", "false");
|
|
});
|
|
});
|