diff --git a/tests/MultiSelect/MultiSelect.test.ts b/tests/MultiSelect/MultiSelect.test.ts index aed26b4a..7d2b8fae 100644 --- a/tests/MultiSelect/MultiSelect.test.ts +++ b/tests/MultiSelect/MultiSelect.test.ts @@ -2,7 +2,13 @@ import { render, screen } from "@testing-library/svelte"; import { MultiSelect } from "carbon-components-svelte"; import { user } from "../setup-tests"; -describe("MultiSelect sorts items correctly", () => { +const items = [ + { id: "0", text: "Slack" }, + { id: "1", text: "Email" }, + { id: "2", text: "Fax" }, +] as const; + +describe("MultiSelect", () => { /** Opens the dropdown. */ const openMenu = async () => await user.click( @@ -29,96 +35,317 @@ describe("MultiSelect sorts items correctly", () => { const nthRenderedOptionText = (index: number) => screen.queryAllByRole("option").at(index)?.textContent?.trim(); - it("initially sorts items alphabetically", async () => { - render(MultiSelect, { - items: [ - { id: "1", text: "C" }, - { id: "3", text: "A" }, - { id: "2", text: "B" }, - ], + describe("sorting behavior", () => { + it("initially sorts items alphabetically", async () => { + render(MultiSelect, { + items: [ + { id: "1", text: "C" }, + { id: "3", text: "A" }, + { id: "2", text: "B" }, + ], + }); + + // Initially, items should be sorted alphabetically. + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("A"); + expect(nthRenderedOptionText(1)).toBe("B"); + expect(nthRenderedOptionText(2)).toBe("C"); }); - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); - expect(nthRenderedOptionText(1)).toBe("B"); - expect(nthRenderedOptionText(2)).toBe("C"); + it("immediately moves selected items to the top (with selectionFeedback: top)", async () => { + render(MultiSelect, { + items: [ + { id: "3", text: "C" }, + { id: "1", text: "A" }, + { id: "2", text: "B" }, + ], + selectionFeedback: "top", + }); + + // Initially, items should be sorted alphabetically. + 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 newly-toggled items only after the dropdown is reoponed (with selectionFeedback: top-after-reopen)", async () => { + render(MultiSelect, { + items: [ + { id: "3", text: "C" }, + { id: "1", text: "A" }, + { id: "2", text: "B" }, + ], + }); + + // Initially, items should be sorted alphabetically. + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("A"); + + // While the menu is still open, a newly-selected item should not move. + await toggleOption("C"); + expect(nthRenderedOptionText(0)).toBe("A"); + + // The newly-selected item should move after the menu is closed and + // re-opened. + await closeMenu(); + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("C"); + + // A deselected item should not move while the dropdown is still open. + await toggleOption("C"); + expect(nthRenderedOptionText(0)).toBe("C"); + + // The deselected item should move after closing and re-opening the dropdown. + await closeMenu(); + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("A"); + }); + + it("never moves selected items to the top (with selectionFeedback: fixed)", async () => { + render(MultiSelect, { + items: [ + { id: "3", text: "C" }, + { id: "1", text: "A" }, + { id: "2", text: "B" }, + ], + selectionFeedback: "fixed", + }); + + // Items should be sorted alphabetically. + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("A"); + + // A newly-selected item should not move after the selection is made. + await toggleOption("C"); + expect(nthRenderedOptionText(0)).toBe("A"); + + // The newly-selected item also shouldn’t move after the dropdown is closed + // and reopened. + await closeMenu(); + await openMenu(); + expect(nthRenderedOptionText(0)).toBe("A"); + }); }); - it("immediately moves selected items to the top (with selectionFeedback: top)", async () => { - render(MultiSelect, { - items: [ - { id: "3", text: "C" }, - { id: "1", text: "A" }, - { id: "2", text: "B" }, - ], - selectionFeedback: "top", + describe("filtering behavior", () => { + it("should filter items based on input value", async () => { + render(MultiSelect, { + 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 openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); + it("should use custom filter function", async () => { + render(MultiSelect, { + items, + filterable: true, + filterItem: (item, value) => + item.text.toLowerCase().startsWith(value.toLowerCase()), + }); - await toggleOption("C"); - expect(nthRenderedOptionText(0)).toBe("C"); + await openMenu(); + const input = screen.getByRole("combobox"); + await user.type(input, "e"); - await toggleOption("C"); - expect(nthRenderedOptionText(0)).toBe("A"); + expect(screen.queryByText("Slack")).not.toBeInTheDocument(); + expect(screen.getByText("Email")).toBeInTheDocument(); + expect(screen.queryByText("Fax")).not.toBeInTheDocument(); + }); + + // TODO(bug): ListBoxSelection aria-labels should be user-friendly + it.skip("should clear filter on selection clear", async () => { + render(MultiSelect, { + items, + filterable: true, + selectedIds: ["0"], + }); + + const clearButton = screen.getByLabelText("Clear all"); + await user.click(clearButton); + + const input = screen.getByRole("combobox"); + expect(input).toHaveValue(""); + }); }); - it("sorts newly-toggled items only after the dropdown is reoponed (with selectionFeedback: top-after-reopen)", async () => { - render(MultiSelect, { - items: [ - { id: "3", text: "C" }, - { id: "1", text: "A" }, - { id: "2", text: "B" }, - ], + describe("keyboard navigation", () => { + it("should handle arrow keys for navigation", async () => { + render(MultiSelect, { items }); + + await openMenu(); + await user.keyboard("{ArrowDown}"); + + const options = screen.getAllByRole("option"); + expect(options[0]).toHaveClass("bx--list-box__menu-item--highlighted"); }); - // Initially, items should be sorted alphabetically. - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); + it("should select item with Enter key", async () => { + const { component } = render(MultiSelect, { items }); + const selectHandler = vi.fn(); + component.$on("select", selectHandler); - // While the menu is still open, a newly-selected item should not move. - await toggleOption("C"); - expect(nthRenderedOptionText(0)).toBe("A"); + await openMenu(); + await user.keyboard("{ArrowDown}"); + await user.keyboard("{Enter}"); - // The newly-selected item should move after the menu is closed and - // re-opened. - await closeMenu(); - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("C"); + expect(selectHandler).toHaveBeenCalled(); + }); - // A deselected item should not move while the dropdown is still open. - await toggleOption("C"); - expect(nthRenderedOptionText(0)).toBe("C"); + it("should close menu with Escape key", async () => { + render(MultiSelect, { items }); - // The deselected item should move after closing and re-opening the dropdown. - await closeMenu(); - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); + await openMenu(); + await user.keyboard("{Escape}"); + + const button = screen.getByRole("button"); + expect(button).toHaveAttribute("aria-expanded", "false"); + }); }); - it("never moves selected items to the top (with selectionFeedback: fixed)", async () => { - render(MultiSelect, { - items: [ - { id: "3", text: "C" }, - { id: "1", text: "A" }, - { id: "2", text: "B" }, - ], - selectionFeedback: "fixed", + describe("accessibility", () => { + it("should handle hidden label", () => { + render(MultiSelect, { + items, + titleText: "Contact methods", + hideLabel: true, + }); + + const label = screen.getByText("Contact methods"); + expect(label).toHaveClass("bx--visually-hidden"); }); - // Items should be sorted alphabetically. - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); + it("should handle custom aria-label", async () => { + render(MultiSelect, { + items, + "aria-label": "Custom label", + }); - // A newly-selected item should not move after the selection is made. - await toggleOption("C"); - expect(nthRenderedOptionText(0)).toBe("A"); + await openMenu(); + const menu = screen.getByLabelText("Custom label"); + expect(menu).toBeInTheDocument(); + }); + }); - // The newly-selected item also shouldn’t move after the dropdown is closed - // and reopened. - await closeMenu(); - await openMenu(); - expect(nthRenderedOptionText(0)).toBe("A"); + describe("variants and states", () => { + it("should render in light variant", async () => { + render(MultiSelect, { + items, + light: true, + }); + + await openMenu(); + const listBox = screen.getByRole("listbox").closest(".bx--list-box"); + expect(listBox).toHaveClass("bx--list-box--light"); + }); + + it("should render in inline variant", () => { + render(MultiSelect, { + items, + type: "inline", + }); + + const wrapper = screen + .getByRole("button") + .closest(".bx--multi-select__wrapper"); + expect(wrapper).toHaveClass("bx--multi-select__wrapper--inline"); + }); + + it("should handle invalid state", () => { + render(MultiSelect, { + 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("should handle warning state", () => { + render(MultiSelect, { + 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("should handle disabled state", () => { + render(MultiSelect, { 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("should handle disabled items", async () => { + const itemsWithDisabled = [ + { id: "0", text: "Slack" }, + { id: "1", text: "Email", disabled: true }, + { id: "2", text: "Fax" }, + ]; + + render(MultiSelect, { + items: itemsWithDisabled, + }); + + await openMenu(); + const emailOption = screen + .getByText("Email") + .closest(".bx--list-box__menu-item"); + expect(emailOption).toHaveAttribute("disabled"); + }); + }); + + describe("custom formatting", () => { + it("should handle custom itemToString", () => { + render(MultiSelect, { + items, + selectedIds: ["0"], + itemToString: (item) => `${item.text} (${item.id})`, + }); + + expect(screen.getByText("Slack (0)")).toBeInTheDocument(); + }); + + it("should handle custom itemToInput", async () => { + render(MultiSelect, { + 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"); + assert(checkboxWrapper); + + const checkboxInput = checkboxWrapper.querySelector("input"); + expect(checkboxInput).toHaveAttribute("name", "contact_0"); + }); }); });