Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
65 changes: 63 additions & 2 deletions packages/ui/src/components/button.test.tsx
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand All @@ -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(<Button variant={variant}>Action</Button>)
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 ? (
<Button size={size} aria-label="Action">
<svg viewBox="0 0 24 24" />
</Button>
) : (
<Button size={size}>Action</Button>
)
)
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(
<Button size="icon" aria-label="Settings">
<svg viewBox="0 0 24 24" />
</Button>
)
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument()
})
})
13 changes: 7 additions & 6 deletions packages/ui/src/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions packages/ui/src/components/loading-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<LoadingButton isLoading loadingText="Confirming...">
Stake
</LoadingButton>
)
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(<LoadingButton isLoading>Stake</LoadingButton>)
expect(screen.getByRole("button", { name: "Stake" })).toBeInTheDocument()
})

it("is not disabled and not aria-busy when not loading", () => {
render(<LoadingButton>Stake</LoadingButton>)
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(<LoadingButton>Stake</LoadingButton>)
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(
<LoadingButton isLoading size="icon" aria-label="Stake">
<svg viewBox="0 0 24 24" />
</LoadingButton>
)
const button = screen.getByRole("button", { name: "Stake" })
expect(button).toBeDisabled()
expect(button).toHaveAttribute("aria-busy", "true")
expect(await axe(container)).toHaveNoViolations()
})
})
22 changes: 14 additions & 8 deletions packages/ui/src/components/loading-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ type LoadingButtonProps = ComponentProps<typeof Button> & {
* 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,
Expand All @@ -30,14 +34,16 @@ function LoadingButton({
className={cn("gap-1.5", className)}
{...props}
>
{isLoading ? (
<>
<Spinner />
{loadingText ?? children}
</>
) : (
children
)}
<span
aria-hidden="true"
className={cn(
"inline-flex size-3 shrink-0 items-center justify-center",
!isLoading && "invisible"
)}
>
{isLoading && <Spinner />}
</span>
{isLoading ? (loadingText ?? children) : children}
</Button>
)
}
Expand Down
71 changes: 67 additions & 4 deletions packages/ui/src/components/numeric.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ const numericVariants = cva('font-mono tabular-nums', {
},
})

type NumericRole = VariantProps<typeof numericVariants>['role']
type LegacyNumericRole = VariantProps<typeof numericVariants>['role']

interface NumericProps {
value: number | null | undefined
format?: 'usd' | 'token' | 'pct' | 'number'
role?: NumericRole
role?: LegacyNumericRole
decimals?: number
compact?: boolean
fallback?: string
Expand Down Expand Up @@ -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<typeof numericTextVariants>['role']

type NumericTextProps = React.ComponentProps<'span'> &
VariantProps<typeof numericTextVariants>

function NumericText({ role, size, weight, className, ...props }: NumericTextProps) {
return (
<span
data-slot="numeric-text"
className={cn(numericTextVariants({ role, size, weight }), className)}
{...props}
/>
)
}

/** Derives a positive/negative/neutral role from a signed value's sign. */
function numericRoleForValue(
value: number
): Extract<NumericRole, 'positive' | 'negative' | 'neutral'> {
if (value > 0) return 'positive'
if (value < 0) return 'negative'
return 'neutral'
}

function Numeric({
value,
format = 'number',
Expand Down Expand Up @@ -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 }
2 changes: 1 addition & 1 deletion packages/ui/src/components/spinner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading