diff --git a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx index 28374e0..34a0b08 100644 --- a/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx +++ b/apps/web/src/features/referrals/components/shared/stat-chart-card.tsx @@ -1,4 +1,5 @@ import { cn } from "@workspace/ui/lib/utils" +import type { NumericRole } from "@workspace/ui/components/numeric" import type { TimePeriod } from "../../hooks/use-referrals-data" import { formatUsd } from "@/shared/lib/format" diff --git a/packages/ui/src/components/button.test.tsx b/packages/ui/src/components/button.test.tsx index b205787..60ff4e5 100644 --- a/packages/ui/src/components/button.test.tsx +++ b/packages/ui/src/components/button.test.tsx @@ -1,7 +1,27 @@ import { describe, it, expect } from "vitest" -import { render } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import { axe } from "vitest-axe" -import { Button } from "./button" +import { Button, buttonVariants } from "./button" + +const VARIANTS = [ + "default", + "outline", + "secondary", + "ghost", + "destructive", + "link", +] as const + +const SIZES = [ + "default", + "xs", + "sm", + "lg", + "icon", + "icon-xs", + "icon-sm", + "icon-lg", +] as const describe("Button accessibility", () => { it("has no accessibility violations", async () => { @@ -26,3 +46,44 @@ describe("Button accessibility", () => { expect(results).toHaveNoViolations() }) }) + +describe("Button variants and sizes", () => { + it.each(VARIANTS)("renders the %s variant without violations", async (variant) => { + const { container } = render() + expect(await axe(container)).toHaveNoViolations() + }) + + it.each(SIZES)("renders the %s size without violations", async (size) => { + const isIconSize = size.startsWith("icon") + const { container } = render( + isIconSize ? ( + + ) : ( + + ) + ) + expect(await axe(container)).toHaveNoViolations() + }) + + it("every variant defines default, hover, active, and disabled classes", () => { + for (const variant of VARIANTS) { + const className = buttonVariants({ variant }) + expect(className).toMatch(/hover:/) + expect(className).toMatch(/active:/) + } + // disabled + focus-visible states live in the shared base classes. + expect(buttonVariants({})).toMatch(/disabled:/) + expect(buttonVariants({})).toMatch(/focus-visible:/) + }) + + it("keeps the icon-only button's accessible name from aria-label, not the icon", () => { + render( + + ) + expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 1d8948c..e83a3ae 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -9,16 +9,17 @@ const buttonVariants = cva( { variants: { variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", + default: + "bg-primary text-primary-foreground hover:bg-primary/80 active:not-aria-[haspopup]:bg-primary/70", outline: - "border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30", + "border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground active:not-aria-[haspopup]:bg-input/70 dark:bg-input/30 dark:active:not-aria-[haspopup]:bg-input/50", secondary: - "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground active:not-aria-[haspopup]:bg-secondary/70", ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground active:not-aria-[haspopup]:bg-muted/70 dark:hover:bg-muted/50 dark:active:not-aria-[haspopup]:bg-muted/40", destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 active:not-aria-[haspopup]:bg-destructive/30 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40 dark:active:not-aria-[haspopup]:bg-destructive/40", + link: "text-primary underline-offset-4 hover:underline active:not-aria-[haspopup]:text-primary/70", }, size: { default: diff --git a/packages/ui/src/components/loading-button.test.tsx b/packages/ui/src/components/loading-button.test.tsx new file mode 100644 index 0000000..6da1f84 --- /dev/null +++ b/packages/ui/src/components/loading-button.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest" +import { render, screen } from "@testing-library/react" +import { axe } from "vitest-axe" +import { LoadingButton } from "./loading-button" + +describe("LoadingButton", () => { + it("is disabled and aria-busy while loading, with text content", async () => { + const { container } = render( + + Stake + + ) + const button = screen.getByRole("button", { name: "Confirming..." }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute("aria-busy", "true") + expect(await axe(container)).toHaveNoViolations() + }) + + it("falls back to children as the loading label when loadingText is omitted", () => { + render(Stake) + expect(screen.getByRole("button", { name: "Stake" })).toBeInTheDocument() + }) + + it("is not disabled and not aria-busy when not loading", () => { + render(Stake) + const button = screen.getByRole("button", { name: "Stake" }) + expect(button).not.toBeDisabled() + expect(button).not.toHaveAttribute("aria-busy") + }) + + it("keeps the width-reserving spinner slot present (but hidden) when not loading", () => { + const { container } = render(Stake) + const slot = container.querySelector('[aria-hidden="true"]') + expect(slot).toBeInTheDocument() + expect(slot).toHaveClass("invisible") + }) + + it("supports an icon-only loading button, keeping its accessible name", async () => { + const { container } = render( + + + + ) + const button = screen.getByRole("button", { name: "Stake" }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute("aria-busy", "true") + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/loading-button.tsx b/packages/ui/src/components/loading-button.tsx index 633e2a7..0755fce 100644 --- a/packages/ui/src/components/loading-button.tsx +++ b/packages/ui/src/components/loading-button.tsx @@ -13,6 +13,10 @@ type LoadingButtonProps = ComponentProps & { * Button that owns its own pending presentation: disables itself, flags * `aria-busy` and swaps in a spinner. The spinner is `aria-hidden`, so the * accessible name is exactly `loadingText` (or the children). + * + * The spinner sits in a fixed-size slot that's always present (just hidden + * via `invisible` when not loading), so entering the loading state never + * changes the button's width. */ function LoadingButton({ isLoading = false, @@ -30,14 +34,16 @@ function LoadingButton({ className={cn("gap-1.5", className)} {...props} > - {isLoading ? ( - <> - - {loadingText ?? children} - - ) : ( - children - )} + + {isLoading ? (loadingText ?? children) : children} ) } diff --git a/packages/ui/src/components/numeric.tsx b/packages/ui/src/components/numeric.tsx index 5510237..f457adb 100644 --- a/packages/ui/src/components/numeric.tsx +++ b/packages/ui/src/components/numeric.tsx @@ -19,12 +19,12 @@ const numericVariants = cva('font-mono tabular-nums', { }, }) -type NumericRole = VariantProps['role'] +type LegacyNumericRole = VariantProps['role'] interface NumericProps { value: number | null | undefined format?: 'usd' | 'token' | 'pct' | 'number' - role?: NumericRole + role?: LegacyNumericRole decimals?: number compact?: boolean fallback?: string @@ -63,6 +63,69 @@ function formatNumber(value: number): string { return value.toLocaleString('en-US') } +/** + * Semantic tone for a numeric value already formatted by the caller. + * + * Unlike `Numeric` below, this primitive does not format or round its + * children — it only supplies the tabular-figure font treatment and the + * approved positive/negative/warning/accent/muted tones (DS-012). + */ +const numericTextVariants = cva("font-mono tabular-nums slashed-zero", { + variants: { + role: { + neutral: "text-foreground", + positive: "text-success", + negative: "text-destructive", + warning: "text-warning", + accent: "text-primary", + muted: "text-muted-foreground", + }, + size: { + "2xs": "text-[0.625rem] leading-4", + xs: "text-[0.6875rem] leading-4", + sm: "text-xs leading-5", + md: "text-[0.8125rem] leading-5", + base: "text-sm leading-5", + lg: "text-base leading-6", + }, + weight: { + normal: "font-normal", + medium: "font-medium", + semibold: "font-semibold", + bold: "font-bold", + }, + }, + defaultVariants: { + role: "neutral", + size: "base", + weight: "normal", + }, +}) + +type NumericRole = VariantProps['role'] + +type NumericTextProps = React.ComponentProps<'span'> & + VariantProps + +function NumericText({ role, size, weight, className, ...props }: NumericTextProps) { + return ( + + ) +} + +/** Derives a positive/negative/neutral role from a signed value's sign. */ +function numericRoleForValue( + value: number +): Extract { + if (value > 0) return 'positive' + if (value < 0) return 'negative' + return 'neutral' +} + function Numeric({ value, format = 'number', @@ -98,5 +161,5 @@ function Numeric({ ) } -export { Numeric, numericVariants } -export type { NumericRole, NumericProps } +export { Numeric, numericVariants, NumericText, numericTextVariants, numericRoleForValue } +export type { NumericRole, NumericProps, LegacyNumericRole, NumericTextProps } diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index 9c88ce3..a2724d4 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -17,7 +17,7 @@ function Spinner({ className, label, ...props }: SpinnerProps) { ? { role: "status", "aria-label": label } : { "aria-hidden": true })} className={cn( - "inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent", + "inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent motion-reduce:animate-none motion-reduce:opacity-60", className )} {...props}