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).
256 lines
11 KiB
TypeScript
256 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// 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";
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("Button primitive (S5)", () => {
|
|
it("defaults to type=button so it never submits a form", () => {
|
|
render(<Button>Go</Button>);
|
|
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
|
|
});
|
|
|
|
it("applies the variant paint recipe", () => {
|
|
render(
|
|
<Button variant="danger" size="sm">
|
|
Delete
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Delete" });
|
|
expect(btn.className).toContain("state-danger");
|
|
expect(btn.className).toContain("h-7");
|
|
});
|
|
|
|
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: "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", () => {
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
|
Save
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
|
expect(btn.getAttribute("aria-busy")).toBe("true");
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
// the leading icon is swapped for a spinner
|
|
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
|
|
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
|
|
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
|
|
it("loading keeps the button natively enabled, so it keeps the focus", () => {
|
|
render(<Button loading>Reroll</Button>);
|
|
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(false);
|
|
});
|
|
|
|
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
|
|
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
|
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
|
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button disabled={false} loading onClick={onClick}>
|
|
Create
|
|
</Button>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a busy submit button cancels the form's submission", () => {
|
|
// Enter in a field submits through a click on the default button; the guard cancels that click.
|
|
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
|
render(
|
|
<form onSubmit={onSubmit}>
|
|
<Button type="submit" loading>
|
|
Send
|
|
</Button>
|
|
</form>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a non-busy button still calls onClick", () => {
|
|
const onClick = vi.fn();
|
|
render(<Button onClick={onClick}>Go</Button>);
|
|
fireEvent.click(screen.getByRole("button", { name: "Go" }));
|
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("a link gets no button padding, and an explicit size still wins", () => {
|
|
// The size default is variant-dependent (link → "none"). Locking it because the failure is
|
|
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
|
|
const { rerender } = render(<Button variant="link">Show all</Button>);
|
|
let btn = screen.getByRole("button", { name: "Show all" });
|
|
expect(btn.className).toContain("text-accent");
|
|
expect(btn.className).not.toContain("px-3");
|
|
rerender(
|
|
<Button variant="link" size="sm">
|
|
Show all
|
|
</Button>,
|
|
);
|
|
btn = screen.getByRole("button", { name: "Show all" });
|
|
expect(btn.className).toContain("px-2.5");
|
|
});
|
|
|
|
it("keeps one font-weight utility whatever the variant", () => {
|
|
// Two font-* utilities of equal specificity are decided by stylesheet order, not class order,
|
|
// so a variant must never add a second one (there is no tailwind-merge here).
|
|
for (const variant of ["primary", "link", "glass", "outline"] as const) {
|
|
const { unmount } = render(<Button variant={variant}>X</Button>);
|
|
const cls = screen.getByRole("button", { name: "X" }).className;
|
|
expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1);
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it("carries the coarse-pointer minimum target, except on a link", () => {
|
|
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
|
|
// out of it by accident. A link is text in a sentence and is excluded by design.
|
|
const { unmount } = render(<Button>Tap</Button>);
|
|
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
|
unmount();
|
|
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", () => {
|
|
render(<Button fullWidth>Wide</Button>);
|
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
|
});
|
|
});
|
|
|
|
// --- the accessible-name contract, pinned at the TYPE level ------------------------------------
|
|
// A button that renders no text must carry a name, and that rule is a union in Button.tsx rather
|
|
// than a doc comment because the comment version let two nameless icon buttons ship (E10 review
|
|
// round 1). But a type with no test is a type someone widens to silence a stubborn call site:
|
|
// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green
|
|
// (round 2). Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening,
|
|
// so weakening the union turns the whole typecheck lane red.
|
|
//
|
|
// Rendered, not just declared, so eslint sees them used and the file stays one test suite.
|
|
describe("Button · disabled while it holds the focus", () => {
|
|
// A focused button that turns natively disabled loses the focus to <body> in Chrome (measured, E11
|
|
// S2) — and ~60 call sites disable the very button that was just pressed while its mutation runs.
|
|
// jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is
|
|
// unavailable by aria-disabled + a click guard, never by the native attribute.
|
|
it("keeps the native attribute off while focused, and blocks activation", () => {
|
|
const onClick = vi.fn();
|
|
const { rerender } = render(<Button onClick={onClick}>Save</Button>);
|
|
const btn = screen.getByRole("button", { name: "Save" });
|
|
act(() => btn.focus());
|
|
rerender(
|
|
<Button onClick={onClick} disabled>
|
|
Save
|
|
</Button>,
|
|
);
|
|
expect(btn.hasAttribute("disabled")).toBe(false);
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("also holds when the window has no focus and no focus event ever fired", () => {
|
|
// Chrome, with another app in front: `.focus()` moves activeElement and fires nothing. The
|
|
// button asks the document at render time, so it does not rely on the event (measured).
|
|
const { rerender } = render(<Button onClick={() => {}}>Next</Button>);
|
|
const btn = screen.getByRole("button", { name: "Next" });
|
|
const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn);
|
|
rerender(
|
|
<Button onClick={() => {}} disabled>
|
|
Next
|
|
</Button>,
|
|
);
|
|
expect(btn.hasAttribute("disabled")).toBe(false);
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
active.mockRestore();
|
|
});
|
|
|
|
it("is natively disabled again once the focus is elsewhere", () => {
|
|
render(
|
|
<Button onClick={() => {}} disabled>
|
|
Save
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Save" });
|
|
expect(btn.hasAttribute("disabled")).toBe(true);
|
|
});
|
|
});
|
|
|
|
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 an icon with no children renders no text and no name */}
|
|
<Button icon={<span />} data-testid="bad-c" />
|
|
{/* @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>
|
|
</>,
|
|
);
|
|
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "by aria-label" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "by title" })).toBeTruthy();
|
|
});
|
|
});
|