Files
siftlode/frontend/src/components/ui/Button.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

105 lines
4.4 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";
import { buttonClass, isLinkVariant, type ButtonSize, type ButtonVariant } from "./buttonStyles";
// 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.
//
// No focus-visible here on purpose: @layer base in index.css already rings every <button> for keyboard
// users. Adding one would double the ring.
// A toggle says so through `pressed`, never a bare `aria-pressed`: the prop also paints the pressed
// state (B3), and a raw attribute left the Server log's Live toggle looking like any outline button.
interface ButtonOwnProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-pressed"> {
variant?: ButtonVariant;
/** Defaults to "md", except for the link variants, whose default is "none" (a link owns no box). */
size?: ButtonSize;
/** Leading icon (a lucide element; the primitive sizes it). Replaced by a spinner while `loading`. */
icon?: ReactNode;
/** 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;
/** Not accepted: a toggle is `pressed` (JSX lets any undeclared `aria-*` through, so it is `never`). */
"aria-pressed"?: never;
/** Sits on a picture (a thumbnail, a poster) or inside a pill field: the pill shape. Implied by the
* `media` variant. */
round?: boolean;
fullWidth?: boolean;
/** Its text is a title or a name (a link back to a show): it stays selectable (FocusSafeButton). */
selectable?: boolean;
}
// 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 };
export type ButtonProps = ButtonOwnProps & ({ children: ReactNode } | NamedSomehow);
/** 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,
label,
loading = false,
pressed,
round = false,
fullWidth = false,
className,
children,
disabled,
type,
onClick,
onFocus,
onBlur,
...rest
},
ref,
) {
// 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 ?? (isLinkVariant(variant) ? "none" : "md");
const leading = loading ? <Loader2 className="animate-spin" /> : icon;
return (
<FocusSafeButton
ref={ref}
type={type ?? "button"}
disabled={disabled}
busy={loading}
onClick={onClick}
onFocus={onFocus}
onBlur={onBlur}
aria-label={label}
aria-pressed={pressed}
title={label}
className={clsx(
buttonClass({ variant, size: resolvedSize, round, fullWidth, pressed }),
className,
)}
{...rest}
>
{leading}
{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>;