// @vitest-environment jsdom // // E28 S3b: the glyph-only button (DESIGN.md A2) and the two other click primitives that sit beside // `Button`: `Pressable` (a surface whose content is the control) and `DragHandle`. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { IconButton } from "./IconButton"; import { Pressable } from "./Pressable"; import { DragHandle } from "./DragHandle"; afterEach(cleanup); describe("IconButton", () => { it("is named and tooltipped by its label, and sizes to the A2 rungs", () => { const { rerender } = render( , ); const btn = screen.getByRole("button", { name: "Delete" }); expect(btn.getAttribute("title")).toBe("Delete"); expect(btn.getAttribute("type")).toBe("button"); expect(btn.className).toContain("size-7"); expect(btn.className).toContain("touch-target"); expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); rerender( , ); expect(btn.className).toContain("size-8"); expect(btn.hasAttribute("title")).toBe(false); }); it("is round only on media (A2)", () => { for (const variant of ["ghost", "outline", "danger", "glass", "overlay"] as const) { const { unmount } = render( , ); expect(screen.getByRole("button", { name: "X" }).className).toContain("rounded-lg"); unmount(); } render( , ); expect(screen.getByRole("button", { name: "Play" }).className).toContain("rounded-full"); }); it("a toggle announces aria-pressed", () => { render( , ); expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true"); }); it("loading swaps the glyph for a spinner and swallows the click", () => { const onClick = vi.fn(); render( , ); const btn = screen.getByRole("button", { name: "Sync" }); expect(btn.getAttribute("aria-busy")).toBe("true"); expect(btn.querySelector("[data-testid=ic]")).toBeNull(); fireEvent.click(btn); expect(onClick).not.toHaveBeenCalled(); }); it("keeps a caller's aria-disabled: unavailable, focusable, and still saying why", () => { // The "an account is behind this address, delete the user instead" state: the reason lives in // the label, so the control must stay in the tab order. FocusSafeButton used to overwrite it. render( e.preventDefault()}> , ); const btn = screen.getByRole("button", { name: "Delete the user instead" }); expect(btn.getAttribute("aria-disabled")).toBe("true"); expect(btn.hasAttribute("disabled")).toBe(false); }); it("cannot be rendered without a name (type level)", () => { render( <> {/* @ts-expect-error a glyph-only button with no label has no accessible name */} {/* @ts-expect-error a toggle states itself through `pressed`, which also tints it (B3) */} , ); expect(screen.getByRole("button", { name: "named" })).toBeTruthy(); }); }); describe("Pressable", () => { it("never submits a form, reads left-aligned, and paints a layer only on request", () => { const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault()); const { rerender } = render(
Row
, ); const row = screen.getByRole("button", { name: "Row" }); fireEvent.click(row); expect(onSubmit).not.toHaveBeenCalled(); expect(row.className).toContain("text-left"); expect(row.className).not.toContain("state-layer"); rerender(
Row
, ); expect(row.className).toContain("state-layer"); }); }); describe("DragHandle", () => { it("names the row it moves, and when unavailable stays focusable with its reason", () => { const onClick = vi.fn(); render(); const h = screen.getByRole("button", { name: "Move Intro" }); expect(h.hasAttribute("disabled")).toBe(false); expect(h.getAttribute("aria-disabled")).toBe("true"); expect(h.getAttribute("title")).toBe("Move Intro"); fireEvent.click(h); expect(onClick).not.toHaveBeenCalled(); }); it("takes a finger's drag instead of scrolling the page (touch-action: none, WCAG 2.5.7)", () => { render(); expect(screen.getByRole("button", { name: "Move Intro" }).className).toContain("touch-none"); }); });