diff --git a/js/dropdown/dropdown.test.ts b/js/dropdown/dropdown.test.ts index 5433deb44a..283f0469ea 100644 --- a/js/dropdown/dropdown.test.ts +++ b/js/dropdown/dropdown.test.ts @@ -1,523 +1,1373 @@ -import { - test, - describe, - assert, - afterEach, - vi, - beforeAll, - expect -} from "vitest"; -import { cleanup, render } from "@self/tootils/render"; +import { test, describe, afterEach, expect, vi } 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 } from "../core/src/i18n"; import Dropdown from "./Index.svelte"; -import type { LoadingStatus } from "@gradio/statustracker"; - -beforeAll(() => { - Element.prototype.animate = () => - ({ - finished: Promise.resolve(), - cancel: () => {}, - onfinish: null - }) as unknown as Animation; -}); -const loading_status: LoadingStatus = { - eta: 0, - queue_position: 1, - queue_size: 1, - status: "complete" as LoadingStatus["status"], - scroll_to_output: false, - visible: true, - fn_index: 0, - show_progress: "full" +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 }; -describe("Dropdown", () => { - afterEach(() => { - cleanup(); - vi.useRealTimers(); +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"); }); - beforeEach(() => { - setupi18n(); + + 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 provided value", async () => { + + test("renders empty input when value is null", async () => { const { getByLabelText } = await render(Dropdown, { - show_label: true, - loading_status, - max_choices: null, - value: "choice", - label: "Dropdown", - choices: [ - ["choice", "choice"], - ["choice2", "choice2"] - ], - filterable: false, - interactive: false + ...single_select_props, + value: null }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - assert.equal(item.value, "choice"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + expect(input.value).toBe(""); }); - test("selecting the textbox should show the options", async () => { - const { getByLabelText, getAllByTestId } = await render(Dropdown, { - show_label: true, - loading_status, - max_choices: 10, - value: "choice", - label: "Dropdown", - choices: [ - ["choice", "choice"], - ["name2", "choice2"] - ], - filterable: true, - interactive: true + test("renders empty input when value is undefined", async () => { + const { getByLabelText } = await render(Dropdown, { + ...single_select_props, + value: undefined }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + 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 + }); - await item.focus(); + const input = getByLabelText("Dropdown") as HTMLInputElement; + expect(input).toBeDisabled(); + }); - const options = getAllByTestId("dropdown-option"); + test("input is readonly when filterable is false", async () => { + const { getByLabelText } = await render(Dropdown, { + ...single_select_props, + filterable: false + }); - expect(options).toHaveLength(2); - expect(options[0]).toContainHTML("choice"); - expect(options[1]).toContainHTML("name2"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + expect(input).toHaveAttribute("readonly"); }); - test.skip("editing the textbox value should trigger the type event and filter the options", async () => { - const { getByLabelText, listen, getAllByTestId } = await render(Dropdown, { - show_label: true, - loading_status, - max_choices: 10, - value: "", - label: "Dropdown", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"] - ], - filterable: true, - interactive: true + test("input is not readonly when filterable is true", async () => { + const { getByLabelText } = await render(Dropdown, { + ...single_select_props, + filterable: true }); - const key_up_event = listen("key_up"); + 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 item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - await item.focus(); const options = getAllByTestId("dropdown-option"); + expect(options).toHaveLength(3); + }); - expect(options).toHaveLength(2); + test("options display names, not internal values", async () => { + const { getByLabelText, getAllByTestId } = await render(Dropdown, { + ...single_select_props, + choices: tuple_choices, + value: "apple_val" + }); - item.value = ""; - await event.keyboard("z"); + 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 options_new = getAllByTestId("dropdown-option"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - await expect(options_new).toHaveLength(1); - await expect(options[0]).toContainHTML("zebra"); - await assert.equal(key_up_event.callCount, 1); + 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.todo("blurring the textbox should cancel the filter", async () => { - // there is no assertion here - const { getByLabelText, listen } = await render(Dropdown, { - show_label: true, - loading_status, - value: "default", - label: "Dropdown", - max_choices: undefined, - choices: [ - ["default", "default"], - ["other", "other"] - ], - filterable: false, - interactive: true + test("options not shown when interactive is false", async () => { + const { getByLabelText, queryAllByTestId } = await render(Dropdown, { + ...single_select_props, + interactive: false }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - item.focus(); - await event.keyboard("other"); + const options = queryAllByTestId("dropdown-option"); + expect(options).toHaveLength(0); }); - test.skip("blurring the textbox should save the input value", async () => { - const { getByLabelText, listen } = await render(Dropdown, { - show_label: true, - loading_status, - value: "new ", - label: "Dropdown", - max_choices: undefined, - allow_custom_value: true, - choices: [ - ["dwight", "dwight"], - ["michael", "michael"] - ], - filterable: true, - interactive: true + 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); }); + }); +}); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - const change_event = listen("change"); +describe("Single-select: Filtering", () => { + afterEach(() => cleanup()); - item.focus(); - await event.keyboard("kevin"); - await item.blur(); + test("typing filters options case-insensitively", async () => { + const { getByLabelText, getAllByTestId } = await render(Dropdown, { + ...single_select_props, + value: null + }); - assert.equal(item.value, "new kevin"); - assert.equal(change_event.callCount, 1); + 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.skip("focusing the label should toggle the options", async () => { - const { getByLabelText, listen } = await render(Dropdown, { - show_label: true, - loading_status, - value: "default", - label: "Dropdown", - choices: [ - ["default", "default"], - ["other", "other"] - ], - filterable: true, - interactive: true + test("blur and re-focus resets filter to show all options", async () => { + const { getByLabelText, getAllByTestId } = await render(Dropdown, { + ...single_select_props, + value: "apple" }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - const blur_event = listen("blur"); - const focus_event = listen("focus"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + await event.keyboard("z"); - item.focus(); - item.blur(); + await input.blur(); + await input.focus(); - assert.equal(blur_event.callCount, 1); - assert.equal(focus_event.callCount, 1); + const options = getAllByTestId("dropdown-option"); + expect(options).toHaveLength(3); }); - test("deselecting and reselcting a filtered dropdown should show all options again", async () => { + test("partial match filtering works", async () => { const { getByLabelText, getAllByTestId } = await render(Dropdown, { - show_label: true, - loading_status, - max_choices: 10, - value: "", - label: "Dropdown", + ...single_select_props, + value: null, choices: [ + ["pineapple", "pineapple"], ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ], - filterable: true, - interactive: true + ["grape", "grape"] + ] as [string, string | number][] }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + await event.keyboard("apple"); - item.focus(); - item.value = ""; - await event.keyboard("z"); const options = getAllByTestId("dropdown-option"); + expect(options).toHaveLength(2); + }); +}); - expect(options).toHaveLength(1); +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 + ); - await item.blur(); - // Mock 100ms delay between interactions. - await item.focus(); - const options_new = getAllByTestId("dropdown-option"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + + const options = getAllByTestId("dropdown-option"); + await event.click(options[2]); - expect(options_new).toHaveLength(3); + const data = await get_data(); + expect(data.value).toBe("cherry"); }); - test.skip("passing in a new set of identical choices when the dropdown is open should not filter the dropdown", async () => { - // TODO: Fix this test, the test requires prop update using $set which is deprecated in Svelte 5. - const { getByLabelText, getAllByTestId, component } = await render( + test("clicking an option with tuple choices returns internal value via get_data", async () => { + const { getByLabelText, getAllByTestId, get_data } = await render( Dropdown, { - show_label: true, - loading_status, - value: "", - label: "Dropdown", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ], - filterable: true, - interactive: true + ...single_select_props, + choices: tuple_choices, + value: "apple_val" } ); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - - await item.focus(); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); const options = getAllByTestId("dropdown-option"); + await event.click(options[1]); - expect(options).toHaveLength(3); + expect(input.value).toBe("Banana Display"); + const data = await get_data(); + expect(data.value).toBe("banana_val"); + }); - component.$set({ - value: "", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ] + test("arrow down then Enter selects first option", async () => { + const { getByLabelText } = await render(Dropdown, { + ...single_select_props, + value: null }); - item.focus(); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - const options_new = getAllByTestId("dropdown-option"); - expect(options_new).toHaveLength(3); + await event.keyboard("{ArrowDown}"); + await event.keyboard("{Enter}"); + + expect(input.value).toBe("apple"); }); - test("setting a custom value when allow_custom_choice is false should revert to the first valid choice", async () => { - const { getByLabelText, getAllByTestId, component } = await render( + test("selecting a new option replaces the previous one", async () => { + const { getByLabelText, getAllByTestId } = await render( Dropdown, - { - show_label: true, - loading_status, - value: "apple", - allow_custom_value: false, - label: "Dropdown", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ], - filterable: true, - interactive: true - } + single_select_props ); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - await item.focus(); - await event.keyboard("pie"); - expect(item.value).toBe("applepie"); - await item.blur(); - expect(item.value).toBe("apple"); + 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("setting a custom value when allow_custom_choice is true should keep the value", async () => { - const { getByLabelText, getAllByTestId, component } = await render( + test("clicking option updates selected marker", async () => { + const { getByLabelText, getAllByTestId } = await render( Dropdown, - { - show_label: true, - loading_status, - value: "apple", - allow_custom_value: true, - label: "Dropdown", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ], - filterable: true, - interactive: true - } + single_select_props ); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + 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 item.focus(); - await event.keyboard("pie"); - expect(item.value).toBe("applepie"); - await item.blur(); - expect(item.value).toBe("applepie"); + await event.click(options[1]); + expect(input.value).toBe("banana"); + await input.focus(); + options = getAllByTestId("dropdown-option"); + expect(options[1]).toHaveClass("selected"); }); +}); - test("setting a value should update the displayed value and selected indices", async () => { - const { getByLabelText, getAllByTestId } = await render(Dropdown, { - show_label: true, - loading_status, - value: "apple", +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, - label: "Dropdown", - choices: [ - ["apple", "apple"], - ["zebra", "zebra"], - ["pony", "pony"] - ], - filterable: true, - interactive: true + value: "apple" }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + await event.keyboard("pie"); + expect(input.value).toBe("applepie"); - expect(item.value).toBe("apple"); - await item.focus(); - let options = getAllByTestId("dropdown-option"); - expect(options[0]).toHaveClass("selected"); + await input.blur(); + expect(input.value).toBe("apple"); + }); - await event.click(options[1]); - expect(item.value).toBe("zebra"); - await item.focus(); - options = getAllByTestId("dropdown-option"); - expect(options[1]).toHaveClass("selected"); + 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("blurring a dropdown should set the input text to the previously selected value", async () => { - const { getByLabelText, getAllByTestId, component } = await render( + 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, { - show_label: true, - loading_status, - value: "apple_internal_value", - allow_custom_value: false, - label: "Dropdown", + ...single_select_props, choices: [ - ["apple", "apple_internal_value"], - ["zebra", "zebra_internal_value"], - ["pony", "pony_internal_value"] - ], - filterable: true, - interactive: true + ["hello", "goodbye"], + ["abc", "123"] + ] as [string, string | number][], + value: null, + allow_custom_value: true } ); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); - expect(item.value).toBe("apple"); - await item.focus(); - let options = getAllByTestId("dropdown-option"); - expect(options[0]).toHaveClass("selected"); - await item.blur(); - expect(item.value).toBe("apple"); + const options = getAllByTestId("dropdown-option"); + await event.click(options[0]); - await item.focus(); - await event.keyboard("z"); - expect(item.value).toBe("applez"); - await item.blur(); - expect(item.value).toBe("apple"); + expect(input.value).toBe("hello"); + const data = await get_data(); + expect(data.value).toBe("goodbye"); }); - test.skip("updating choices should keep the dropdown focus-able and change the value appropriately if custom values are not allowed", async () => { - // TODO: Fix this test, the test requires prop update using $set which is deprecated in Svelte 5. - const { getByLabelText, component } = await render(Dropdown, { - show_label: true, - loading_status, - value: "apple_internal_value", - allow_custom_value: false, - label: "Dropdown", + 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: [ - ["apple_choice", "apple_internal_value"], - ["zebra_choice", "zebra_internal_value"] - ], - filterable: true, - interactive: true + ["hello", "goodbye"], + ["abc", "123"] + ] as [string, string | number][], + value: null, + allow_custom_value: true }); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + await event.keyboard("hello"); + await input.blur(); - await expect(item.value).toBe("apple_choice"); + const data = await get_data(); + expect(data.value).toBe("goodbye"); + }); - component.$set({ - choices: [ - ["apple_new_choice", "apple_internal_value"], - ["zebra_new_choice", "zebra_internal_value"] - ] + 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 }); - await item.focus(); - await item.blur(); - await expect(item.value).toBe("apple_new_choice"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + expect(input.value).toBe(""); }); +}); - test.skip("updating choices should not reset the value if custom values are allowed", async () => { - // TODO: Fix this test, the test requires prop update using $set which is deprecated in Svelte 5. - const { getByLabelText, component } = await render(Dropdown, { - show_label: true, - loading_status, - value: "apple_internal_value", - allow_custom_value: true, - label: "Dropdown", - choices: [ - ["apple_choice", "apple_internal_value"], - ["zebra_choice", "zebra_internal_value"] - ], - filterable: true, - interactive: true - }); +describe("Single-select: Events", () => { + afterEach(() => cleanup()); - const item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; + test("no spurious change event on mount", async () => { + const { listen } = await render(Dropdown, single_select_props); - await expect(item.value).toBe("apple_choice"); + const change = listen("change", { retrospective: true }); + expect(change).not.toHaveBeenCalled(); + }); - component.$set({ - choices: [ - ["apple_new_choice", "apple_internal_value"], - ["zebra_new_choice", "zebra_internal_value"] - ] + 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 + ); - await expect(item.value).toBe("apple_choice"); + const focus = listen("focus"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + + expect(focus).toHaveBeenCalledTimes(1); }); - test("ensure dropdown can have the first item of the choices as a default value", async () => { - const { getByLabelText } = await render(Dropdown, { - show_label: true, - loading_status, - allow_custom_value: false, - value: "apple_internal_value", - label: "Dropdown", - choices: [ - ["apple_choice", "apple_internal_value"], - ["zebra_choice", "zebra_internal_value"] - ], - filterable: true, - interactive: true + 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 item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - await expect(item.value).toBe("apple_choice"); + + const key_up = listen("key_up"); + const input = getByLabelText("Dropdown") as HTMLInputElement; + await input.focus(); + await event.keyboard("a"); + + expect(key_up).toHaveBeenCalled(); + const call_args = key_up.mock.calls[0][0]; + expect(call_args.key).toBe("a"); + expect(call_args.input_value).toBe("a"); }); - test("ensure dropdown works when initial value is undefined and allow_custom_value is true", async () => { - const { getByLabelText } = await render(Dropdown, { - show_label: true, - loading_status, - value: undefined, - allow_custom_value: true, - label: "Dropdown", - choices: [ - ["apple_choice", "apple_internal_value"], - ["zebra_choice", "zebra_internal_value"] - ], - filterable: true, - interactive: true + 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 item: HTMLInputElement = getByLabelText( - "Dropdown" - ) as HTMLInputElement; - await expect(item.value).toBe(""); + + 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"); + + const last_call = key_up.mock.calls[key_up.mock.calls.length - 1][0]; + expect(last_call.key).toBe("4"); + expect(last_call.input_value).toBe("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("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 { container } = await render(Dropdown, { + ...multiselect_props, + value: ["apple", "cherry"] + }); + + const tokens = container.querySelectorAll(".token"); + expect(tokens).toHaveLength(2); + expect(tokens[0].textContent).toContain("apple"); + expect(tokens[1].textContent).toContain("cherry"); + }); + + test("renders display names for tuple choices in tokens", async () => { + const { container } = await render(Dropdown, { + ...multiselect_props, + choices: tuple_choices, + value: ["apple_val", "cherry_val"] + }); + + const tokens = container.querySelectorAll(".token"); + expect(tokens).toHaveLength(2); + expect(tokens[0].textContent).toContain("Apple Display"); + expect(tokens[1].textContent).toContain("Cherry Display"); + }); + + 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: 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("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"); + }); +}); + +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" +);