import { fireEvent, render } from "@testing-library/preact";
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { SearchField } from "./SearchField.js";
// These assert MARKUP and BEHAVIOUR. The primitive's geometric claims -- that
// the box occupies its final size in the first frame, that the coarse-pointer
// floor reaches 44px -- are not evaluable here, because jsdom does not lay out
// and does not resolve media queries. Those live in scripts/layout-check.mjs in
// real Chromium. See docs/testing-tiers.md: an assertion placed in a tier that
// cannot evaluate it does not fail, it passes.
describe("SearchField", () => {
it("is a search landmark carrying a search input", () => {
// Both halves matter and they are different things. The landmark is how a
// screen reader user jumps to search; the input type is what gives the
// control its search semantics and its keyboard behaviour.
const { container } = render();
const field = container.querySelector(".strand-search-field");
expect(field?.getAttribute("role")).toBe("search");
expect(
container.querySelector(".strand-search-field__input")?.getAttribute("type"),
).toBe("search");
});
it("names the input even when the only visible affordance is a placeholder", () => {
// The defect this prevents: a placeholder is a hint, it vanishes on the
// first keystroke, and it is not an accessible name. A field labelled only
// by placeholder is announced as an unlabelled edit box.
const { container } = render();
const input = container.querySelector(".strand-search-field__input");
expect(input?.getAttribute("aria-label")).toBe("Search");
expect(input?.getAttribute("placeholder")).toBe("Search events");
});
it("takes an explicit label when the default is not specific enough", () => {
const { container } = render();
expect(
container.querySelector(".strand-search-field__input")?.getAttribute("aria-label"),
).toBe("Search events");
});
it("spans its container when asked", () => {
const { container } = render();
expect(
container.querySelector(".strand-search-field")?.classList.contains(
"strand-search-field--full",
),
).toBe(true);
});
it("reports every keystroke", () => {
const onValueChange = vi.fn();
const { container } = render();
const input = container.querySelector(
".strand-search-field__input",
) as HTMLInputElement;
fireEvent.input(input, { target: { value: "pottery" } });
expect(onValueChange).toHaveBeenCalledWith("pottery");
});
it("passes arbitrary attributes through to the input, not the wrapper", () => {
// The consumer's handle needs to be on the control they will read a value
// from and focus, so an id landing on the wrapper would make every
// `document.getElementById(...).focus()` a silent no-op.
const { container } = render();
const input = container.querySelector(".strand-search-field__input");
expect(input?.getAttribute("id")).toBe("q");
expect(input?.getAttribute("name")).toBe("query");
expect(container.querySelector(".strand-search-field")?.hasAttribute("id")).toBe(
false,
);
});
it("withholds the clear control when there is no value to read", () => {
// Uncontrolled: the component cannot know whether the field is empty, so a
// clear button would be a control that lies about having something to do.
const { container } = render( {}} />);
expect(container.querySelector(".strand-search-field__clear")).toBeNull();
});
it("hides the clear control while the field is empty", () => {
const { container } = render( {}} />);
const clear = container.querySelector(".strand-search-field__clear");
// Present but hidden, so showing it on the first keystroke costs no mount.
expect(clear).not.toBeNull();
expect(clear?.hasAttribute("hidden")).toBe(true);
});
it("shows and fires the clear control once there is a value", () => {
const onClear = vi.fn();
const { container } = render();
const clear = container.querySelector(
".strand-search-field__clear",
) as HTMLButtonElement;
expect(clear.hasAttribute("hidden")).toBe(false);
expect(clear.getAttribute("aria-label")).toBe("Clear search");
fireEvent.click(clear);
expect(onClear).toHaveBeenCalledTimes(1);
});
it("hides its icons from the accessibility tree", () => {
// Decorative: the landmark and the label already say what this is, so an
// exposed magnifier is one more thing announced and nothing more learned.
const { container } = render( {}} />);
for (const svg of container.querySelectorAll("svg")) {
expect(svg.getAttribute("aria-hidden")).toBe("true");
expect(svg.getAttribute("focusable")).toBe("false");
}
});
it("renders no structural wrapper a vanilla-HTML consumer could not reproduce", () => {
// The class layer is the primitive; the component is a wrapper over exactly
// those classes. An extra element here would break that parity silently.
const { container } = render( {}} />);
const field = container.querySelector(".strand-search-field");
expect(field?.parentElement).toBe(container);
expect([...(field?.children ?? [])].map((el) => el.tagName)).toEqual([
"svg",
"INPUT",
"BUTTON",
]);
});
});
import { snapshotFixtures } from "../../test/snapshot.js";
import { snapshotStylesheet } from "../../test/stylesheet.js";
import { fixtures } from "./SearchField.fixtures.js";
snapshotFixtures(SearchField, fixtures);
snapshotStylesheet(resolve(__dirname, "./SearchField.css"));