feat(ui): shared <Button> primitive + migrate ShareDialog/VideoEditor/ChannelPage proofs

Introduce frontend/src/components/ui/Button.tsx — the one action-button primitive
(variants primary|danger|outline|ghost|glass|danger-outline; sizes xs|sm|md|lg|icon;
icon/iconOnly/label/loading/fullWidth/forwardRef). No focus-visible (already centralized
in index.css @layer base). Migrate the cleanest proof sites off btnCls, and flip the
channel Subscribe button to the outline variant so it matches Full history (the user's ask).
This commit is contained in:
2026-08-28 02:59:21 +02:00
parent 9ae6c47d6c
commit 4892c38466
5 changed files with 202 additions and 57 deletions
@@ -0,0 +1,62 @@
// @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.
import { afterEach, describe, expect, it, vi } from "vitest";
import { 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("btn-danger");
expect(btn.className).toContain("py-1.5");
});
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
</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();
});
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
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.disabled).toBe(true);
expect(btn.getAttribute("aria-busy")).toBe("true");
// the leading icon is swapped for a spinner
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("fullWidth adds the w-full utility", () => {
render(<Button fullWidth>Wide</Button>);
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
});
});