Files
siftlode/frontend/src/components/ui/IconButton.dom.test.tsx
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

167 lines
6.0 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");
});
// A mode cycle (auto-advance, loop) is no two-state switch: it looks on, its name says the mode.
it("a lit cycle tints its glyph without announcing a toggle", () => {
const { rerender } = render(
<IconButton label="Auto: Next" lit>
<svg />
</IconButton>,
);
const btn = screen.getByRole("button", { name: "Auto: Next" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:text-chosen");
rerender(
<IconButton label="Auto: Off" lit={false}>
<svg />
</IconButton>,
);
expect(btn.hasAttribute("data-lit")).toBe(false);
});
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");
});
});