import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { Lookup } from "@/components/ui/Lookup"; type Person = { id: string; name: string }; const PEOPLE: Person[] = [ { id: "1", name: "Katharina Aigner" }, { id: "2", name: "Kurt Aigner" }, { id: "3", name: "Simon Aigner" }, ]; function renderLookup(onSelect = vi.fn()) { const onSearch = vi.fn(async (q: string) => PEOPLE.filter((p) => p.name.toLowerCase().includes(q.toLowerCase()))); render( onSearch={onSearch} onSelect={onSelect} renderResult={(p) => {p.name}} placeholder="Suchen…" />); return { onSelect, input: screen.getByRole("combobox") }; } // The component was a text input with a div of clickable buttons under it: // typeable, but no keyboard path to a result and nothing announcing that a // list had appeared. describe("Lookup keyboard operation", () => { it("exposes combobox semantics", async () => { const { input } = renderLookup(); expect(input).toHaveAttribute("aria-expanded", "false"); await userEvent.type(input, "Aigner"); expect(await screen.findByRole("listbox")).toBeInTheDocument(); expect(input).toHaveAttribute("aria-expanded", "true"); expect(await screen.findAllByRole("option")).toHaveLength(3); }); it("moves the selection with the arrow keys", async () => { const { input } = renderLookup(); await userEvent.type(input, "Aigner"); await screen.findByRole("listbox"); const options = screen.getAllByRole("option"); expect(options[0]).toHaveAttribute("aria-selected", "true"); await userEvent.keyboard("{ArrowDown}"); expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true"); // aria-activedescendant is what tells a screen reader which row is // current while focus stays in the input. expect(input).toHaveAttribute("aria-activedescendant", screen.getAllByRole("option")[1].id); }); it("wraps around at the ends", async () => { const { input } = renderLookup(); await userEvent.type(input, "Aigner"); await screen.findByRole("listbox"); await userEvent.keyboard("{ArrowUp}"); expect(screen.getAllByRole("option")[2]).toHaveAttribute("aria-selected", "true"); await userEvent.keyboard("{ArrowDown}"); expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true"); }); it("selects the highlighted result with Enter", async () => { const { input, onSelect } = renderLookup(); await userEvent.type(input, "Aigner"); await screen.findByRole("listbox"); await userEvent.keyboard("{ArrowDown}{Enter}"); expect(onSelect).toHaveBeenCalledWith(PEOPLE[1]); // Clears itself afterwards, ready for the next search. expect(input).toHaveValue(""); }); it("closes on Escape without selecting", async () => { const { input, onSelect } = renderLookup(); await userEvent.type(input, "Aigner"); await screen.findByRole("listbox"); await userEvent.keyboard("{Escape}"); expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); expect(onSelect).not.toHaveBeenCalled(); }); it("does not open below the minimum query length", async () => { const { input } = renderLookup(); await userEvent.type(input, "A"); expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); it("still selects on click", async () => { const { input, onSelect } = renderLookup(); await userEvent.type(input, "Kurt"); await screen.findByRole("listbox"); await userEvent.click(screen.getByRole("option", { name: "Kurt Aigner" })); expect(onSelect).toHaveBeenCalledWith(PEOPLE[1]); }); });