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.
148 lines
5.3 KiB
TypeScript
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");
|
|
});
|
|
});
|