feat(e10): re-aim the Button variants at the paint the app actually uses, and migrate the first 15 sites
Counting the un-migrated call sites showed the primitive and the app had drifted into two different looks: the majority of hand-rolled buttons hover to accent (outline) and fill with card (ghost), while Button hovered to fg and filled with surface. That split is the inconsistency this epic exists to remove, so the primitive follows the majority rather than the other way round -- 16 existing call sites shift slightly, ~90 hand-rolled ones stop being special. Adds the variants the call sites prove are missing (outline-accent, outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon family, and a size=none so a link owns no button padding; renames danger-outline to danger-soft so it cannot be confused with outline-danger (red at rest vs red on hover). Two new tests lock the link-size default and the rule that a variant never adds a second font-weight utility -- without tailwind-merge, two font-* utilities are decided by stylesheet order, not class order, so that is a real hazard. Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists, PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone deliberately: conditional-paint sites, video-overlay white buttons, drag handles, chip removers and card-click targets -- the families Button's own docstring excludes. 3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users, Access requests and Playlists at 1280x900, no console errors.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
@@ -244,14 +245,16 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.roles.makeAdmin")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
|
||||
icon={<Shield className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={
|
||||
@@ -264,36 +267,36 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
|
||||
u.is_suspended
|
||||
? "border-warn/40 text-warn hover:bg-warn/10"
|
||||
: "border-border text-muted hover:text-warn"
|
||||
}`}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-40 transition"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -435,25 +438,28 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-fg disabled:opacity-50 transition"
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
<Tooltip hint={t("users.pendingAccess.activateHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={onActivate}
|
||||
disabled={busy}
|
||||
className="shrink-0 px-2.5 py-1.5 rounded-lg border border-accent/40 text-accent text-xs font-medium hover:bg-accent/10 disabled:opacity-50 transition"
|
||||
>
|
||||
{t("users.pendingAccess.activate")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -673,12 +679,14 @@ function AdminInvites() {
|
||||
placeholder={t("settings.invites.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
||||
variant="glass"
|
||||
className="shrink-0"
|
||||
icon={<UserPlus className="w-4 h-4" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
|
||||
</button>
|
||||
{t("settings.invites.add")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{decided.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Trash2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Section } from "./ui/form";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import DataTable from "./ui/DataTable";
|
||||
@@ -72,14 +73,15 @@ export default function AuditLog() {
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsLeading={
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
onClick={onClear}
|
||||
disabled={clear.isPending}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-lg border border-border text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{t("audit.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
emptyText={t("audit.empty")}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
@@ -489,13 +490,15 @@ export default function Channels() {
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 uppercase tracking-wide"
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
icon={<Pencil className="h-3 w-3" />}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
@@ -518,12 +521,9 @@ export default function Channels() {
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { cycleSort, type SortState } from "../lib/columnSort";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { applyView, sameOrder } from "../lib/playlistView";
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import { Button } from "./ui/Button";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useChoice, useConfirm } from "./ConfirmProvider";
|
||||
@@ -585,12 +586,9 @@ export default function Playlists() {
|
||||
<AlertTriangle className="w-4 h-4 text-danger" />
|
||||
{t("playlists.loadFailed")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => detailQuery.refetch()}
|
||||
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
|
||||
{t("common.retry")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -703,25 +701,26 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{linked && detail.dirty && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={revertToYoutube}
|
||||
disabled={reverting}
|
||||
title={t("playlists.revert")}
|
||||
aria-label={t("playlists.revert")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{editable && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
onClick={deletePlaylist}
|
||||
title={t("playlists.delete")}
|
||||
aria-label={t("playlists.delete")}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-danger hover:border-danger/50 transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
icon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,12 +754,14 @@ export default function Playlists() {
|
||||
</button>
|
||||
)}
|
||||
{canSaveOrder && (
|
||||
<button
|
||||
<Button
|
||||
variant="outline-accent"
|
||||
size="xs"
|
||||
onClick={saveOrder}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
|
||||
icon={<Check className="w-3.5 h-3.5" />}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
|
||||
</button>
|
||||
{t("playlists.saveOrder")}
|
||||
</Button>
|
||||
)}
|
||||
{/* ONE sentence that names the state. Three loose texts used to pile up here
|
||||
(reset link + save hint + "reordering is off"), and none of them answered the
|
||||
|
||||
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { Button } from "./ui/Button";
|
||||
import { type PanelItem } from "./ui/PanelGroups";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import { type SortOption } from "../lib/columnSort";
|
||||
@@ -147,14 +148,10 @@ export default function PlaylistsRail({
|
||||
title: t("playlists.manageGroup"),
|
||||
body: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<Button variant="outline" size="xs" fullWidth onClick={syncYoutube} disabled={syncing}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
</Button>
|
||||
{playlists.length > 1 && (
|
||||
<SortControl
|
||||
label={t("playlists.sortLabel")}
|
||||
|
||||
@@ -324,12 +324,9 @@ function AccessRow({
|
||||
</span>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.account.enableHint")}>
|
||||
<button
|
||||
onClick={onEnable}
|
||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||
>
|
||||
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
||||
{t("settings.account.enable")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -676,7 +673,7 @@ export function DownloadAuth() {
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{h.configured && (
|
||||
<Button
|
||||
variant="danger-outline"
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
onClick={() => onRemove(h.host_key)}
|
||||
disabled={remove.isPending}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
@@ -203,13 +204,15 @@ export default function TagManager({
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
className="shrink-0 px-3 py-1.5 rounded-md border border-border text-sm text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-40 transition"
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -71,6 +71,33 @@ describe("Button primitive (S5)", () => {
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a link gets no button padding, and an explicit size still wins", () => {
|
||||
// The size default is variant-dependent (link → "none"). Locking it because the failure is
|
||||
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
|
||||
const { rerender } = render(<Button variant="link">Show all</Button>);
|
||||
let btn = screen.getByRole("button", { name: "Show all" });
|
||||
expect(btn.className).toContain("text-accent");
|
||||
expect(btn.className).not.toContain("px-3");
|
||||
rerender(
|
||||
<Button variant="link" size="sm">
|
||||
Show all
|
||||
</Button>,
|
||||
);
|
||||
btn = screen.getByRole("button", { name: "Show all" });
|
||||
expect(btn.className).toContain("px-3");
|
||||
});
|
||||
|
||||
it("keeps one font-weight utility whatever the variant", () => {
|
||||
// Two font-* utilities of equal specificity are decided by stylesheet order, not class order,
|
||||
// so a variant must never add a second one (there is no tailwind-merge here).
|
||||
for (const variant of ["primary", "link", "glass", "outline"] as const) {
|
||||
const { unmount } = render(<Button variant={variant}>X</Button>);
|
||||
const cls = screen.getByRole("button", { name: "X" }).className;
|
||||
expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("fullWidth adds the w-full utility", () => {
|
||||
render(<Button fullWidth>Wide</Button>);
|
||||
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
||||
|
||||
@@ -17,25 +17,45 @@ 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-outline"; // bordered danger — a destructive action that shouldn't shout
|
||||
| "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" // 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: "btn-primary",
|
||||
danger: "btn-danger",
|
||||
outline: "border border-border text-muted hover:text-fg hover:border-fg/40",
|
||||
ghost: "text-muted hover:text-fg hover:bg-surface",
|
||||
// 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: "border border-border text-muted hover:text-accent hover:border-accent",
|
||||
"outline-accent": "border border-accent/40 text-accent 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": "border border-border text-muted hover:text-danger hover:border-danger/40",
|
||||
"outline-warn": "border border-warn/40 text-warn hover:bg-warn/10",
|
||||
ghost: "text-muted hover:text-fg hover:bg-card",
|
||||
glass: "glass-card glass-hover hover:text-accent",
|
||||
"danger-outline": "border border-danger/60 text-danger hover:bg-danger/10 hover:border-danger",
|
||||
"danger-soft": "border border-danger/60 text-danger hover:bg-danger/10 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: "text-accent hover:underline",
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
@@ -44,6 +64,8 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
@@ -51,6 +73,7 @@ const BASE =
|
||||
|
||||
export interface ButtonProps 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;
|
||||
@@ -67,7 +90,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
size,
|
||||
icon,
|
||||
iconOnly = false,
|
||||
label,
|
||||
@@ -81,6 +104,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
},
|
||||
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");
|
||||
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
|
||||
return (
|
||||
<button
|
||||
@@ -93,7 +120,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
|
||||
aria-busy={loading || undefined}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(BASE, VARIANTS[variant], SIZES[size], fullWidth && "w-full", className)}
|
||||
className={clsx(
|
||||
BASE,
|
||||
VARIANTS[variant],
|
||||
SIZES[resolvedSize],
|
||||
fullWidth && "w-full",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{leading}
|
||||
|
||||
Reference in New Issue
Block a user