feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
@@ -4,12 +4,12 @@ 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.
|
||||
// The ONE action-button primitive: a labelled action (Save, Cancel, Subscribe, Delete, "Show all").
|
||||
// docs/DESIGN.md decides its look (A1 shape, B1 ladder, B3 state layers); this file is where that
|
||||
// holds. The app's other button families have their own primitives beside it: `IconButton` (a glyph
|
||||
// with no text), `Pressable` (a card, row or tile whose content is the control), `Chip` and
|
||||
// `FilterChipGroup` (filters), `SegmentedToggle`, `DragHandle`. A hand-rolled `<button>` is none of
|
||||
// them (E28 S3b).
|
||||
//
|
||||
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
|
||||
// users. Adding one would double the ring.
|
||||
@@ -19,71 +19,60 @@ type ButtonVariant =
|
||||
| "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-danger" // neutral at rest, danger on hover — a row-level Delete
|
||||
| "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
|
||||
| "ghost" // borderless — tertiary/dismiss (dialog Cancel, close)
|
||||
| "glass" // glass card — the Plex/overlay action buttons (A1: glass is a variant, not a hand-built one)
|
||||
| "overlay" // white text on the always-dark player chrome, whatever the theme
|
||||
| "media" // dark translucent on a picture — "From the start" on a card (C5); always round
|
||||
| "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
|
||||
| "link" // inline accent text — "Show all", "Learn more", a resend-mail prompt inside a sentence
|
||||
| "link-muted"; // inline quiet text that brightens on hover — "Clear", "Show less", "Skip"
|
||||
|
||||
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
|
||||
| "xs" // 24 — inside a table row
|
||||
| "sm" // 28 — toolbars and panel headers; lines up with the search pill and the dropdown trigger
|
||||
| "md" // 32 — forms and dialog footers
|
||||
| "lg" // 40 — a page's single hero call to action (Welcome, onboarding), nowhere else
|
||||
| "none"; // no box of its own — the link variants' default
|
||||
|
||||
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
||||
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
||||
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
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",
|
||||
primary: "font-medium state-filled",
|
||||
danger: "font-medium state-danger",
|
||||
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
||||
// 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",
|
||||
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
||||
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
|
||||
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||||
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
link: `font-semibold text-accent ${ACT}hover:underline`,
|
||||
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
|
||||
};
|
||||
|
||||
// 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.
|
||||
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
||||
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
||||
const PRESSED =
|
||||
"aria-pressed:bg-accent aria-pressed:text-accent-fg aria-pressed:border-accent aria-pressed:no-underline";
|
||||
|
||||
// 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.
|
||||
// B1, the whole ladder: explicit heights, so a toolbar's `sm` button, search pill, dropdown trigger and
|
||||
// small icon button all line up at 28. A call site picks a rung; it does not invent one. rem-derived, so
|
||||
// the Settings text-size slider (--font-scale) grows the box with the label. A1: r8 on every rung; a
|
||||
// button on a picture is a pill instead (A2: round means "on media", and nothing else).
|
||||
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",
|
||||
xs: "h-6 px-2 text-xs gap-1 whitespace-nowrap",
|
||||
sm: "h-7 px-2.5 text-sm gap-1.5 whitespace-nowrap",
|
||||
md: "h-8 px-3 text-sm gap-1.5 whitespace-nowrap",
|
||||
lg: "h-10 px-4 text-base gap-2 whitespace-nowrap",
|
||||
none: "gap-1.5",
|
||||
};
|
||||
|
||||
@@ -91,42 +80,33 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
// 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";
|
||||
"inline-flex shrink-0 items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed [&>svg]:size-4 [&>svg]:shrink-0";
|
||||
|
||||
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
/** Defaults to "md", except for `variant="link"`, whose default is "none" (a link owns no padding). */
|
||||
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
|
||||
size?: ButtonSize;
|
||||
/** Leading icon (an lucide element). Replaced by a spinner while `loading`. */
|
||||
/** Leading icon (a lucide element; the primitive sizes it). 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. */
|
||||
/** Accessible name + tooltip, when the visible text alone is not enough. */
|
||||
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;
|
||||
/** A toggle: sets aria-pressed and paints the pressed state (the solid accent). */
|
||||
pressed?: boolean;
|
||||
/** Sits on a picture (a thumbnail, a poster): the pill shape. Implied by the `media` variant. */
|
||||
onMedia?: 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.
|
||||
// A button of this primitive always renders text, or it is an `IconButton`. `<Button icon={x} />`
|
||||
// with no children would typecheck into a nameless button, so a text-less one must be named — and
|
||||
// the named, text-less shape is what `IconButton` exists for.
|
||||
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);
|
||||
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
|
||||
|
||||
const isLink = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||||
|
||||
/** The shared action button. `type` defaults to "button" so it never accidentally submits a form. */
|
||||
const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button(
|
||||
@@ -134,9 +114,10 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
variant = "primary",
|
||||
size,
|
||||
icon,
|
||||
iconOnly = false,
|
||||
label,
|
||||
loading = false,
|
||||
pressed,
|
||||
onMedia = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
children,
|
||||
@@ -149,18 +130,15 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
},
|
||||
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;
|
||||
// A link must not inherit the action-button box, so its size default differs. Resolved here rather
|
||||
// than by asking every call site to pass `size="none"` alongside a link variant — a pairing everyone
|
||||
// would eventually forget, which is how a "link" grows a button's box.
|
||||
const resolvedSize: ButtonSize = size ?? (isLink(variant) ? "none" : "md");
|
||||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A 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 = !isLink(variant);
|
||||
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
|
||||
return (
|
||||
<FocusSafeButton
|
||||
ref={ref}
|
||||
@@ -171,11 +149,14 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
title={label}
|
||||
className={clsx(
|
||||
BASE,
|
||||
VARIANTS[variant],
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[resolvedSize],
|
||||
resolvedSize !== "none" && (onMedia || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||
touchTarget && "touch-target",
|
||||
fullWidth && "w-full",
|
||||
className,
|
||||
@@ -183,7 +164,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
{...rest}
|
||||
>
|
||||
{leading}
|
||||
{!iconOnly && children}
|
||||
{children}
|
||||
</FocusSafeButton>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user