import React from "react"
import { render, screen, act, cleanup, waitFor, within, fireEvent } from "@/utils/test-utils"
import { SearchableSelect, SearchableSelectOption } from "@/components/ui/searchable-select"
import userEvent from "@testing-library/user-event"
describe("SearchableSelect", () => {
const mockOptions: SearchableSelectOption[] = [
{ value: "option1", label: "Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3", disabled: true },
]
const defaultProps = {
options: mockOptions,
placeholder: "Select an option",
searchPlaceholder: "Search options...",
emptyMessage: "No options found",
onValueChange: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it("renders with placeholder when no value is selected", () => {
render()
expect(screen.getByText("Select an option")).toBeInTheDocument()
})
it("renders with selected option label when value is provided", () => {
render()
expect(screen.getByText("Option 1")).toBeInTheDocument()
})
it("opens dropdown when clicked", async () => {
const user = userEvent.setup()
render()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
expect(screen.getByPlaceholderText("Search options...")).toBeInTheDocument()
expect(screen.getByText("Option 1")).toBeInTheDocument()
expect(screen.getByText("Option 2")).toBeInTheDocument()
})
it("filters options based on search input", async () => {
const user = userEvent.setup()
render()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Verify all options are initially visible
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument()
expect(screen.getByText("Option 2")).toBeInTheDocument()
expect(screen.getByText("Option 3")).toBeInTheDocument()
})
const searchInput = screen.getByPlaceholderText("Search options...")
// Use fireEvent for cmdk input
fireEvent.change(searchInput, { target: { value: "1" } })
// Wait for the filtering to take effect
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument()
expect(screen.queryByText("Option 2")).not.toBeInTheDocument()
expect(screen.queryByText("Option 3")).not.toBeInTheDocument()
})
})
it("calls onValueChange when an option is selected", async () => {
const user = userEvent.setup()
render()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
const option = screen.getByText("Option 2")
await user.click(option)
expect(defaultProps.onValueChange).toHaveBeenCalledWith("option2")
})
it("does not call onValueChange when a disabled option is clicked", async () => {
const user = userEvent.setup()
render()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
const disabledOption = screen.getByText("Option 3")
await user.click(disabledOption)
expect(defaultProps.onValueChange).not.toHaveBeenCalled()
})
it("clears search value when dropdown is closed", async () => {
const user = userEvent.setup()
render()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
const searchInput = screen.getByPlaceholderText("Search options...")
// Use fireEvent for cmdk input
fireEvent.change(searchInput, { target: { value: "test" } })
// Verify the search filters the options
await waitFor(() => {
expect(screen.queryByText("Option 1")).not.toBeInTheDocument()
expect(screen.queryByText("Option 2")).not.toBeInTheDocument()
})
// Close the dropdown by clicking outside
await user.click(document.body)
// Wait for dropdown to close
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument()
})
// Wait a bit for the timeout to clear search
await new Promise((resolve) => setTimeout(resolve, 200))
// Open again to check if search was cleared
await user.click(trigger)
// All options should be visible again
await waitFor(() => {
expect(screen.getByText("Option 1")).toBeInTheDocument()
expect(screen.getByText("Option 2")).toBeInTheDocument()
expect(screen.getByText("Option 3")).toBeInTheDocument()
})
})
it("handles component unmounting without memory leaks", async () => {
vi.useFakeTimers()
const { unmount, rerender } = render()
// Change the value prop to trigger the effect with timeout
rerender()
// Immediately unmount the component before the timeout completes
act(() => {
unmount()
})
// This test ensures that no setState calls happen after unmount
// If there was a memory leak, this would throw an error
expect(() => {
// Wait for any pending timeouts
act(() => {
vi.runAllTimers()
})
}).not.toThrow()
})
it("cleans up timeouts on unmount", () => {
vi.useFakeTimers()
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout")
const { unmount } = render()
act(() => {
unmount()
})
// Verify that clearTimeout was called during cleanup
expect(clearTimeoutSpy).toHaveBeenCalled()
clearTimeoutSpy.mockRestore()
})
it("resets search value when value prop changes", async () => {
const user = userEvent.setup()
const { rerender } = render()
// Open dropdown and type something
const trigger = screen.getByRole("combobox")
await user.click(trigger)
const searchInput = screen.getByPlaceholderText("Search options...")
fireEvent.change(searchInput, { target: { value: "2" } })
// Verify search is working - use within to scope to dropdown
const dropdown = screen.getByRole("dialog")
await waitFor(() => {
expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument()
expect(within(dropdown).getByText("Option 2")).toBeInTheDocument()
})
// Close dropdown
await user.click(document.body)
// Change the value prop
rerender()
// Wait for the component to update
await waitFor(() => {
expect(screen.getByRole("combobox")).toHaveTextContent("Option 2")
})
// Wait for the effect to run (100ms timeout in component)
await new Promise((resolve) => setTimeout(resolve, 150))
// Open dropdown again
await user.click(trigger)
// All options should be visible (search cleared) - use within to scope
const newDropdown = screen.getByRole("dialog")
await waitFor(() => {
expect(within(newDropdown).getByText("Option 1")).toBeInTheDocument()
expect(within(newDropdown).getByText("Option 2")).toBeInTheDocument()
expect(within(newDropdown).getByText("Option 3")).toBeInTheDocument()
})
})
it("handles rapid value changes without issues", async () => {
const { rerender } = render()
// Rapidly change values
rerender()
rerender()
rerender()
// Wait for the final value to be reflected
await waitFor(() => {
const trigger = screen.getByRole("combobox")
expect(trigger).toHaveTextContent("Option 1")
})
// Component should still be functional - open dropdown
const user = userEvent.setup()
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Should be able to search
const searchInput = screen.getByPlaceholderText("Search options...")
fireEvent.change(searchInput, { target: { value: "2" } })
// Check filtering works - use within to scope to dropdown
const dropdown = screen.getByRole("dialog")
await waitFor(() => {
expect(within(dropdown).getByText("Option 2")).toBeInTheDocument()
expect(within(dropdown).queryByText("Option 1")).not.toBeInTheDocument()
expect(within(dropdown).queryByText("Option 3")).not.toBeInTheDocument()
})
})
it("closes the dropdown when ESC key is pressed", async () => {
const user = userEvent.setup()
render()
// Open the dropdown
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Verify dropdown is open
expect(trigger).toHaveAttribute("aria-expanded", "true")
expect(screen.getByRole("listbox")).toBeInTheDocument()
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify dropdown is closed
await waitFor(() => {
expect(trigger).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
})
it("does not close the dropdown when ESC is pressed while dropdown is closed", async () => {
render()
const trigger = screen.getByRole("combobox")
// Ensure dropdown is closed
expect(trigger).toHaveAttribute("aria-expanded", "false")
// Press ESC key
fireEvent.keyDown(window, { key: "Escape" })
// Verify dropdown remains closed
expect(trigger).toHaveAttribute("aria-expanded", "false")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("prevents default and stops propagation when ESC is pressed", async () => {
const user = userEvent.setup()
render()
// Open the dropdown
const trigger = screen.getByRole("combobox")
await user.click(trigger)
// Create a mock event to track preventDefault and stopPropagation
const escapeEvent = new KeyboardEvent("keydown", {
key: "Escape",
bubbles: true,
cancelable: true,
})
const preventDefaultSpy = vi.spyOn(escapeEvent, "preventDefault")
const stopPropagationSpy = vi.spyOn(escapeEvent, "stopPropagation")
// Dispatch the event
window.dispatchEvent(escapeEvent)
// Verify preventDefault and stopPropagation were called
expect(preventDefaultSpy).toHaveBeenCalled()
expect(stopPropagationSpy).toHaveBeenCalled()
})
})