import { resolve } from "node:path";
import { render, fireEvent, waitFor } from "@testing-library/preact";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CommandPalette } from "./CommandPalette.js";
// The canonical implementation had NO test file while every other strand-ui
// component carried one, and the Vue and Svelte ports were tested because they
// were written here. That gap is the direct reason a broken keyboard path
// reached npm: the component's only interaction model is "open and type", and
// nothing asserted that typing was possible.
const ITEMS = [
{ id: "a", label: "Alpha", sublabel: "first" },
{ id: "b", label: "Beta", badge: "Channel" },
{ id: "c", label: "Gamma" },
];
function setup(overrides = {}) {
const props = {
open: true,
onClose: vi.fn(),
items: ITEMS,
query: "",
onQueryChange: vi.fn(),
onSelect: vi.fn(),
...overrides,
};
const utils = render();
return { ...utils, props };
}
const combobox = () => document.querySelector("[role=combobox]") as HTMLInputElement;
const options = () => Array.from(document.querySelectorAll("[role=option]"));
describe("CommandPalette", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not render when closed", () => {
const { container } = setup({ open: false });
expect(container.innerHTML).toBe("");
});
// ── The defect this file exists for ──
it("puts the caret in the search field when it opens, so the visitor can type", async () => {
// Dialog focuses the first focusable element in its panel, which is its own
// close button. For this component that is fatal rather than merely
// suboptimal: the visitor opens a SEARCH overlay and their keystrokes go to
// a button. Measured in a real browser before the fix, activeElement was
// BUTTON.strand-dialog__close.
setup();
await waitFor(() => {
expect(document.activeElement).toBe(combobox());
});
});
it("does not steal focus while it is closed", async () => {
const anchor = document.createElement("button");
document.body.appendChild(anchor);
anchor.focus();
setup({ open: false });
await new Promise((r) => setTimeout(r, 20));
expect(document.activeElement).toBe(anchor);
anchor.remove();
});
// ── Roles and ARIA ──
it("exposes the combobox and listbox roles a screen reader navigates by", () => {
setup();
expect(combobox()).toBeTruthy();
expect(document.querySelector("[role=listbox]")).toBeTruthy();
expect(options()).toHaveLength(3);
});
it("points aria-activedescendant at the highlighted option, so focus can stay in the field", () => {
setup();
const target = options()[0].id;
expect(combobox().getAttribute("aria-activedescendant")).toBe(target);
});
it("omits aria-activedescendant entirely when there is nothing to point at", () => {
setup({ items: [] });
expect(combobox().getAttribute("aria-activedescendant")).toBeNull();
});
// ── Selection ──
it("moves the highlight down and wraps past the end", () => {
setup();
const input = combobox();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(options()[1].getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(options()[0].getAttribute("aria-selected")).toBe("true");
});
it("wraps backwards from the first item to the last", () => {
setup();
fireEvent.keyDown(combobox(), { key: "ArrowUp" });
expect(options()[2].getAttribute("aria-selected")).toBe("true");
});
it("Home and End jump to the ends", () => {
setup();
const input = combobox();
fireEvent.keyDown(input, { key: "End" });
expect(options()[2].getAttribute("aria-selected")).toBe("true");
fireEvent.keyDown(input, { key: "Home" });
expect(options()[0].getAttribute("aria-selected")).toBe("true");
});
it("Enter selects the highlighted item", () => {
const { props } = setup();
fireEvent.keyDown(combobox(), { key: "ArrowDown" });
fireEvent.keyDown(combobox(), { key: "Enter" });
expect(props.onSelect).toHaveBeenCalledWith(ITEMS[1]);
});
it("Enter on an empty result set does nothing rather than throwing", () => {
const { props } = setup({ items: [] });
expect(() => fireEvent.keyDown(combobox(), { key: "Enter" })).not.toThrow();
expect(props.onSelect).not.toHaveBeenCalled();
});
it("clicking an option selects it", () => {
const { props } = setup();
fireEvent.click(options()[2]);
expect(props.onSelect).toHaveBeenCalledWith(ITEMS[2]);
});
it("hovering moves the same highlight Enter acts on, so pointer and keyboard agree", () => {
const { props } = setup();
fireEvent.mouseMove(options()[2]);
fireEvent.keyDown(combobox(), { key: "Enter" });
expect(props.onSelect).toHaveBeenCalledWith(ITEMS[2]);
});
// ── Query ──
it("reports what the user types", () => {
const { props } = setup();
fireEvent.input(combobox(), { target: { value: "alp" } });
expect(props.onQueryChange).toHaveBeenCalledWith("alp");
});
it("resets the highlight when the result set changes, so Enter cannot select past the end", () => {
const { rerender, props } = setup();
fireEvent.keyDown(combobox(), { key: "End" });
expect(options()[2].getAttribute("aria-selected")).toBe("true");
rerender();
expect(options()[0].getAttribute("aria-selected")).toBe("true");
});
// ── Presentation ──
it("shows the empty label instead of a bare box when nothing matches", () => {
const { getByText } = setup({ items: [], emptyLabel: "Nothing matches that" });
expect(getByText("Nothing matches that")).toBeTruthy();
});
it("renders sublabel and badge only when supplied", () => {
const { container } = setup();
expect(container.querySelectorAll(".strand-command-palette__sublabel")).toHaveLength(1);
expect(container.querySelectorAll(".strand-command-palette__badge")).toHaveLength(1);
});
});
import { snapshotFixtures } from "../../test/snapshot.js";
import { snapshotStylesheet } from "../../test/stylesheet.js";
import { fixtures } from "./CommandPalette.fixtures.js";
snapshotFixtures(CommandPalette, fixtures);
snapshotStylesheet(resolve(__dirname, "./CommandPalette.css"));