// @vitest-environment jsdom // // S5: the shared ); expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button"); }); it("applies the variant paint recipe", () => { render( , ); const btn = screen.getByRole("button", { name: "Delete" }); expect(btn.className).toContain("btn-danger"); expect(btn.className).toContain("py-1.5"); }); it("exposes an accessible name for an icon-only button while dropping the text", () => { render( , ); const btn = screen.getByRole("button", { name: "Block channel" }); expect(btn.getAttribute("title")).toBe("Block channel"); expect(btn.textContent).not.toContain("this text is dropped"); expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); }); it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => { const onClick = vi.fn(); render( , ); const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; expect(btn.getAttribute("aria-busy")).toBe("true"); expect(btn.getAttribute("aria-disabled")).toBe("true"); // the leading icon is swapped for a spinner expect(btn.querySelector("[data-testid=ic]")).toBeNull(); fireEvent.click(btn); expect(onClick).not.toHaveBeenCalled(); }); // E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to // and never gets it back, so every "Something else" press threw a keyboard user out of Home. // jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off. it("loading keeps the button natively enabled, so it keeps the focus", () => { render(); const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement; expect(btn.disabled).toBe(false); }); it("loading blocks clicks even when an explicit disabled={false} is passed", () => { // Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a // call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed // clickable mid-mutation and double-submitted. The contract is: loading always blocks. const onClick = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: "Create" })); expect(onClick).not.toHaveBeenCalled(); }); it("a busy submit button cancels the form's submission", () => { // Enter in a field submits through a click on the default button; the guard cancels that click. const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault()); render(
, ); fireEvent.click(screen.getByRole("button", { name: "Send" })); expect(onSubmit).not.toHaveBeenCalled(); }); it("a non-busy button still calls onClick", () => { const onClick = vi.fn(); render(); fireEvent.click(screen.getByRole("button", { name: "Go" })); expect(onClick).toHaveBeenCalledTimes(1); }); it("a link gets no button padding, and an explicit size still wins", () => { // The size default is variant-dependent (link → "none"). Locking it because the failure is // silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors. const { rerender } = render(); let btn = screen.getByRole("button", { name: "Show all" }); expect(btn.className).toContain("text-accent"); expect(btn.className).not.toContain("px-3"); rerender( , ); btn = screen.getByRole("button", { name: "Show all" }); expect(btn.className).toContain("px-3"); }); it("keeps one font-weight utility whatever the variant", () => { // Two font-* utilities of equal specificity are decided by stylesheet order, not class order, // so a variant must never add a second one (there is no tailwind-merge here). for (const variant of ["primary", "link", "glass", "outline"] as const) { const { unmount } = render(); const cls = screen.getByRole("button", { name: "X" }).className; expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1); unmount(); } }); it("carries the coarse-pointer minimum target, except on a link", () => { // The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt // out of it by accident. A link is text in a sentence and is excluded by design. const { unmount } = render(); expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target"); unmount(); render(); expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain( "touch-target", ); }); it("fullWidth adds the w-full utility", () => { render(); expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full"); }); }); // --- the accessible-name contract, pinned at the TYPE level ------------------------------------ // A button that renders no text must carry a name, and that rule is a union in Button.tsx rather // than a doc comment because the comment version let two nameless icon buttons ship (E10 review // round 1). But a type with no test is a type someone widens to silence a stubborn call site: // replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green // (round 2). Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening, // so weakening the union turns the whole typecheck lane red. // // Rendered, not just declared, so eslint sees them used and the file stays one test suite. describe("Button accessible-name contract (type level)", () => { it("rejects a text-less button with no name, and accepts every named spelling", () => { render( <> {/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */} , ); expect(screen.getByRole("button", { name: "by label" })).toBeTruthy(); expect(screen.getByRole("button", { name: "by aria-label" })).toBeTruthy(); expect(screen.getByRole("button", { name: "by title" })).toBeTruthy(); }); });