- 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.
196 lines
10 KiB
TypeScript
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>;
|