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");
});
});
+100
View File
@@ -0,0 +1,100 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
// The ONE action-button primitive. Before this there were 385 raw <button>s styling themselves with
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
// constant. This centralises the geometry+paint so an "action button" (submit/cancel/subscribe/delete…)
// is one component with named variants. It deliberately does NOT cover the app's OTHER button families,
// which keep their own primitives: aria-pressed segmented pills (SegmentedToggle/SortControl), the
// Switch toggle, .chip removers, dropdown/popover trigger pills, and slider/overlay/card-click buttons.
//
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
type ButtonVariant =
| "primary" // solid accent — the main affirmative action
| "danger" // solid danger — destructive confirm
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons
| "danger-outline"; // bordered danger — a destructive action that shouldn't shout
type ButtonSize =
| "xs" // dense inline action
| "sm" // the btnCls geometry (px-3 py-1.5)
| "md" // default action button (px-3 py-2)
| "lg" // prominent CTA
| "icon"; // square icon-only (w-9 h-9)
const VARIANTS: Record<ButtonVariant, string> = {
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
primary: "btn-primary",
danger: "btn-danger",
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
ghost: "text-muted hover:text-fg hover:bg-surface",
glass: "glass-card glass-hover hover:text-accent",
"danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
};
const SIZES: Record<ButtonSize, string> = {
xs: "px-2 py-1 text-xs rounded-md gap-1",
sm: "px-3 py-1.5 text-sm rounded-lg gap-1.5",
md: "px-3 py-2 text-sm rounded-lg gap-1.5",
lg: "px-4 py-2.5 text-base rounded-lg gap-2",
icon: "w-9 h-9 rounded-lg justify-center",
};
const BASE =
"inline-flex items-center justify-center font-medium transition disabled:opacity-50 disabled:cursor-not-allowed";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
icon?: ReactNode;
/** Render only the icon: children are dropped and `label` supplies the accessible name. */
iconOnly?: boolean;
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
label?: string;
/** Disable + swap the icon for a spinner + mark aria-busy. */
loading?: boolean;
fullWidth?: boolean;
}
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
variant = "primary",
size = "md",
icon,
iconOnly = false,
label,
loading = false,
fullWidth = false,
className,
children,
disabled,
type,
...rest
},
ref,
) {
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
return (
<button
ref={ref}
type={type ?? "button"}
disabled={disabled ?? loading}
aria-busy={loading || undefined}
aria-label={label}
title={label}
className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
{...rest}
>
{leading}
{!iconOnly && children}
</button>
);
});