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:
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
||||
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
||||
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
||||
// stops an action button from submitting a surrounding form.
|
||||
// S5: the shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
|
||||
// on: variant/size paint (the E28 B1 ladder and B3 layers), the loading state (disabled + spinner +
|
||||
// aria-busy), the pressed toggle, and the type="button" default that stops an action button from
|
||||
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { Button } from "./Button";
|
||||
@@ -23,20 +23,50 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.className).toContain("btn-danger");
|
||||
expect(btn.className).toContain("py-1.5");
|
||||
expect(btn.className).toContain("state-danger");
|
||||
expect(btn.className).toContain("h-7");
|
||||
});
|
||||
|
||||
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
||||
render(
|
||||
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
||||
this text is dropped
|
||||
it("puts every rung on an explicit height, so a toolbar row lines up (B1)", () => {
|
||||
for (const [size, h] of [
|
||||
["xs", "h-6"],
|
||||
["sm", "h-7"],
|
||||
["md", "h-8"],
|
||||
] as const) {
|
||||
const { unmount } = render(<Button size={size}>X</Button>);
|
||||
const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/);
|
||||
expect(cls).toContain(h);
|
||||
expect(cls).toContain("rounded-lg");
|
||||
expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => {
|
||||
for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) {
|
||||
const { unmount } = render(<Button variant={variant}>X</Button>);
|
||||
const cls = screen.getByRole("button", { name: "X" }).className;
|
||||
expect(cls).toContain("state-layer");
|
||||
expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("a toggle announces aria-pressed and paints the pressed state", () => {
|
||||
const { rerender } = render(
|
||||
<Button variant="outline" pressed={false}>
|
||||
Default
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Block channel" });
|
||||
expect(btn.getAttribute("title")).toBe("Block channel");
|
||||
expect(btn.textContent).not.toContain("this text is dropped");
|
||||
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||
const btn = screen.getByRole("button", { name: "Default" });
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(btn.className).toContain("aria-pressed:bg-accent");
|
||||
rerender(
|
||||
<Button variant="outline" pressed>
|
||||
Default
|
||||
</Button>,
|
||||
);
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
|
||||
@@ -112,7 +142,7 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
btn = screen.getByRole("button", { name: "Show all" });
|
||||
expect(btn.className).toContain("px-3");
|
||||
expect(btn.className).toContain("px-2.5");
|
||||
});
|
||||
|
||||
it("keeps one font-weight utility whatever the variant", () => {
|
||||
@@ -132,10 +162,13 @@ describe("Button primitive (S5)", () => {
|
||||
const { unmount } = render(<Button>Tap</Button>);
|
||||
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
||||
unmount();
|
||||
render(<Button variant="link">Read more</Button>);
|
||||
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
||||
"touch-target",
|
||||
);
|
||||
for (const variant of ["link", "link-muted"] as const) {
|
||||
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
|
||||
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
||||
"touch-target",
|
||||
);
|
||||
u();
|
||||
}
|
||||
});
|
||||
|
||||
it("fullWidth adds the w-full utility", () => {
|
||||
@@ -205,16 +238,13 @@ describe("Button accessible-name contract (type level)", () => {
|
||||
it("rejects a text-less button with no name, and accepts every named spelling", () => {
|
||||
render(
|
||||
<>
|
||||
{/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */}
|
||||
<Button size="icon" icon={<span />} data-testid="bad-a" />
|
||||
{/* @ts-expect-error iconOnly with no label/aria-label/title has no accessible name */}
|
||||
<Button iconOnly icon={<span />} data-testid="bad-b" />
|
||||
{/* @ts-expect-error an icon with no children and no size renders no text and no name */}
|
||||
{/* @ts-expect-error an icon with no children renders no text and no name */}
|
||||
<Button icon={<span />} data-testid="bad-c" />
|
||||
<Button icon={<span />} label="an icon button named by label" />
|
||||
<Button size="icon" icon={<span />} label="by label" />
|
||||
<Button size="icon-sm" icon={<span />} aria-label="by aria-label" />
|
||||
<Button iconOnly icon={<span />} title="by title" />
|
||||
{/* @ts-expect-error the icon-only sizes moved to IconButton (E28 S3b) */}
|
||||
<Button size="icon" label="gone" />
|
||||
<Button icon={<span />} label="by label" />
|
||||
<Button icon={<span />} aria-label="by aria-label" />
|
||||
<Button icon={<span />} title="by title" />
|
||||
<Button>plain text needs none</Button>
|
||||
</>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user