Skip to content

Commit e15c6d6

Browse files
authored
Merge pull request SO4-Markets#445 from tosin-zoffun/feat/issues-398-399-400-401
feat(ui): NumericText primitive, button variant polish, loading state
2 parents 8c811d5 + 114b2f1 commit e15c6d6

7 files changed

Lines changed: 202 additions & 21 deletions

File tree

apps/web/src/features/referrals/components/shared/stat-chart-card.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cn } from "@workspace/ui/lib/utils"
2+
import type { NumericRole } from "@workspace/ui/components/numeric"
23
import type { TimePeriod } from "../../hooks/use-referrals-data"
34
import { formatUsd } from "@/shared/lib/format"
45

packages/ui/src/components/button.test.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,27 @@
11
import { describe, it, expect } from "vitest"
2-
import { render } from "@testing-library/react"
2+
import { render, screen } from "@testing-library/react"
33
import { axe } from "vitest-axe"
4-
import { Button } from "./button"
4+
import { Button, buttonVariants } from "./button"
5+
6+
const VARIANTS = [
7+
"default",
8+
"outline",
9+
"secondary",
10+
"ghost",
11+
"destructive",
12+
"link",
13+
] as const
14+
15+
const SIZES = [
16+
"default",
17+
"xs",
18+
"sm",
19+
"lg",
20+
"icon",
21+
"icon-xs",
22+
"icon-sm",
23+
"icon-lg",
24+
] as const
525

626
describe("Button accessibility", () => {
727
it("has no accessibility violations", async () => {
@@ -26,3 +46,44 @@ describe("Button accessibility", () => {
2646
expect(results).toHaveNoViolations()
2747
})
2848
})
49+
50+
describe("Button variants and sizes", () => {
51+
it.each(VARIANTS)("renders the %s variant without violations", async (variant) => {
52+
const { container } = render(<Button variant={variant}>Action</Button>)
53+
expect(await axe(container)).toHaveNoViolations()
54+
})
55+
56+
it.each(SIZES)("renders the %s size without violations", async (size) => {
57+
const isIconSize = size.startsWith("icon")
58+
const { container } = render(
59+
isIconSize ? (
60+
<Button size={size} aria-label="Action">
61+
<svg viewBox="0 0 24 24" />
62+
</Button>
63+
) : (
64+
<Button size={size}>Action</Button>
65+
)
66+
)
67+
expect(await axe(container)).toHaveNoViolations()
68+
})
69+
70+
it("every variant defines default, hover, active, and disabled classes", () => {
71+
for (const variant of VARIANTS) {
72+
const className = buttonVariants({ variant })
73+
expect(className).toMatch(/hover:/)
74+
expect(className).toMatch(/active:/)
75+
}
76+
// disabled + focus-visible states live in the shared base classes.
77+
expect(buttonVariants({})).toMatch(/disabled:/)
78+
expect(buttonVariants({})).toMatch(/focus-visible:/)
79+
})
80+
81+
it("keeps the icon-only button's accessible name from aria-label, not the icon", () => {
82+
render(
83+
<Button size="icon" aria-label="Settings">
84+
<svg viewBox="0 0 24 24" />
85+
</Button>
86+
)
87+
expect(screen.getByRole("button", { name: "Settings" })).toBeInTheDocument()
88+
})
89+
})

packages/ui/src/components/button.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,17 @@ const buttonVariants = cva(
99
{
1010
variants: {
1111
variant: {
12-
default: "bg-primary text-primary-foreground hover:bg-primary/80",
12+
default:
13+
"bg-primary text-primary-foreground hover:bg-primary/80 active:not-aria-[haspopup]:bg-primary/70",
1314
outline:
14-
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
15+
"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",
1516
secondary:
16-
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
17+
"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",
1718
ghost:
18-
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
19+
"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",
1920
destructive:
20-
"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",
21-
link: "text-primary underline-offset-4 hover:underline",
21+
"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",
22+
link: "text-primary underline-offset-4 hover:underline active:not-aria-[haspopup]:text-primary/70",
2223
},
2324
size: {
2425
default:
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, it, expect } from "vitest"
2+
import { render, screen } from "@testing-library/react"
3+
import { axe } from "vitest-axe"
4+
import { LoadingButton } from "./loading-button"
5+
6+
describe("LoadingButton", () => {
7+
it("is disabled and aria-busy while loading, with text content", async () => {
8+
const { container } = render(
9+
<LoadingButton isLoading loadingText="Confirming...">
10+
Stake
11+
</LoadingButton>
12+
)
13+
const button = screen.getByRole("button", { name: "Confirming..." })
14+
expect(button).toBeDisabled()
15+
expect(button).toHaveAttribute("aria-busy", "true")
16+
expect(await axe(container)).toHaveNoViolations()
17+
})
18+
19+
it("falls back to children as the loading label when loadingText is omitted", () => {
20+
render(<LoadingButton isLoading>Stake</LoadingButton>)
21+
expect(screen.getByRole("button", { name: "Stake" })).toBeInTheDocument()
22+
})
23+
24+
it("is not disabled and not aria-busy when not loading", () => {
25+
render(<LoadingButton>Stake</LoadingButton>)
26+
const button = screen.getByRole("button", { name: "Stake" })
27+
expect(button).not.toBeDisabled()
28+
expect(button).not.toHaveAttribute("aria-busy")
29+
})
30+
31+
it("keeps the width-reserving spinner slot present (but hidden) when not loading", () => {
32+
const { container } = render(<LoadingButton>Stake</LoadingButton>)
33+
const slot = container.querySelector('[aria-hidden="true"]')
34+
expect(slot).toBeInTheDocument()
35+
expect(slot).toHaveClass("invisible")
36+
})
37+
38+
it("supports an icon-only loading button, keeping its accessible name", async () => {
39+
const { container } = render(
40+
<LoadingButton isLoading size="icon" aria-label="Stake">
41+
<svg viewBox="0 0 24 24" />
42+
</LoadingButton>
43+
)
44+
const button = screen.getByRole("button", { name: "Stake" })
45+
expect(button).toBeDisabled()
46+
expect(button).toHaveAttribute("aria-busy", "true")
47+
expect(await axe(container)).toHaveNoViolations()
48+
})
49+
})

packages/ui/src/components/loading-button.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ type LoadingButtonProps = ComponentProps<typeof Button> & {
1313
* Button that owns its own pending presentation: disables itself, flags
1414
* `aria-busy` and swaps in a spinner. The spinner is `aria-hidden`, so the
1515
* accessible name is exactly `loadingText` (or the children).
16+
*
17+
* The spinner sits in a fixed-size slot that's always present (just hidden
18+
* via `invisible` when not loading), so entering the loading state never
19+
* changes the button's width.
1620
*/
1721
function LoadingButton({
1822
isLoading = false,
@@ -30,14 +34,16 @@ function LoadingButton({
3034
className={cn("gap-1.5", className)}
3135
{...props}
3236
>
33-
{isLoading ? (
34-
<>
35-
<Spinner />
36-
{loadingText ?? children}
37-
</>
38-
) : (
39-
children
40-
)}
37+
<span
38+
aria-hidden="true"
39+
className={cn(
40+
"inline-flex size-3 shrink-0 items-center justify-center",
41+
!isLoading && "invisible"
42+
)}
43+
>
44+
{isLoading && <Spinner />}
45+
</span>
46+
{isLoading ? (loadingText ?? children) : children}
4147
</Button>
4248
)
4349
}

packages/ui/src/components/numeric.tsx

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ const numericVariants = cva('font-mono tabular-nums', {
1919
},
2020
})
2121

22-
type NumericRole = VariantProps<typeof numericVariants>['role']
22+
type LegacyNumericRole = VariantProps<typeof numericVariants>['role']
2323

2424
interface NumericProps {
2525
value: number | null | undefined
2626
format?: 'usd' | 'token' | 'pct' | 'number'
27-
role?: NumericRole
27+
role?: LegacyNumericRole
2828
decimals?: number
2929
compact?: boolean
3030
fallback?: string
@@ -63,6 +63,69 @@ function formatNumber(value: number): string {
6363
return value.toLocaleString('en-US')
6464
}
6565

66+
/**
67+
* Semantic tone for a numeric value already formatted by the caller.
68+
*
69+
* Unlike `Numeric` below, this primitive does not format or round its
70+
* children — it only supplies the tabular-figure font treatment and the
71+
* approved positive/negative/warning/accent/muted tones (DS-012).
72+
*/
73+
const numericTextVariants = cva("font-mono tabular-nums slashed-zero", {
74+
variants: {
75+
role: {
76+
neutral: "text-foreground",
77+
positive: "text-success",
78+
negative: "text-destructive",
79+
warning: "text-warning",
80+
accent: "text-primary",
81+
muted: "text-muted-foreground",
82+
},
83+
size: {
84+
"2xs": "text-[0.625rem] leading-4",
85+
xs: "text-[0.6875rem] leading-4",
86+
sm: "text-xs leading-5",
87+
md: "text-[0.8125rem] leading-5",
88+
base: "text-sm leading-5",
89+
lg: "text-base leading-6",
90+
},
91+
weight: {
92+
normal: "font-normal",
93+
medium: "font-medium",
94+
semibold: "font-semibold",
95+
bold: "font-bold",
96+
},
97+
},
98+
defaultVariants: {
99+
role: "neutral",
100+
size: "base",
101+
weight: "normal",
102+
},
103+
})
104+
105+
type NumericRole = VariantProps<typeof numericTextVariants>['role']
106+
107+
type NumericTextProps = React.ComponentProps<'span'> &
108+
VariantProps<typeof numericTextVariants>
109+
110+
function NumericText({ role, size, weight, className, ...props }: NumericTextProps) {
111+
return (
112+
<span
113+
data-slot="numeric-text"
114+
className={cn(numericTextVariants({ role, size, weight }), className)}
115+
{...props}
116+
/>
117+
)
118+
}
119+
120+
/** Derives a positive/negative/neutral role from a signed value's sign. */
121+
function numericRoleForValue(
122+
value: number
123+
): Extract<NumericRole, 'positive' | 'negative' | 'neutral'> {
124+
if (value > 0) return 'positive'
125+
if (value < 0) return 'negative'
126+
return 'neutral'
127+
}
128+
66129
function Numeric({
67130
value,
68131
format = 'number',
@@ -98,5 +161,5 @@ function Numeric({
98161
)
99162
}
100163

101-
export { Numeric, numericVariants }
102-
export type { NumericRole, NumericProps }
164+
export { Numeric, numericVariants, NumericText, numericTextVariants, numericRoleForValue }
165+
export type { NumericRole, NumericProps, LegacyNumericRole, NumericTextProps }

packages/ui/src/components/spinner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function Spinner({ className, label, ...props }: SpinnerProps) {
1717
? { role: "status", "aria-label": label }
1818
: { "aria-hidden": true })}
1919
className={cn(
20-
"inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent",
20+
"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",
2121
className
2222
)}
2323
{...props}

0 commit comments

Comments
 (0)