Files
siftlode/frontend/src/components/ui/Button.tsx
T
peter 3369587412 fix(review): resolve deep-review findings across quickfix batch #2
- Button: `loading` always disables (|| not ??) — stops a double-submit
  when a call-site passes an explicit boolean `disabled` (VideoEditor).
- playlists: track un-ingestable-but-live YouTube ids in the sync baseline
  so a linked playlist containing a private/deleted video converges instead
  of re-classifying it as a pending pull forever; re-attempt ingestion each
  sync so a restored (private->public) video heals, and prune ids that
  leave YouTube.
- SettingsPanel: download-auth section renders an honest error/loading
  state (StateMessage + retry) instead of a bare empty section on a failed
  credentials fetch; HU+EN loadError string.
- tests: reorder-order, partial-failure baseline hold, un-ingestable
  converge/heal/prune, Button loading-disables, download-auth error/retry.
2026-08-29 03:52:13 +02:00

104 lines
4.1 KiB
TypeScript

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"}
// `loading` must always disable (see the prop's contract above), even when a call-site also
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
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>
);
});