Files
siftlode/frontend/src/components/ui/Button.tsx
T
peter d2e40dcffb feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
  (principles, device adaptation, action placement, text stays selectable,
  interaction rules, copy, anti-patterns, decision log); controls and
  surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
  app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
  reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
  1024x768 floor with its never-built "too small" message.
2026-09-24 01:34:12 +02:00

196 lines
10 KiB
TypeScript

import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
import { FocusSafeButton } from "./FocusSafeButton";
// 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)
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
| "outline-danger" // neutral at rest, danger on hover — the row-level Delete icon
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
| "ghost" // borderless, hover-fill — tertiary/dismiss (dialog Cancel, close)
| "glass" // glass card — the Plex/overlay action buttons
| "danger-soft" // bordered AND danger-coloured at rest — a destructive action that shouldn't shout
| "link"; // inline text link — "Show all", "Learn more", a resend-mail prompt inside a sentence
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)
| "icon-sm" // square icon-only, dense (w-8 h-8) — toolbars and table rows
| "none"; // no padding/radius of its own — the `link` variant's default
const VARIANTS: Record<ButtonVariant, string> = {
// .btn-primary / .btn-danger own the fill + one hover-dim (index.css @layer components).
primary: "font-medium btn-primary",
danger: "font-medium btn-danger",
// E10 S1b: the neutral variants were RE-AIMED at the paint the app actually uses. Counting the
// un-migrated call sites showed the majority hovering to `accent` (outline) and filling with `card`
// (ghost), while this primitive hovered to `fg` and filled with `surface` — so the 37 migrated sites
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
outline:
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
"outline-accent":
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
// is whether the button should advertise the destruction before the pointer arrives.
"outline-danger":
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
"outline-warn":
"font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
ghost:
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
"danger-soft":
"font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
// the winner is decided by the generated stylesheet order, not by the class list order).
link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
};
// Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
// disabled button that still lights up on hover advertises a click that does nothing, and the ONE
// migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
// moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
// button stays natively enabled (see `loading` below) and must not light up either. The contract
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
// (22 sites), `px-2 py-1 text-xs` (12), `px-3 py-1.5 text-sm` (11). Every value is rem-derived, so
// the Settings text-size slider (--font-scale, applied to the root font-size) scales the padding and
// the box with the label instead of leaving a shrunken button around bigger text.
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",
"icon-sm": "w-8 h-8 rounded-lg justify-center",
none: "gap-1.5",
};
// The font-weight is owned by each VARIANT, not by BASE: `link` wants semibold (that is what a text
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE =
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
/** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */
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;
/** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
loading?: boolean;
fullWidth?: boolean;
}
// A button that renders NO TEXT has to get its accessible name from somewhere, and "required for
// `iconOnly`" was a sentence in a doc comment — so the S1 migration shipped two icon buttons that
// announce themselves as a bare "button" and every lane stayed green (E10 review round 1). The rule
// is a type now, in the file that owns the contract.
//
// Both spellings of a text-less button are covered: `iconOnly`, and the far commoner `size="icon"` /
// `"icon-sm"` (a square box that fits one glyph). Any of the three naming props satisfies it —
// `title` included, because the accname spec does fall back to it and ~16 call sites legitimately
// use it for the tooltip that IS the name.
type NamedSomehow = { label: string } | { "aria-label": string } | { title: string };
type TextlessButton = ({ iconOnly: true } | { size: "icon" | "icon-sm" }) & NamedSomehow;
// …and a button of any other size must actually HAVE text, or be named: `<Button icon={x} />` with
// no children and no size typechecked into a nameless button (E10 review).
type TextfulButton = { iconOnly?: false; size?: Exclude<ButtonSize, "icon" | "icon-sm"> } & (
| { children: ReactNode }
| NamedSomehow
);
export type ButtonProps = ButtonOwnProps & (TextlessButton | TextfulButton);
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
{
variant = "primary",
size,
icon,
iconOnly = false,
label,
loading = false,
fullWidth = false,
className,
children,
disabled,
type,
onClick,
onFocus,
onBlur,
...rest
},
ref,
) {
// Unavailable while busy (`loading`) or disabled — without dropping a focused button's focus:
// see FocusSafeButton, which owns that rule for every button in the app.
// A link must not inherit the action-button padding/radius, so its size default differs. Resolved
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
const resolvedSize: ButtonSize = size ?? (variant === "link" ? "none" : "md");
// The tablet comfort contract (docs/DESIGN.md) lives HERE, not in the call sites: on a coarse pointer every
// button-shaped control gets at least a 2.75rem box (see .touch-target in index.css). `link` is
// excluded on purpose — it is a run of text inside a sentence, and a 44px-tall inline link would
// wreck the paragraph it sits in rather than make anything easier to hit.
const touchTarget = variant !== "link";
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
disabled={disabled}
busy={loading}
onClick={onClick}
onFocus={onFocus}
onBlur={onBlur}
aria-label={label}
title={label}
className={clsx(
BASE,
VARIANTS[variant],
SIZES[resolvedSize],
touchTarget && "touch-target",
fullWidth && "w-full",
className,
)}
{...rest}
>
{leading}
{!iconOnly && children}
</FocusSafeButton>
);
});
// The implementation takes the permissive shape (it has to destructure every prop); the EXPORT is
// what call sites see, and that is where the "a text-less button must be named" union binds.
export const Button = ButtonImpl as (
props: ButtonProps & RefAttributes<HTMLButtonElement>,
) => ReturnType<typeof ButtonImpl>;