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:
@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
type BannerTone = "accent" | "warning";
|
||||
|
||||
@@ -62,14 +62,15 @@ export default function Banner({
|
||||
</button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Button
|
||||
<IconButton
|
||||
label={dismissTitle ?? t("common.close")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-auto"
|
||||
onClick={onDismiss}
|
||||
title={dismissTitle ?? t("common.close")}
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
className="ml-auto"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
||||
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
||||
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
||||
// stops an action button from submitting a surrounding form.
|
||||
// S5: the shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
|
||||
// on: variant/size paint (the E28 B1 ladder and B3 layers), the loading state (disabled + spinner +
|
||||
// aria-busy), the pressed toggle, and the type="button" default that stops an action button from
|
||||
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { Button } from "./Button";
|
||||
@@ -23,20 +23,50 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.className).toContain("btn-danger");
|
||||
expect(btn.className).toContain("py-1.5");
|
||||
expect(btn.className).toContain("state-danger");
|
||||
expect(btn.className).toContain("h-7");
|
||||
});
|
||||
|
||||
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
||||
render(
|
||||
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
||||
this text is dropped
|
||||
it("puts every rung on an explicit height, so a toolbar row lines up (B1)", () => {
|
||||
for (const [size, h] of [
|
||||
["xs", "h-6"],
|
||||
["sm", "h-7"],
|
||||
["md", "h-8"],
|
||||
] as const) {
|
||||
const { unmount } = render(<Button size={size}>X</Button>);
|
||||
const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/);
|
||||
expect(cls).toContain(h);
|
||||
expect(cls).toContain("rounded-lg");
|
||||
expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => {
|
||||
for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) {
|
||||
const { unmount } = render(<Button variant={variant}>X</Button>);
|
||||
const cls = screen.getByRole("button", { name: "X" }).className;
|
||||
expect(cls).toContain("state-layer");
|
||||
expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("a toggle announces aria-pressed and paints the pressed state", () => {
|
||||
const { rerender } = render(
|
||||
<Button variant="outline" pressed={false}>
|
||||
Default
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Block channel" });
|
||||
expect(btn.getAttribute("title")).toBe("Block channel");
|
||||
expect(btn.textContent).not.toContain("this text is dropped");
|
||||
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||
const btn = screen.getByRole("button", { name: "Default" });
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(btn.className).toContain("aria-pressed:bg-accent");
|
||||
rerender(
|
||||
<Button variant="outline" pressed>
|
||||
Default
|
||||
</Button>,
|
||||
);
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
|
||||
@@ -112,7 +142,7 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
btn = screen.getByRole("button", { name: "Show all" });
|
||||
expect(btn.className).toContain("px-3");
|
||||
expect(btn.className).toContain("px-2.5");
|
||||
});
|
||||
|
||||
it("keeps one font-weight utility whatever the variant", () => {
|
||||
@@ -132,10 +162,13 @@ describe("Button primitive (S5)", () => {
|
||||
const { unmount } = render(<Button>Tap</Button>);
|
||||
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
||||
unmount();
|
||||
render(<Button variant="link">Read more</Button>);
|
||||
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
||||
"touch-target",
|
||||
);
|
||||
for (const variant of ["link", "link-muted"] as const) {
|
||||
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
|
||||
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
||||
"touch-target",
|
||||
);
|
||||
u();
|
||||
}
|
||||
});
|
||||
|
||||
it("fullWidth adds the w-full utility", () => {
|
||||
@@ -205,16 +238,13 @@ describe("Button accessible-name contract (type level)", () => {
|
||||
it("rejects a text-less button with no name, and accepts every named spelling", () => {
|
||||
render(
|
||||
<>
|
||||
{/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */}
|
||||
<Button size="icon" icon={<span />} data-testid="bad-a" />
|
||||
{/* @ts-expect-error iconOnly with no label/aria-label/title has no accessible name */}
|
||||
<Button iconOnly icon={<span />} data-testid="bad-b" />
|
||||
{/* @ts-expect-error an icon with no children and no size renders no text and no name */}
|
||||
{/* @ts-expect-error an icon with no children renders no text and no name */}
|
||||
<Button icon={<span />} data-testid="bad-c" />
|
||||
<Button icon={<span />} label="an icon button named by label" />
|
||||
<Button size="icon" icon={<span />} label="by label" />
|
||||
<Button size="icon-sm" icon={<span />} aria-label="by aria-label" />
|
||||
<Button iconOnly icon={<span />} title="by title" />
|
||||
{/* @ts-expect-error the icon-only sizes moved to IconButton (E28 S3b) */}
|
||||
<Button size="icon" label="gone" />
|
||||
<Button icon={<span />} label="by label" />
|
||||
<Button icon={<span />} aria-label="by aria-label" />
|
||||
<Button icon={<span />} title="by title" />
|
||||
<Button>plain text needs none</Button>
|
||||
</>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
// The grip a sortable row is dragged by. The caller spreads its sortable `attributes` and `listeners`
|
||||
// onto it. `label` names the row it moves: a screen reader that hears a bare "reorder" on every row
|
||||
// cannot tell which one it is on.
|
||||
//
|
||||
// Unavailable (under a sort or a filter, say) it stays focusable, keeps its tooltip and lets the label
|
||||
// say why: native `disabled` would drop it from the tab order and suppress the tooltip, so the reason
|
||||
// would reach nobody. The caller also leaves its `listeners` off, so nothing can start a drag.
|
||||
export const DragHandle = forwardRef<
|
||||
HTMLButtonElement,
|
||||
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "disabled"> & {
|
||||
label: string;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
>(function DragHandle({ label, unavailable = false, className, onClick, ...rest }, ref) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-disabled={unavailable || undefined}
|
||||
onClick={(e) => (unavailable ? e.preventDefault() : onClick?.(e))}
|
||||
className={clsx(
|
||||
"inline-grid size-7 shrink-0 touch-none place-items-center rounded-lg text-muted transition touch-target [&>svg]:size-4",
|
||||
unavailable
|
||||
? "cursor-not-allowed opacity-30"
|
||||
: "cursor-grab state-layer hover:text-fg active:cursor-grabbing",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GripVertical />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
@@ -38,13 +38,16 @@ export const FocusSafeButton = forwardRef<
|
||||
const focused =
|
||||
focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current);
|
||||
const soft = busy || (!!disabled && focused);
|
||||
// A caller's own aria-disabled is the "unavailable, and here is why" state (the tooltip or label
|
||||
// carries the reason, so the button stays focusable): it is kept, never overwritten by ours.
|
||||
const callerSoft = rest["aria-disabled"] === true || rest["aria-disabled"] === "true";
|
||||
return (
|
||||
<button
|
||||
ref={setNode}
|
||||
{...rest}
|
||||
data-focus-safe=""
|
||||
disabled={!!disabled && !focused}
|
||||
aria-disabled={soft || undefined}
|
||||
aria-disabled={soft || callerSoft || undefined}
|
||||
aria-busy={busy || undefined}
|
||||
onClick={(e) => {
|
||||
if (soft) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3b: the glyph-only button (DESIGN.md A2) and the two other click primitives that sit beside
|
||||
// `Button`: `Pressable` (a surface whose content is the control) and `DragHandle`.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { DragHandle } from "./DragHandle";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("IconButton", () => {
|
||||
it("is named and tooltipped by its label, and sizes to the A2 rungs", () => {
|
||||
const { rerender } = render(
|
||||
<IconButton label="Delete">
|
||||
<svg data-testid="ic" />
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Delete" });
|
||||
expect(btn.getAttribute("title")).toBe("Delete");
|
||||
expect(btn.getAttribute("type")).toBe("button");
|
||||
expect(btn.className).toContain("size-7");
|
||||
expect(btn.className).toContain("touch-target");
|
||||
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||
rerender(
|
||||
<IconButton label="Delete" size="md" noTitle>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(btn.className).toContain("size-8");
|
||||
expect(btn.hasAttribute("title")).toBe(false);
|
||||
});
|
||||
|
||||
it("is round only on media (A2)", () => {
|
||||
for (const variant of ["ghost", "outline", "danger", "glass", "overlay"] as const) {
|
||||
const { unmount } = render(
|
||||
<IconButton label="X" variant={variant}>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "X" }).className).toContain("rounded-lg");
|
||||
unmount();
|
||||
}
|
||||
render(
|
||||
<IconButton label="Play" variant="media">
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Play" }).className).toContain("rounded-full");
|
||||
});
|
||||
|
||||
it("a toggle announces aria-pressed", () => {
|
||||
render(
|
||||
<IconButton label="Saved" pressed>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Saved" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("loading swaps the glyph for a spinner and swallows the click", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<IconButton label="Sync" loading onClick={onClick}>
|
||||
<svg data-testid="ic" />
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Sync" });
|
||||
expect(btn.getAttribute("aria-busy")).toBe("true");
|
||||
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a caller's aria-disabled: unavailable, focusable, and still saying why", () => {
|
||||
// The "an account is behind this address, delete the user instead" state: the reason lives in
|
||||
// the label, so the control must stay in the tab order. FocusSafeButton used to overwrite it.
|
||||
render(
|
||||
<IconButton label="Delete the user instead" aria-disabled onClick={(e) => e.preventDefault()}>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Delete the user instead" });
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(btn.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot be rendered without a name (type level)", () => {
|
||||
render(
|
||||
<>
|
||||
{/* @ts-expect-error a glyph-only button with no label has no accessible name */}
|
||||
<IconButton>
|
||||
<svg />
|
||||
</IconButton>
|
||||
<IconButton label="named">
|
||||
<svg />
|
||||
</IconButton>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "named" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pressable", () => {
|
||||
it("never submits a form, reads left-aligned, and paints a layer only on request", () => {
|
||||
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
||||
const { rerender } = render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Pressable>Row</Pressable>
|
||||
</form>,
|
||||
);
|
||||
const row = screen.getByRole("button", { name: "Row" });
|
||||
fireEvent.click(row);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(row.className).toContain("text-left");
|
||||
expect(row.className).not.toContain("state-layer");
|
||||
rerender(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Pressable layer>Row</Pressable>
|
||||
</form>,
|
||||
);
|
||||
expect(row.className).toContain("state-layer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DragHandle", () => {
|
||||
it("names the row it moves, and when unavailable stays focusable with its reason", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<DragHandle label="Move Intro" unavailable onClick={onClick} />);
|
||||
const h = screen.getByRole("button", { name: "Move Intro" });
|
||||
expect(h.hasAttribute("disabled")).toBe(false);
|
||||
expect(h.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(h.getAttribute("title")).toBe("Move Intro");
|
||||
fireEvent.click(h);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
|
||||
// A button that is only a glyph (docs/DESIGN.md A2): two sizes, r8, and a name it cannot ship without
|
||||
// (`label` is required: it is the accessible name and the tooltip). On a picture it is round and dark
|
||||
// translucent (`media`), readable on any thumbnail; round means "on media" and nothing else. The glyph
|
||||
// is sized here, so a row of them never mixes 14 and 16px icons.
|
||||
|
||||
type IconButtonVariant =
|
||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
||||
| "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
|
||||
| "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
|
||||
|
||||
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)
|
||||
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const VARIANTS: Record<IconButtonVariant, string> = {
|
||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||
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`,
|
||||
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 PRESSED = "aria-pressed:text-accent";
|
||||
|
||||
const SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
md: "size-8 [&>svg]:size-4",
|
||||
lg: "size-12 [&>svg]:size-6",
|
||||
};
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/** The accessible name and the tooltip. Required: the button renders no text. */
|
||||
label: string;
|
||||
/** The glyph (a lucide element); the primitive sizes it. */
|
||||
children: ReactNode;
|
||||
variant?: IconButtonVariant;
|
||||
size?: IconButtonSize;
|
||||
/** A toggle: sets aria-pressed and tints the glyph with the accent. */
|
||||
pressed?: boolean;
|
||||
/** Sits on a picture: round (A2). Implied by the `media` variant. */
|
||||
onMedia?: boolean;
|
||||
/** Busy: a spinner replaces the glyph, and the button is unavailable without losing the focus. */
|
||||
loading?: boolean;
|
||||
/** Leave the tooltip to a richer one the caller renders (a `Tooltip` around the button). */
|
||||
noTitle?: boolean;
|
||||
}
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
||||
{
|
||||
label,
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
pressed,
|
||||
onMedia = false,
|
||||
loading = false,
|
||||
noTitle = false,
|
||||
type,
|
||||
className,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<FocusSafeButton
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
busy={loading}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
title={noTitle ? undefined : label}
|
||||
className={clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
VARIANTS[variant],
|
||||
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && PRESSED,
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : children}
|
||||
</FocusSafeButton>
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { useBackToClose } from "../../lib/history";
|
||||
import { anyBlockingLayerAbove, pushLayer, removeLayer, isTopmost } from "../../lib/layerStack";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { trapTab, useDialogChrome } from "./dialogFocus";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import Toaster from "./Toaster";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
|
||||
@@ -122,15 +122,9 @@ export default function Modal({
|
||||
<h2 id={titleId} className="text-lg font-semibold leading-snug">
|
||||
{title}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
<IconButton label={t("common.close")} size="sm" variant="ghost" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="px-5 pb-5">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import clsx from "clsx";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
|
||||
// A click SURFACE: a button whose content is the control — a card, a list row, a poster tile, a menu
|
||||
// row, a thumbnail that opens its video. It is not an action button (that is `Button`, with a label and
|
||||
// a rung) and not a glyph (`IconButton`); it owns what every such surface needs and kept forgetting:
|
||||
// `type="button"` (never a stray form submit), a left-aligned body, the focus-safe disabled state, and,
|
||||
// on request, the B3 state layer for a row that has no hover of its own. `className` lays the surface
|
||||
// out; its paint is the surface's own (a card's glass, a row's layer).
|
||||
export const Pressable = forwardRef<
|
||||
HTMLButtonElement,
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
/** Paint the B3 hover/pressed layer (a plain row or menu item; a card has its own hover). */
|
||||
layer?: boolean;
|
||||
/** A tile with a caption under a picture reads centred; everything else left. */
|
||||
align?: "left" | "center";
|
||||
/** Working: unavailable and announced as busy. */
|
||||
busy?: boolean;
|
||||
}
|
||||
>(function Pressable({ type, layer = false, align = "left", className, ...rest }, ref) {
|
||||
return (
|
||||
<FocusSafeButton
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
className={clsx(
|
||||
align === "center" ? "text-center" : "text-left",
|
||||
layer && "state-layer",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -39,7 +39,7 @@ domain-free.
|
||||
| area | components |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
@@ -51,8 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
|
||||
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
||||
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` are the rungs, derived from what
|
||||
the app actually used. Pick a rung; do not hand-roll padding on a primitive.
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
|
||||
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
|
||||
padding on a primitive.
|
||||
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
|
||||
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
||||
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
||||
@@ -64,10 +65,10 @@ change at 1024x768, 820x1180 and a desktop size. That contract lives in these co
|
||||
button is fine with a mouse and not with a thumb, so the box grows where there is no precise
|
||||
pointer. It grows the real box rather than painting an invisible hit area: two expanded hit areas
|
||||
on neighbouring icon buttons would overlap, and an overlap fires the _wrong_ button.
|
||||
- `link` is excluded from the minimum: it is text inside a sentence.
|
||||
- The link variants are excluded from the minimum: they are text inside a sentence.
|
||||
|
||||
Controls outside this directory (chips, list rows, the player's overlay buttons) are **not** covered
|
||||
yet -- they are the next thing to bring under the contract, not evidence that there isn't one.
|
||||
E28 S3b brings every button in the app onto these primitives (the chips and the player's overlay
|
||||
buttons last), after which a raw `<button>` outside this directory is a lint error.
|
||||
|
||||
## Keeping it honest
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||
@@ -102,16 +102,15 @@ export default function SidePanel({
|
||||
{/* Row 1 — identity only: pin, icon, title (flexes + truncates last), count, customize.
|
||||
Module actions live on their own row below so the title always has room. */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||
<Button
|
||||
<IconButton
|
||||
label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 -ml-1"
|
||||
onClick={onToggleCollapse}
|
||||
title={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
className="-ml-1"
|
||||
>
|
||||
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
@@ -136,14 +135,14 @@ export default function SidePanel({
|
||||
</span>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<Button
|
||||
<IconButton
|
||||
label={t("sidebar.resetDefaults")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onReset}
|
||||
title={t("sidebar.resetDefaults")}
|
||||
icon={<RotateCcw className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user