import { fireEvent, render } from "@testing-library/preact"; import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { SearchTrigger } from "./SearchTrigger.js"; // The GEOMETRY -- that this is pixel-identical to SearchField at rest, and // that the coarse-pointer floor reaches 44px -- belongs to the layout tier in // real Chromium. jsdom neither lays out nor resolves media queries. // // What this file pins is the SEMANTIC contract, which is the entire reason // this is a separate primitive rather than a variant of SearchField. Every // assertion below fails if someone "simplifies" it back into an input. describe("SearchTrigger", () => { it("is a button, never an input", () => { // WCAG 3.2.1 (On Focus): a control that changes context when focused is a // failure. An input that opens an overlay on focus does exactly that, and // a keyboard user tabbing through the header would be thrown into it. const { container } = render(); const el = container.querySelector(".strand-search-trigger"); expect(el?.tagName).toBe("BUTTON"); expect(el?.getAttribute("type")).toBe("button"); expect(container.querySelector("input")).toBeNull(); }); it("announces that it opens something rather than that it accepts text", () => { const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.getAttribute("aria-haspopup"), ).toBe("dialog"); }); it("does not claim the search landmark role", () => { // role="search" around a text input promises assistive technology that // typing works here. Wiring that to "your keystrokes go to an overlay" // makes the promise false at the AT layer, which is worse than a plainly // labelled button. The landmark belongs to whatever the overlay renders. const { container } = render(); expect(container.querySelector('[role="search"]')).toBeNull(); }); // WCAG 2.5.3 Label in Name: a speech-input user says what they can see. If // the accessible name were "Search" while the visible text read "Search // events", saying the visible words would not activate the control. it("uses its visible text as its accessible name, with no aria-label override", () => { const { container } = render(); const el = container.querySelector(".strand-search-trigger"); expect(el?.textContent).toContain("Search events"); expect(el?.hasAttribute("aria-label")).toBe(false); }); it("defaults to the fixed-width presentation", () => { const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.classList.contains( "strand-search-field--full", ), ).toBe(false); }); it("spans its container when asked", () => { const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.classList.contains( "strand-search-field--full", ), ).toBe(true); }); it("reports the overlay's state when the consumer drives it", () => { const { container } = render(); const el = container.querySelector(".strand-search-trigger"); expect(el?.getAttribute("aria-expanded")).toBe("true"); expect(el?.getAttribute("aria-controls")).toBe("palette"); }); it("omits aria-expanded entirely when no overlay state is supplied", () => { // A trigger with no overlay mounted must not assert collapsed, which // would announce a widget state that does not exist. const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.hasAttribute("aria-expanded"), ).toBe(false); }); it("activates on click", () => { const onClick = vi.fn(); const { container } = render(); fireEvent.click(container.querySelector(".strand-search-trigger") as HTMLElement); expect(onClick).toHaveBeenCalledTimes(1); }); it("hides its icon from the accessibility tree", () => { const { container } = render(); const svg = container.querySelector("svg"); expect(svg?.getAttribute("aria-hidden")).toBe("true"); expect(svg?.getAttribute("focusable")).toBe("false"); }); // ── The icon presentation ── // // Its geometry (44px square, centred glyph) is a layout-tier claim and lives // in scripts/layout-check.mjs, per the note at the top of this file. What is // assertable here is the part that is an ACCESSIBILITY contract rather than a // visual one, and it is the part most likely to be "simplified" away. it("takes the icon presentation without becoming a different control", () => { // The whole reason this is a variant rather than a Button: an icon-only // Button is not a SearchTrigger, so it drops the popup semantics and the // shared field identity. Both must survive the variant. const { container } = render(); const el = container.querySelector(".strand-search-trigger"); expect(el?.classList.contains("strand-search-trigger--icon")).toBe(true); expect(el?.classList.contains("strand-search-field")).toBe(true); expect(el?.getAttribute("aria-haspopup")).toBe("dialog"); expect(el?.tagName).toBe("BUTTON"); }); it("keeps its accessible name when the label is visually hidden", () => { // THE DEFECT THIS PINS: `display: none` on the label would take the // control's accessible name with it and leave a button that announces // nothing. The label is clipped instead, so it stays in the accessibility // tree and a speech-input user can still say what the field would have // read. WCAG 2.5.3, Label in Name. const { container } = render(); const el = container.querySelector(".strand-search-trigger"); expect(el?.textContent).toContain("Search events"); expect(el?.hasAttribute("aria-label")).toBe(false); expect(container.querySelector(".strand-search-trigger__label")).not.toBeNull(); }); it("is not full-width, which would defeat a square icon", () => { const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.classList.contains( "strand-search-field--full", ), ).toBe(false); }); it("emits no icon modifier for the other two presentations", () => { for (const variant of ["field", "full"] as const) { const { container } = render(); expect( container.querySelector(".strand-search-trigger")?.classList.contains( "strand-search-trigger--icon", ), ).toBe(false); } }); }); import { snapshotFixtures } from "../../test/snapshot.js"; import { snapshotStylesheet } from "../../test/stylesheet.js"; import { fixtures } from "./SearchTrigger.fixtures.js"; snapshotFixtures(SearchTrigger, fixtures); snapshotStylesheet(resolve(__dirname, "./SearchTrigger.css"));