Files
siftlode/frontend/src/components/ui/IconButton.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

148 lines
5.3 KiB
TypeScript

// @vitest-environment jsdom
//
// 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(
<IconButton label="Delete">
<svg data-testid="ic" />
</IconButton>,
);
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(
<IconButton label="Delete" size="md" noTitle>
<svg />
</IconButton>,
);
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(
<IconButton label="X" variant={variant}>
<svg />
</IconButton>,
);
expect(screen.getByRole("button", { name: "X" }).className).toContain("rounded-lg");
unmount();
}
render(
<IconButton label="Play" variant="media">
<svg />
</IconButton>,
);
expect(screen.getByRole("button", { name: "Play" }).className).toContain("rounded-full");
});
it("a toggle announces aria-pressed", () => {
render(
<IconButton label="Saved" pressed>
<svg />
</IconButton>,
);
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(
<IconButton label="Sync" loading onClick={onClick}>
<svg data-testid="ic" />
</IconButton>,
);
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(
<IconButton label="Delete the user instead" aria-disabled onClick={(e) => e.preventDefault()}>
<svg />
</IconButton>,
);
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 */}
<IconButton>
<svg />
</IconButton>
<IconButton label="named">
<svg />
</IconButton>
{/* @ts-expect-error a toggle states itself through `pressed`, which also tints it (B3) */}
<IconButton label="raw toggle" aria-pressed>
<svg />
</IconButton>
</>,
);
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(
<form onSubmit={onSubmit}>
<Pressable>Row</Pressable>
</form>,
);
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(
<form onSubmit={onSubmit}>
<Pressable layer>Row</Pressable>
</form>,
);
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(<DragHandle label="Move Intro" unavailable onClick={onClick} />);
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(<DragHandle label="Move Intro" />);
expect(screen.getByRole("button", { name: "Move Intro" }).className).toContain("touch-none");
});
});