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:
@@ -34,6 +34,7 @@ import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -441,21 +442,18 @@ export default function ChannelPage({
|
||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||
spinner. */}
|
||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||
loading={ch.deep_requested}
|
||||
icon={<History className="w-4 h-4" />}
|
||||
title={
|
||||
ch.deep_requested
|
||||
? t("channel.fullHistoryLoadingHint")
|
||||
: t("channel.fullHistory")
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-60 disabled:hover:text-muted disabled:hover:border-border transition"
|
||||
>
|
||||
{ch.deep_requested ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<History className="w-4 h-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline whitespace-nowrap">
|
||||
{!ch.deep_requested
|
||||
? t("channel.fullHistory")
|
||||
@@ -466,7 +464,7 @@ export default function ChannelPage({
|
||||
})
|
||||
: t("channel.fullHistoryLoading")}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
@@ -504,24 +502,24 @@ export default function ChannelPage({
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
(ch?.subscribed ? (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUnsubscribe}
|
||||
disabled={unsubscribe.isPending}
|
||||
title={t("channel.unsubscribe")}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm border border-border text-muted hover:text-fg disabled:opacity-50"
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{t("channel.subscribedState")}
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => subscribe.mutate()}
|
||||
disabled={subscribe.isPending}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm btn-primary disabled:opacity-50"
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("channel.subscribe")}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
||||
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { api, type DownloadJob, type ShareLink } from "../lib/api";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
|
||||
|
||||
// --- share with a registered user (ACL) -----------------------------------------------------
|
||||
@@ -343,31 +344,29 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(false)}
|
||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreating(false)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={create.isPending}
|
||||
className={clsx(btnCls, "btn-primary")}
|
||||
>
|
||||
{t("downloads.share.createLink")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCreating(true)}
|
||||
className={clsx(
|
||||
btnCls,
|
||||
"mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5",
|
||||
)}
|
||||
icon={<Link2 className="w-4 h-4" />}
|
||||
className="mt-2"
|
||||
>
|
||||
<Link2 className="w-4 h-4" /> {t("downloads.share.newLink")}
|
||||
</button>
|
||||
{t("downloads.share.newLink")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,22 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Crop,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Pause,
|
||||
Play,
|
||||
Scissors,
|
||||
SplitSquareHorizontal,
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Crop, Eye, EyeOff, Pause, Play, Scissors, SplitSquareHorizontal, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import Modal from "./Modal";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -621,28 +612,23 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={clsx(btnCls, "text-muted hover:text-fg hover:bg-surface")}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
disabled={!valid || create.isPending}
|
||||
className={clsx(btnCls, "btn-primary inline-flex items-center gap-1.5")}
|
||||
disabled={!valid}
|
||||
loading={create.isPending}
|
||||
icon={<Scissors className="w-4 h-4" />}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Scissors className="w-4 h-4" />
|
||||
)}
|
||||
{willJoin
|
||||
? t("editor.createJoin")
|
||||
: kept.length > 1
|
||||
? t("editor.createN", { count: kept.length })
|
||||
: t("editor.create")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user