feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
|
||||
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
|
||||
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. The hover guard is
|
||||
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
|
||||
const ACT_BUTTON = "enabled:not-aria-disabled:";
|
||||
const ACT_LINK = "not-aria-disabled:";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "primary" // solid accent — the main affirmative action
|
||||
| "danger" // solid danger — destructive confirm
|
||||
| "warn" // solid warn — the action of a warning bar (reload for a new version)
|
||||
| "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 — a row-level Delete
|
||||
| "outline-warn" // bordered + warn-tinted — a reversible restriction that is currently ON
|
||||
| "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 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"
|
||||
|
||||
export type ButtonSize =
|
||||
| "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 buttonVariants = (ACT: string): Record<ButtonVariant, string> => ({
|
||||
primary: "font-medium state-filled",
|
||||
danger: "font-medium state-danger",
|
||||
warn: "font-medium state-warn",
|
||||
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 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`,
|
||||
});
|
||||
|
||||
// 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";
|
||||
|
||||
// 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, or nested in a pill (the header search's Go), is a pill instead (A2: round
|
||||
// means "floats over something", and a pill's inner button follows its radius).
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
// 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 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";
|
||||
|
||||
export type IconButtonVariant =
|
||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
||||
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
|
||||
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
|
||||
| "outline" // bordered — an icon action that stands on its own in a form or a header
|
||||
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
|
||||
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
|
||||
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
|
||||
| "danger" // quiet at rest, danger on hover — a row's delete/remove
|
||||
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
|
||||
| "glass" // glass card — beside the glass Buttons on a Plex page
|
||||
| "media" // round, dark translucent — on a thumbnail or a poster
|
||||
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
||||
|
||||
export type IconButtonSize =
|
||||
| "sm" // 28 — rows and dense toolbars
|
||||
| "md" // 32 — on its own
|
||||
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||||
|
||||
const iconVariants = (ACT: string): Record<IconButtonVariant, string> => ({
|
||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"ghost-accent": "text-accent state-layer",
|
||||
primary: "state-filled",
|
||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
||||
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
||||
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
||||
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
||||
});
|
||||
|
||||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
||||
const ICON_PRESSED = "aria-pressed:text-accent";
|
||||
|
||||
const ICON_SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
md: "size-8 [&>svg]:size-4",
|
||||
lg: "size-12 [&>svg]:size-6",
|
||||
player: "size-9 [&>svg]:size-5",
|
||||
};
|
||||
|
||||
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
|
||||
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
|
||||
|
||||
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||||
|
||||
export function buttonClass({
|
||||
variant,
|
||||
size,
|
||||
round = false,
|
||||
fullWidth = false,
|
||||
pressed,
|
||||
element = "button",
|
||||
}: {
|
||||
variant: ButtonVariant;
|
||||
size: ButtonSize;
|
||||
round?: boolean;
|
||||
fullWidth?: boolean;
|
||||
pressed?: boolean;
|
||||
element?: "button" | "link";
|
||||
}) {
|
||||
return clsx(
|
||||
BASE,
|
||||
BUTTON_VARIANTS[element][variant],
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[size],
|
||||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||
// The coarse-pointer target (DESIGN.md A3) lives HERE, not in the call sites. A link variant 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.
|
||||
!isLinkVariant(variant) && "touch-target",
|
||||
fullWidth && "w-full",
|
||||
);
|
||||
}
|
||||
|
||||
export function iconButtonClass({
|
||||
variant,
|
||||
size,
|
||||
round = false,
|
||||
pressed,
|
||||
element = "button",
|
||||
}: {
|
||||
variant: IconButtonVariant;
|
||||
size: IconButtonSize;
|
||||
round?: boolean;
|
||||
pressed?: boolean;
|
||||
element?: "button" | "link";
|
||||
}) {
|
||||
return clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
ICON_VARIANTS[element][variant],
|
||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && ICON_PRESSED,
|
||||
ICON_SIZES[size],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user