Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
198 lines
11 KiB
TypeScript
198 lines
11 KiB
TypeScript
import { forwardRef } from "react";
|
|
import type { ButtonHTMLAttributes, ReactNode, RefAttributes } 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)
|
|
| "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;
|
|
type TextfulButton = { iconOnly?: false; size?: Exclude<ButtonSize, "icon" | "icon-sm"> };
|
|
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,
|
|
...rest
|
|
},
|
|
ref,
|
|
) {
|
|
// 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 1024x768 comfort contract 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 (
|
|
<button
|
|
ref={ref}
|
|
type={type ?? "button"}
|
|
// `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
|
|
// loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
|
|
// handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
|
|
// out of the page on every press. `aria-disabled` keeps the button focusable and announced as
|
|
// unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
|
|
// says, and its preventDefault also cancels a form's implicit submission (which is a click on
|
|
// the default button).
|
|
disabled={disabled}
|
|
aria-disabled={loading || undefined}
|
|
aria-busy={loading || undefined}
|
|
onClick={(e) => {
|
|
if (loading) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
onClick?.(e);
|
|
}}
|
|
aria-label={label}
|
|
title={label}
|
|
className={clsx(
|
|
BASE,
|
|
VARIANTS[variant],
|
|
SIZES[resolvedSize],
|
|
touchTarget && "touch-target",
|
|
fullWidth && "w-full",
|
|
className,
|
|
)}
|
|
{...rest}
|
|
>
|
|
{leading}
|
|
{!iconOnly && children}
|
|
</button>
|
|
);
|
|
});
|
|
|
|
// 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>;
|