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
1829 lines
50 KiB
TypeScript
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"
|
|
);
|