Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

1829 lines
50 KiB
TypeScript

import { test, describe, afterEach, expect, vi, beforeAll } from "vitest";
import { cleanup, render, fireEvent, waitFor } from "@self/tootils/render";
import { run_shared_prop_tests } from "@self/tootils/shared-prop-tests";
import event from "@testing-library/user-event";
import { setupi18n, changeLocale } from "../core/src/i18n";
import { formatter, reactive_formatter } from "../core/src/gradio_helper";
import Dropdown from "./Index.svelte";
import { handle_filter } from "./shared/utils";
// Build a real i18n marker the way the backend's I18nData does.
const marker = (key: string): string =>
`__i18n__${JSON.stringify({ __type__: "translation_metadata", key })}`;
const single_select_props = {
label: "Dropdown",
show_label: true,
value: "apple",
choices: [
["apple", "apple"],
["banana", "banana"],
["cherry", "cherry"]
] as [string, string | number][],
filterable: true,
interactive: true,
multiselect: false,
max_choices: null,
allow_custom_value: false
};
const tuple_choices: [string, string | number][] = [
["Apple Display", "apple_val"],
["Banana Display", "banana_val"],
["Cherry Display", "cherry_val"]
];
const multiselect_props = {
label: "Multiselect",
show_label: true,
value: [] as (string | number)[],
choices: [
["apple", "apple"],
["banana", "banana"],
["cherry", "cherry"]
] as [string, string | number][],
filterable: true,
interactive: true,
multiselect: true,
max_choices: null,
allow_custom_value: false
};
run_shared_prop_tests({
component: Dropdown,
name: "Dropdown",
base_props: {
value: "apple",
choices: [
["apple", "apple"],
["banana", "banana"]
],
filterable: true,
interactive: true,
multiselect: false,
max_choices: null
}
});
describe("Single-select: Rendering", () => {
afterEach(() => cleanup());
test("renders provided value as display text", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: "banana"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("banana");
});
test("renders display name when value differs from display name", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
choices: tuple_choices,
value: "banana_val"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("Banana Display");
});
test("renders empty input when value is null", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
});
test("renders empty input when value is undefined", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: undefined
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
});
test("input is disabled when interactive is false", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
interactive: false
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input).toBeDisabled();
});
test("input is readonly when filterable is false", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
filterable: false
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input).toHaveAttribute("readonly");
});
test("input is not readonly when filterable is true", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
filterable: true
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input).not.toHaveAttribute("readonly");
});
});
describe("Single-select: Options display", () => {
afterEach(() => cleanup());
test("focus shows all options", async () => {
const { getByLabelText, getAllByTestId } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(3);
});
test("options display names, not internal values", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
choices: tuple_choices,
value: "apple_val"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-label", "Apple Display");
expect(options[1]).toHaveAttribute("aria-label", "Banana Display");
expect(options[2]).toHaveAttribute("aria-label", "Cherry Display");
});
test("selected option is marked as selected", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
value: "banana"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-selected", "false");
expect(options[1]).toHaveAttribute("aria-selected", "true");
expect(options[2]).toHaveAttribute("aria-selected", "false");
});
test("options not shown when interactive is false", async () => {
const { getByLabelText, queryAllByTestId } = await render(Dropdown, {
...single_select_props,
interactive: false
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = queryAllByTestId("dropdown-option");
expect(options).toHaveLength(0);
});
test("blur hides options", async () => {
const { getByLabelText, getAllByTestId, queryAllByTestId } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
expect(getAllByTestId("dropdown-option")).toHaveLength(3);
await input.blur();
await waitFor(() => {
expect(queryAllByTestId("dropdown-option")).toHaveLength(0);
});
});
});
describe("Single-select: Filtering", () => {
afterEach(() => cleanup());
test("typing filters options case-insensitively", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("BAN");
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(1);
expect(options[0]).toHaveAttribute("aria-label", "banana");
});
test("blur and re-focus resets filter to show all options", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
value: "apple"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("z");
await input.blur();
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(3);
});
test("partial match filtering works", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
value: null,
choices: [
["pineapple", "pineapple"],
["apple", "apple"],
["grape", "grape"]
] as [string, string | number][]
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("apple");
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(2);
});
});
describe("Single-select: Selection", () => {
afterEach(() => cleanup());
test("clicking an option selects it and updates input", async () => {
const { getByLabelText, getAllByTestId } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(input.value).toBe("banana");
});
test("clicking an option updates get_data value", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[2]);
const data = await get_data();
expect(data.value).toBe("cherry");
});
test("clicking an option with tuple choices returns internal value via get_data", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...single_select_props,
choices: tuple_choices,
value: "apple_val"
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(input.value).toBe("Banana Display");
const data = await get_data();
expect(data.value).toBe("banana_val");
});
test("arrow down then Enter selects first option", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("{ArrowDown}");
await event.keyboard("{Enter}");
expect(input.value).toBe("apple");
});
test("arrow down from a selected option moves to the next option", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: "banana"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("{ArrowDown}");
await event.keyboard("{Enter}");
expect(input.value).toBe("cherry");
});
test("arrow up from a selected option moves to the previous option", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: "banana"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("{ArrowUp}");
await event.keyboard("{Enter}");
expect(input.value).toBe("apple");
});
test("selecting a new option replaces the previous one", async () => {
const { getByLabelText, getAllByTestId } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(input.value).toBe("banana");
await input.focus();
options = getAllByTestId("dropdown-option");
await event.click(options[2]);
expect(input.value).toBe("cherry");
});
test("clicking option updates selected marker", async () => {
const { getByLabelText, getAllByTestId } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("apple");
await input.focus();
let options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveClass("selected");
await event.click(options[1]);
expect(input.value).toBe("banana");
await input.focus();
options = getAllByTestId("dropdown-option");
expect(options[1]).toHaveClass("selected");
});
});
describe("Single-select: Custom values", () => {
afterEach(() => cleanup());
test("allow_custom_value=false: blur reverts invalid typed text", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
allow_custom_value: false,
value: "apple"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("pie");
expect(input.value).toBe("applepie");
await input.blur();
expect(input.value).toBe("apple");
});
test("allow_custom_value=true: blur keeps custom typed text", async () => {
const { getByLabelText, get_data } = await render(Dropdown, {
...single_select_props,
allow_custom_value: true,
value: "apple"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("pie");
expect(input.value).toBe("applepie");
await input.blur();
expect(input.value).toBe("applepie");
const data = await get_data();
expect(data.value).toBe("applepie");
});
test("allow_custom_value=true: Enter accepts custom value", async () => {
const { getByLabelText, get_data } = await render(Dropdown, {
...single_select_props,
allow_custom_value: true,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("custom_text{Enter}");
const data = await get_data();
expect(data.value).toBe("custom_text");
});
test("allow_custom_value=false: Enter with no matching active option keeps current value", async () => {
const { getByLabelText, get_data } = await render(Dropdown, {
...single_select_props,
allow_custom_value: false,
value: "apple"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
// Type something that filters to no results, then Enter
await event.keyboard("xyz");
await input.blur();
const data = await get_data();
expect(data.value).toBe("apple");
});
test("regression #12548: selecting a tuple choice with allow_custom_value=true returns internal value, not display name", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...single_select_props,
choices: [
["hello", "goodbye"],
["abc", "123"]
] as [string, string | number][],
value: null,
allow_custom_value: true
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[0]);
expect(input.value).toBe("hello");
const data = await get_data();
expect(data.value).toBe("goodbye");
});
test("regression #12548: blur on a matching choice name with allow_custom_value=true returns internal value", async () => {
const { getByLabelText, get_data } = await render(Dropdown, {
...single_select_props,
choices: [
["hello", "goodbye"],
["abc", "123"]
] as [string, string | number][],
value: null,
allow_custom_value: true
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("hello");
await input.blur();
const data = await get_data();
expect(data.value).toBe("goodbye");
});
test("undefined initial value with allow_custom_value=true renders empty", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: undefined,
allow_custom_value: true
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
});
});
describe("Single-select: Events", () => {
afterEach(() => cleanup());
test("no spurious change event on mount", async () => {
const { listen } = await render(Dropdown, single_select_props);
const change = listen("change", { retrospective: true });
expect(change).not.toHaveBeenCalled();
});
test("change fires when value changes via set_data", async () => {
const { listen, set_data } = await render(Dropdown, single_select_props);
const change = listen("change");
await set_data({ value: "banana" });
expect(change).toHaveBeenCalledTimes(1);
});
test("change event deduplication: setting same value does not fire again", async () => {
const { listen, set_data } = await render(Dropdown, single_select_props);
const change = listen("change");
await set_data({ value: "banana" });
await set_data({ value: "banana" });
expect(change).toHaveBeenCalledTimes(1);
});
test("change fires when option is clicked", async () => {
const { getByLabelText, getAllByTestId, listen } = await render(
Dropdown,
single_select_props
);
const change = listen("change");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(change).toHaveBeenCalledTimes(1);
});
test("input fires when option is selected", async () => {
const { getByLabelText, getAllByTestId, listen } = await render(
Dropdown,
single_select_props
);
const input_event = listen("input");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(input_event).toHaveBeenCalled();
});
test("select fires with correct data when option is clicked", async () => {
const { getByLabelText, getAllByTestId, listen } = await render(
Dropdown,
single_select_props
);
const select = listen("select");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[2]);
expect(select).toHaveBeenCalledTimes(1);
expect(select).toHaveBeenCalledWith({
index: 2,
value: "cherry",
selected: true
});
});
test("focus fires when input gains focus", async () => {
const { getByLabelText, listen } = await render(
Dropdown,
single_select_props
);
const focus = listen("focus");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
expect(focus).toHaveBeenCalledTimes(1);
});
test("blur fires when input loses focus", async () => {
const { getByLabelText, listen } = await render(
Dropdown,
single_select_props
);
const blur = listen("blur");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await input.blur();
expect(blur).toHaveBeenCalledTimes(1);
});
test("regression #12634: key_up fires with current input value after keypress", async () => {
const { getByLabelText, listen } = await render(Dropdown, {
...single_select_props,
value: null,
allow_custom_value: true
});
const key_up = listen("key_up");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("a");
expect(key_up).toHaveBeenCalledWith(
expect.objectContaining({ key: "a", input_value: "a" })
);
});
test("regression #12634: key_up passes updated value, not stale value", async () => {
const { getByLabelText, listen } = await render(Dropdown, {
...single_select_props,
value: null,
allow_custom_value: true
});
const key_up = listen("key_up");
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("1");
await event.keyboard("5");
await event.keyboard("4");
expect(key_up).toHaveBeenLastCalledWith(
expect.objectContaining({ key: "4", input_value: "154" })
);
});
});
describe("Single-select: get_data / set_data", () => {
afterEach(() => cleanup());
test("get_data returns current value", async () => {
const { get_data } = await render(Dropdown, single_select_props);
const data = await get_data();
expect(data.value).toBe("apple");
});
test("set_data updates displayed value", async () => {
const { getByLabelText, set_data } = await render(
Dropdown,
single_select_props
);
await set_data({ value: "cherry" });
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("cherry");
});
test("set_data updates displayed text for tuple choices", async () => {
const { getByLabelText, set_data } = await render(Dropdown, {
...single_select_props,
choices: tuple_choices,
value: "apple_val"
});
await set_data({ value: "cherry_val" });
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("Cherry Display");
});
test("round-trip: set_data then get_data returns same value", async () => {
const { get_data, set_data } = await render(Dropdown, single_select_props);
await set_data({ value: "banana" });
const data = await get_data();
expect(data.value).toBe("banana");
});
test("set_data to null clears the input", async () => {
const { getByLabelText, set_data, get_data } = await render(
Dropdown,
single_select_props
);
await set_data({ value: null });
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
const data = await get_data();
expect(data.value).toBeNull();
});
test("user interaction reflected in get_data", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
single_select_props
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[2]);
const data = await get_data();
expect(data.value).toBe("cherry");
});
});
describe("Single-select: Accessibility", () => {
afterEach(() => cleanup());
test("input exposes the combobox role", async () => {
const { getByLabelText } = await render(Dropdown, single_select_props);
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input).toHaveAttribute("role", "combobox");
});
test("aria-controls points at the options listbox", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
const controls = input.getAttribute("aria-controls");
expect(controls).toBeTruthy();
await input.focus();
const listbox = document.getElementById(controls as string);
expect(listbox).toBeTruthy();
expect(listbox).toHaveAttribute("role", "listbox");
});
test("aria-activedescendant tracks the active option while navigating", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
expect(input).not.toHaveAttribute("aria-activedescendant");
await event.keyboard("{ArrowDown}");
const active_id = input.getAttribute("aria-activedescendant");
expect(active_id).toBeTruthy();
const active_option = document.getElementById(active_id as string);
expect(active_option).toHaveAttribute("role", "option");
expect(active_option).toHaveAttribute("aria-label", "apple");
});
});
describe("Single-select: Dynamic choices", () => {
afterEach(() => cleanup());
test("updating choices while typing does not clear the typed text", async () => {
const { getByLabelText, set_data } = await render(Dropdown, {
...single_select_props,
value: null,
allow_custom_value: true,
choices: [] as [string, string | number][]
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("ap");
expect(input.value).toBe("ap");
await set_data({
choices: [
["ap item 1", "ap item 1"],
["ap item 2", "ap item 2"]
] as [string, string | number][]
});
expect(input.value).toBe("ap");
});
test("choices returned while open surface as new options", async () => {
const { getByLabelText, getAllByTestId, set_data } = await render(
Dropdown,
{
...single_select_props,
value: null,
allow_custom_value: true,
choices: [["old", "old"]] as [string, string | number][]
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await event.keyboard("new");
await set_data({
choices: [
["new item 1", "a"],
["new item 2", "b"],
["new item 3", "c"]
] as [string, string | number][]
});
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(3);
expect(options[0]).toHaveAttribute("aria-label", "new item 1");
});
});
describe("Single-select: Parent value update while focused", () => {
afterEach(() => cleanup());
test("re-syncs the selected option after the parent updates value while focused", async () => {
const { getByLabelText, getAllByTestId, set_data } = await render(
Dropdown,
{
...single_select_props,
value: "apple"
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await set_data({ value: "cherry" });
await input.blur();
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-selected", "false");
expect(options[2]).toHaveAttribute("aria-selected", "true");
});
test("arrow navigation starts from the parent-updated value after focus", async () => {
const { getByLabelText, set_data } = await render(Dropdown, {
...single_select_props,
value: "apple"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await set_data({ value: "cherry" });
await input.blur();
await input.focus();
await event.keyboard("{ArrowDown}");
await event.keyboard("{Enter}");
expect(input.value).toBe("apple");
});
test("does not revert the parent value on blur with allow_custom_value", async () => {
const { getByLabelText, get_data, set_data } = await render(Dropdown, {
...single_select_props,
value: "apple",
allow_custom_value: true
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
await set_data({ value: "cherry" });
await input.blur();
const data = await get_data();
expect(data.value).toBe("cherry");
expect(input.value).toBe("cherry");
});
});
describe("Multiselect: Rendering", () => {
afterEach(() => cleanup());
test("renders with empty value (no tokens)", async () => {
const { container } = await render(Dropdown, multiselect_props);
const tokens = container.querySelectorAll(".token");
expect(tokens).toHaveLength(0);
});
test("renders selected values as tokens", async () => {
const { getByText } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "cherry"]
});
expect(getByText("apple")).toBeTruthy();
expect(getByText("cherry")).toBeTruthy();
});
test("renders display names for tuple choices in tokens", async () => {
const { getByText } = await render(Dropdown, {
...multiselect_props,
choices: tuple_choices,
value: ["apple_val", "cherry_val"]
});
expect(getByText("Apple Display")).toBeTruthy();
expect(getByText("Cherry Display")).toBeTruthy();
});
test("disabled state: no remove buttons on tokens", async () => {
const { container } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana"],
interactive: false
});
const tokens = container.querySelectorAll(".token");
expect(tokens).toHaveLength(2);
const removeButtons = container.querySelectorAll(".token-remove");
expect(removeButtons).toHaveLength(0);
});
});
describe("Multiselect: Accessibility", () => {
afterEach(() => cleanup());
test("input is a combobox wired to the options listbox", async () => {
const { container } = await render(Dropdown, multiselect_props);
const input = container.querySelector("input") as HTMLInputElement;
expect(input).toHaveAttribute("role", "combobox");
await input.focus();
const controls = input.getAttribute("aria-controls");
const listbox = document.getElementById(controls as string);
expect(listbox).toHaveAttribute("role", "listbox");
const active_id = input.getAttribute("aria-activedescendant");
const active_option = document.getElementById(active_id as string);
expect(active_option).toHaveAttribute("role", "option");
expect(active_option).toHaveAttribute("aria-label", "apple");
});
});
describe("Multiselect: Options display", () => {
afterEach(() => cleanup());
test("focus shows all options", async () => {
const { container, getAllByTestId } = await render(
Dropdown,
multiselect_props
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(3);
});
test("selected options are marked as selected", async () => {
const { container, getAllByTestId } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "cherry"]
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-selected", "true");
expect(options[1]).toHaveAttribute("aria-selected", "false");
expect(options[2]).toHaveAttribute("aria-selected", "true");
});
});
describe("Multiselect: Selection", () => {
afterEach(() => cleanup());
test("clicking an option adds a token", async () => {
const { container, getAllByTestId } = await render(
Dropdown,
multiselect_props
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[0]);
await input.focus();
const tokens = container.querySelectorAll(".token");
expect(tokens).toHaveLength(1);
expect(tokens[0].textContent).toContain("apple");
});
test("clicking same option twice toggles it (add then remove)", async () => {
const { container, getAllByTestId, get_data } = await render(
Dropdown,
multiselect_props
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[0]);
let data = await get_data();
expect(data.value).toEqual(["apple"]);
await input.focus();
options = getAllByTestId("dropdown-option");
await event.click(options[0]);
data = await get_data();
expect(data.value).toEqual([]);
});
test("multiple options can be selected", async () => {
const { container, getAllByTestId, get_data } = await render(
Dropdown,
multiselect_props
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[0]);
await input.focus();
options = getAllByTestId("dropdown-option");
await event.click(options[2]);
const data = await get_data();
expect(data.value).toEqual(["apple", "cherry"]);
});
test("remove button on token removes that selection", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana", "cherry"]
});
const removeButtons = container.querySelectorAll(
".token-remove:not(.remove-all)"
);
expect(removeButtons).toHaveLength(3);
await event.click(removeButtons[1]);
const data = await get_data();
expect(data.value).toEqual(["apple", "cherry"]);
});
test("clear-all button removes all selections", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana"]
});
const clearAll = container.querySelector(".remove-all") as HTMLElement;
expect(clearAll).toBeInTheDocument();
await event.click(clearAll);
const data = await get_data();
expect(data.value).toEqual([]);
});
test("remove button on each token works independently", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana", "cherry"]
});
const removeButtons = container.querySelectorAll(
".token-remove:not(.remove-all)"
);
await event.click(removeButtons[0]);
const data = await get_data();
expect(data.value).toEqual(["banana", "cherry"]);
});
test("max_choices limits the number of selections", async () => {
const { container, getAllByTestId, get_data } = await render(Dropdown, {
...multiselect_props,
max_choices: 2
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[0]);
await input.focus();
options = getAllByTestId("dropdown-option");
await event.click(options[1]);
const data = await get_data();
expect((data.value as string[]).length).toBeLessThanOrEqual(2);
});
test("max_choices closes the options panel when limit is met", async () => {
const { container, getAllByTestId, queryAllByTestId } = await render(
Dropdown,
{
...multiselect_props,
max_choices: 2
}
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[0]);
await input.focus();
options = getAllByTestId("dropdown-option");
await event.click(options[1]);
await waitFor(() => {
expect(queryAllByTestId("dropdown-option")).toHaveLength(0);
});
});
test("backspace removes the last token when input is empty", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana", "cherry"]
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await fireEvent.keyDown(input, { key: "Backspace" });
const data = await get_data();
expect(data.value).toEqual(["apple", "banana"]);
});
test("Enter selects the active option when allow_custom_value is false", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
allow_custom_value: false
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
// When allow_custom_value=false, active_index defaults to first filtered option
await event.keyboard("{Enter}");
const data = await get_data();
expect(data.value).toContain("apple");
});
});
describe("Multiselect: Custom values", () => {
afterEach(() => cleanup());
test("allow_custom_value=true: blur adds typed text as token", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
allow_custom_value: true
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await event.keyboard("custom_fruit");
await input.blur();
const data = await get_data();
expect(data.value).toEqual(["custom_fruit"]);
});
test("allow_custom_value=false: blur clears typed text without adding", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
allow_custom_value: false
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await event.keyboard("custom_fruit");
await input.blur();
const data = await get_data();
expect(data.value).toEqual([]);
});
test("allow_custom_value=true: Enter adds custom value", async () => {
const { container, get_data } = await render(Dropdown, {
...multiselect_props,
allow_custom_value: true
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await event.keyboard("new_item{Enter}");
const data = await get_data();
expect(data.value).toContain("new_item");
});
test("mix of choices and custom values", async () => {
const { container, getAllByTestId, get_data } = await render(Dropdown, {
...multiselect_props,
allow_custom_value: true
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
let options = getAllByTestId("dropdown-option");
await event.click(options[0]);
await input.focus();
await event.keyboard("custom{Enter}");
const data = await get_data();
expect(data.value).toContain("apple");
expect(data.value).toContain("custom");
});
});
describe("Multiselect: Filtering", () => {
afterEach(() => cleanup());
test("typing filters options", async () => {
const { container, getAllByTestId } = await render(
Dropdown,
multiselect_props
);
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await event.keyboard("ban");
const options = getAllByTestId("dropdown-option");
expect(options).toHaveLength(1);
expect(options[0]).toHaveAttribute("aria-label", "banana");
});
});
describe("Multiselect: Events", () => {
afterEach(() => cleanup());
test("change fires when option is selected", async () => {
const { container, getAllByTestId, listen } = await render(
Dropdown,
multiselect_props
);
const change = listen("change");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[0]);
expect(change).toHaveBeenCalled();
});
test("change fires when value changes via set_data", async () => {
const { listen, set_data } = await render(Dropdown, multiselect_props);
const change = listen("change");
await set_data({ value: ["apple", "banana"] });
expect(change).toHaveBeenCalled();
});
test("select fires with selected=true when adding", async () => {
const { container, getAllByTestId, listen } = await render(
Dropdown,
multiselect_props
);
const select = listen("select");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
value: "banana",
selected: true
})
);
});
test("select fires with selected=false when removing via toggle", async () => {
const { container, getAllByTestId, listen } = await render(Dropdown, {
...multiselect_props,
value: ["banana"]
});
const select = listen("select");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[1]);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
value: "banana",
selected: false
})
);
});
test("focus fires when input gains focus", async () => {
const { container, listen } = await render(Dropdown, multiselect_props);
const focus = listen("focus");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
expect(focus).toHaveBeenCalledTimes(1);
});
test("blur fires when input loses focus", async () => {
const { container, listen } = await render(Dropdown, multiselect_props);
const blur = listen("blur");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
await input.blur();
expect(blur).toHaveBeenCalledTimes(1);
});
});
describe("Multiselect: get_data / set_data", () => {
afterEach(() => cleanup());
test("get_data returns current value array", async () => {
const { get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "cherry"]
});
const data = await get_data();
expect(data.value).toEqual(["apple", "cherry"]);
});
test("get_data returns empty array for no selections", async () => {
const { get_data } = await render(Dropdown, multiselect_props);
const data = await get_data();
expect(data.value).toEqual([]);
});
test("set_data updates tokens", async () => {
const { container, set_data } = await render(Dropdown, multiselect_props);
await set_data({ value: ["banana", "cherry"] });
const tokens = container.querySelectorAll(".token");
expect(tokens).toHaveLength(2);
expect(tokens[0].textContent).toContain("banana");
expect(tokens[1].textContent).toContain("cherry");
});
test("round-trip: set_data then get_data", async () => {
const { get_data, set_data } = await render(Dropdown, multiselect_props);
await set_data({ value: ["apple", "cherry"] });
const data = await get_data();
expect(data.value).toEqual(["apple", "cherry"]);
});
test("set_data to empty array clears all tokens", async () => {
const { container, set_data, get_data } = await render(Dropdown, {
...multiselect_props,
value: ["apple", "banana"]
});
await set_data({ value: [] });
const tokens = container.querySelectorAll(".token");
expect(tokens).toHaveLength(0);
const data = await get_data();
expect(data.value).toEqual([]);
});
});
describe("Regression: #12629 — Buttons on multiselect", () => {
afterEach(() => cleanup());
test("buttons are rendered in multiselect mode when show_label is true", async () => {
const { container } = await render(Dropdown, {
...multiselect_props,
show_label: true,
buttons: [{ label: "B1", id: "btn1" }]
});
const customButton = container.querySelector(
"button.custom-button"
) as HTMLElement;
expect(customButton).toBeInTheDocument();
});
test("buttons are rendered in single-select mode", async () => {
const { container } = await render(Dropdown, {
...single_select_props,
show_label: true,
buttons: [{ label: "B1", id: "btn1" }]
});
const customButton = container.querySelector(
"button.custom-button"
) as HTMLElement;
expect(customButton).toBeInTheDocument();
});
});
describe("Regression: #12764 — Multiselect event propagation", () => {
afterEach(() => cleanup());
test("selecting a multiselect option correctly dispatches select event without side effects", async () => {
const { container, getAllByTestId, listen } = await render(Dropdown, {
...multiselect_props,
value: []
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
const select = listen("select");
await event.click(options[0]);
expect(select).toHaveBeenCalledWith(
expect.objectContaining({
value: "apple",
selected: true
})
);
});
test("selecting a multiselect option with buttons does not trigger custom_button_click", async () => {
const { container, getAllByTestId, listen } = await render(Dropdown, {
...multiselect_props,
show_label: true,
buttons: [{ label: "TestBtn", id: "test_btn" }],
value: []
});
const custom_click = listen("custom_button_click");
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[0]);
expect(custom_click).not.toHaveBeenCalled();
});
});
describe("Edge cases", () => {
afterEach(() => cleanup());
test("empty choices array renders without error", async () => {
const { getByLabelText, queryAllByTestId } = await render(Dropdown, {
...single_select_props,
choices: [],
value: null
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
await input.focus();
const options = queryAllByTestId("dropdown-option");
expect(options).toHaveLength(0);
});
test("value not in choices renders empty when custom values not allowed", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: "nonexistent",
allow_custom_value: false
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("");
});
test("value not in choices renders as-is when custom values allowed", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
value: "nonexistent",
allow_custom_value: true
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("nonexistent");
});
test("numeric values work correctly", async () => {
const { get_data, getByLabelText, getAllByTestId } = await render(
Dropdown,
{
...single_select_props,
choices: [
["One", 1],
["Two", 2],
["Three", 3]
] as [string, number][],
value: 1
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("One");
await input.focus();
const options = getAllByTestId("dropdown-option");
await event.click(options[2]);
const data = await get_data();
expect(data.value).toBe(3);
});
test("blurring after typing reverts to selected display name when custom not allowed", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
choices: [
["Apple Fruit", "apple_val"],
["Banana Fruit", "banana_val"]
] as [string, string][],
value: "apple_val",
allow_custom_value: false
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("Apple Fruit");
await input.focus();
await event.keyboard("xyz");
expect(input.value).toBe("Apple Fruitxyz");
await input.blur();
expect(input.value).toBe("Apple Fruit");
});
test("first item can be default value with tuple choices", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
choices: [
["apple_choice", "apple_internal_value"],
["zebra_choice", "zebra_internal_value"]
] as [string, string][],
value: "apple_internal_value"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("apple_choice");
});
});
describe("handle_filter", () => {
const choices: [string, string | number][] = [
["Apple", "apple"],
["Banana", "banana"],
["Cherry", "cherry"],
["Apricot", "apricot"]
];
test("returns all indices when input_text is empty", () => {
expect(handle_filter(choices, "")).toEqual([0, 1, 2, 3]);
});
test("filters by case-insensitive substring match on display name", () => {
expect(handle_filter(choices, "ap")).toEqual([0, 3]);
});
test("returns empty array when no choices match", () => {
expect(handle_filter(choices, "xyz")).toEqual([]);
});
test("matches full display name", () => {
expect(handle_filter(choices, "banana")).toEqual([1]);
});
test("is case-insensitive", () => {
expect(handle_filter(choices, "CHERRY")).toEqual([2]);
});
test("handles empty choices array", () => {
expect(handle_filter([], "test")).toEqual([]);
});
test("matches substring anywhere in display name", () => {
expect(handle_filter(choices, "an")).toEqual([1]);
});
});
describe("i18n choices", () => {
beforeAll(async () => {
await setupi18n(undefined, "en");
changeLocale("en");
});
afterEach(() => cleanup());
const i18n_choices: [string, string][] = [
[marker("common.clear"), "bold"],
[marker("common.remove"), "italic"]
];
test("single-select shows translated display name for the selected value", async () => {
const { getByLabelText } = await render(Dropdown, {
...single_select_props,
i18n: formatter,
choices: i18n_choices,
value: "bold"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("Clear");
});
test("single-select translates option display names", async () => {
const { getByLabelText, getAllByTestId } = await render(Dropdown, {
...single_select_props,
i18n: formatter,
choices: i18n_choices,
value: "bold"
});
const input = getByLabelText("Dropdown") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-label", "Clear");
expect(options[1]).toHaveAttribute("aria-label", "Remove");
});
test("single-select internal value is unaffected by translation", async () => {
const { get_data } = await render(Dropdown, {
...single_select_props,
i18n: formatter,
choices: i18n_choices,
value: "bold"
});
const data = await get_data();
expect(data.value).toBe("bold");
});
test("multiselect renders translated display names in tokens", async () => {
const { getByText, queryByText } = await render(Dropdown, {
...multiselect_props,
i18n: formatter,
choices: i18n_choices,
value: ["bold", "italic"]
});
expect(getByText("Clear")).toBeTruthy();
expect(getByText("Remove")).toBeTruthy();
expect(queryByText(marker("common.clear"))).toBeNull();
});
test("multiselect translates option display names", async () => {
const { container, getAllByTestId } = await render(Dropdown, {
...multiselect_props,
i18n: formatter,
choices: i18n_choices
});
const input = container.querySelector("input") as HTMLInputElement;
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-label", "Clear");
expect(options[1]).toHaveAttribute("aria-label", "Remove");
});
test("multiselect internal value is unaffected by translation", async () => {
const { get_data } = await render(Dropdown, {
...multiselect_props,
i18n: formatter,
choices: i18n_choices,
value: ["italic"]
});
const data = await get_data();
expect(data.value).toEqual(["italic"]);
});
});
describe("runtime locale switching", () => {
beforeAll(async () => {
await setupi18n(undefined, "en");
changeLocale("en");
});
afterEach(() => {
changeLocale("en");
cleanup();
});
const i18n_choices: [string, string][] = [
[marker("common.clear"), "bold"],
[marker("common.remove"), "italic"]
];
test("single-select re-translates the selected display name and options", async () => {
const { getByLabelText, getAllByTestId, get_data } = await render(
Dropdown,
{
...single_select_props,
i18n: formatter,
i18n_store: reactive_formatter,
choices: i18n_choices,
value: "bold"
}
);
const input = getByLabelText("Dropdown") as HTMLInputElement;
expect(input.value).toBe("Clear");
changeLocale("es");
await waitFor(() => {
expect(input.value).toBe("Limpiar");
});
await input.focus();
const options = getAllByTestId("dropdown-option");
expect(options[0]).toHaveAttribute("aria-label", "Limpiar");
expect(options[1]).toHaveAttribute("aria-label", "Eliminar");
const data = await get_data();
expect(data.value).toBe("bold");
});
test("multiselect re-translates selected tokens when the locale changes", async () => {
const { getByText, get_data } = await render(Dropdown, {
...multiselect_props,
i18n: formatter,
i18n_store: reactive_formatter,
choices: i18n_choices,
value: ["bold", "italic"]
});
expect(getByText("Clear")).toBeVisible();
expect(getByText("Remove")).toBeVisible();
changeLocale("es");
await waitFor(() => {
expect(getByText("Limpiar")).toBeVisible();
expect(getByText("Eliminar")).toBeVisible();
});
const data = await get_data();
expect(data.value).toEqual(["bold", "italic"]);
});
});
test.todo(
"VISUAL: subdued text color applied when input text doesn't match any choice and allow_custom_value is false — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: show_options border/shadow styling changes when dropdown is open — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: token styling in multiselect mode — needs Playwright visual regression screenshot comparison"
);
test.todo(
"VISUAL: regression #12993 — dropdown options list repositions on page scroll — needs Playwright visual/integration test with scrolling"
);