Files
siftlode/frontend/src/components/ui/IconButton.dom.test.tsx
T
peter 4d0abc61d5 feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
2026-09-26 18:14:28 +02:00

148 lines
5.3 KiB
TypeScript

// @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(
<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");
});
});