import { cleanup, render, screen, waitFor, act, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createRef } from "react"; import assert from "node:assert"; import { afterEach, describe, mock, test, } from "node:test"; import { Select } from "./index"; import type { ISelectRef, TOptionType } from "./index"; const options: TOptionType[] = [ { label: "Option A", value: "a" }, { label: "Option B", value: "b" }, { label: "Option C", value: "c" }, ]; const selectors = { hiddenInput: "input[data-js-component-input='select']", clearIndicator: ".formSelect__indicator--isClear", }; const waitCfg = { timeout: 1000 }; const getHiddenInputs = (container: HTMLElement): HTMLInputElement[] => { return Array.from(container.querySelectorAll(selectors.hiddenInput)) as HTMLInputElement[]; }; const getHiddenValues = (container: HTMLElement): string[] => { return getHiddenInputs(container).map((item) => item.value); }; // Helper to add a timeout to a promise (safety net) const withTimeout = (promise: Promise, ms: number, errorMessage: string): Promise => { return Promise.race([ promise, new Promise((resolve, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)), ]); }; // Custom touch simulation that creates a TouchList-like object with .item() method const touchTap = (el: HTMLElement): void => { const windowRef = el.ownerDocument.defaultView; assert.notStrictEqual(windowRef, null); const point = { clientX: 10, clientY: 10 }; const touchList = { 0: point, length: 1, item: (index: number) => (index === 0 ? point : null), }; // @ts-expect-error (mock) const touchStartEvent = new (windowRef as Window).Event("touchstart", { bubbles: true, cancelable: true }); Object.defineProperty(touchStartEvent, "touches", { value: touchList }); Object.defineProperty(touchStartEvent, "changedTouches", { value: touchList }); Object.defineProperty(touchStartEvent, "targetTouches", { value: touchList }); // @ts-expect-error (mock) const touchEndEvent = new (windowRef as Window).Event("touchend", { bubbles: true, cancelable: true }); Object.defineProperty(touchEndEvent, "changedTouches", { value: touchList }); el.dispatchEvent(touchStartEvent); el.dispatchEvent(touchEndEvent); }; describe("Select", () => { afterEach(() => { cleanup(); }); test("renders without crashing", () => { const { container } = render(); assert.notStrictEqual(screen.queryByText("Option A"), null); rerender( ); await user.click(screen.getByRole("combobox")); await user.click(await screen.findByText("Option B")); await waitFor(() => { const values = getHiddenValues(container); assert.strictEqual(values.includes("b"), true); }, waitCfg); const nextOptions = options.map((item) => ({ ...item })); rerender( ); const control = container.querySelector(".formSelect__control") as HTMLElement; assert.notStrictEqual(control, null); // Wrap touch simulation in act to ensure state updates are processed await act(async () => { touchTap(control); }); // Wait for menu to appear with a timeout safety await withTimeout( screen.findByRole("listbox"), 2000, "Menu did not open after touch interaction (timeout)" ); }); test("does not close immediately after touch open", async () => { const { container } = render( ); const control = container.querySelector(".formSelect__control") as HTMLElement; assert.notStrictEqual(control, null); await act(async () => { touchTap(control); }); await withTimeout( screen.findByRole("listbox"), 2000, "Menu did not open after touch (timeout)" ); const input = screen.getByRole("combobox") as HTMLInputElement; input.dispatchEvent(new FocusEvent("blur", { bubbles: true })); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 250)); }); assert.notStrictEqual(screen.queryByRole("listbox"), null); }); test("does not close immediately after open for searchable async select", async () => { const user = userEvent.setup(); render( ); await user.type(screen.getByRole("combobox"), "a"); assert.notStrictEqual(await screen.findByText("Async option"), null); assert.strictEqual(requestFn.mock.callCount(), 1); }); test("calls onChange when user selects option", async () => { const user = userEvent.setup(); const onChange = mock.fn(); render( { if (type === "close") { const [ selectedValue ] = (value || []) as TOptionType[]; if (selectedValue) { currentOptions.unshift(selectedValue); } const sorted = Array.from(new Set(currentOptions)); closeResultValues = sorted.map((item) => item?.value || ""); return sorted; } return currentOptions; }} /> ); await user.click(screen.getByRole("combobox")); await user.click(document.body); await waitFor(() => { assert.strictEqual(screen.queryByRole("listbox"), null); assert.notStrictEqual(closeResultValues, null); }, waitCfg); await act(async () => { await new Promise((resolve) => setTimeout(resolve, 350)); }); await user.click(screen.getByRole("combobox")); await screen.findByRole("listbox"); const renderedOptions = screen.getAllByRole("option"); const renderedLabels = renderedOptions.map((item) => item.textContent?.trim() || ""); assert.deepStrictEqual(closeResultValues, [ "b", "a", "c" ]); assert.strictEqual(renderedLabels.length, 3); assert.deepStrictEqual(new Set(renderedLabels).size, renderedLabels.length); }); test("keeps selected value for isMulti with getOptionsModify close-sort", async () => { const user = userEvent.setup(); const customOptions: TOptionType[] = [ { label: "Option 1", value: "option1" }, { label: "Option 2", value: "option2" }, { label: "Option 3", value: "option3" }, ]; const { container } = render( { if (type !== "close") { return currentOptions; } const selected = Array.isArray(value) ? value[0] : value; if (!selected?.value) { return currentOptions; } const rest = currentOptions.filter((item) => item?.value !== selected.value); const sorted = [ selected, ...rest ]; closeResultValues = sorted.map((item) => item?.value || ""); return sorted; }} /> ); await user.click(screen.getByRole("combobox")); await user.click(await screen.findByText("Option 1")); await user.click(await screen.findByText("Option 2")); await user.click(document.body); await waitFor(() => { const values = getHiddenValues(container); assert.strictEqual(values.includes("option1"), true); assert.strictEqual(values.includes("option2"), true); assert.notStrictEqual(closeResultValues, null); assert.strictEqual(closeResultValues?.[0], "option2"); }, waitCfg); }); test("clears selected value by clear indicator when isClearable=true", async () => { const user = userEvent.setup(); const { container } = render( ); await user.click(screen.getByRole("combobox")); await user.click(await screen.findByText("Option B")); await waitFor(() => { const values = getHiddenValues(container); assert.strictEqual(values.includes("b"), true); }, waitCfg); const clearIndicator = container.querySelector(selectors.clearIndicator) as HTMLElement; assert.notStrictEqual(clearIndicator, null); await user.click(clearIndicator); await waitFor(() => { const values = getHiddenValues(container); assert.strictEqual(values.includes(""), true); }, waitCfg); assert.strictEqual(onChange.mock.calls.length > 0, true); }); test("does not restore previous value after clear when value prop is static", async () => { const user = userEvent.setup(); const onChange = mock.fn(); const { container } = render( ); await user.click(screen.getByRole("combobox")); await user.click(await screen.findByText("Option B")); await waitFor(() => { assert.notStrictEqual(screen.queryByRole("listbox"), null); }, waitCfg); await user.click(await screen.findByText("Option C")); await waitFor(() => { const values = getHiddenValues(container); assert.strictEqual(values.includes("b"), true); assert.strictEqual(values.includes("c"), true); assert.notStrictEqual(screen.queryByRole("listbox"), null); }, waitCfg); }); test("closes menu by outside click when isMulti=true and value already selected", async () => { const user = userEvent.setup(); const onMenuToggle = mock.fn(); const { container } = render( ); await waitFor(() => { assert.notStrictEqual(screen.queryByRole("listbox"), null); assert.strictEqual( onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "open"), true ); }, waitCfg); await user.click(document.body); await waitFor(() => { assert.strictEqual(screen.queryByRole("listbox"), null); assert.strictEqual( onMenuToggle.mock.calls.some((call) => call.arguments?.[0] === "close"), true ); }, waitCfg); }); test("handles backspace according to backspaceRemovesValue prop", async () => { const user = userEvent.setup(); const onChangeFalse = mock.fn(); const { rerender } = render( ); await user.click(screen.getByRole("combobox")); await user.keyboard("{Backspace}"); assert.strictEqual(onChangeTrue.mock.calls.length > 0, true); }); test("calls onMenuToggle('open') for initially opened select", async () => { const onMenuToggle = mock.fn(); render( ); const hiddenInput = container.querySelector(selectors.hiddenInput) as HTMLInputElement; assert.notStrictEqual(hiddenInput, null); assert.strictEqual(hiddenInput.disabled, false); assert.strictEqual(hiddenInput.getAttribute("data-js-input-required"), null); rerender( ); await waitFor(() => { const root = container.querySelector(".formSelectA") as HTMLElement; assert.notStrictEqual(root, null); assert.strictEqual(root.classList.contains("formSelectA--isRequired"), true); assert.strictEqual(root.classList.contains("formSelectA--isDisabled"), true); }, waitCfg); rerender(