feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)

Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
2026-09-24 05:25:55 +02:00
parent 7d480e2ad0
commit 86f467bd65
42 changed files with 1304 additions and 1036 deletions
@@ -0,0 +1,138 @@
// @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>
</>,
);
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();
});
});