@smicolon/fasla-ui 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +91 -23
  4. package/dist/registry/index.js +31 -2
  5. package/dist/tokens/index.d.ts +75 -6
  6. package/dist/tokens/index.js +59 -20
  7. package/package.json +4 -1
  8. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  9. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  10. package/registry/blocks/data-table/DataTable.tsx +53 -16
  11. package/registry/blocks/data-table/index.ts +1 -0
  12. package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
  13. package/registry/blocks/empty-state/EmptyState.tsx +22 -31
  14. package/registry/blocks/form-section/FormSection.tsx +1 -1
  15. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  16. package/registry/blocks/navbar/Navbar.tsx +8 -23
  17. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  18. package/registry/blocks/sidebar/Sidebar.tsx +9 -16
  19. package/registry/blocks/stats-card/StatsCard.tsx +9 -27
  20. package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
  21. package/registry/effects/border-beam/border-beam.tsx +77 -53
  22. package/registry/effects/effects.test.tsx +266 -0
  23. package/registry/effects/glow-card/glow-card.tsx +68 -43
  24. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  25. package/registry/effects/spotlight/spotlight.tsx +73 -29
  26. package/registry/effects/text-reveal/index.ts +1 -0
  27. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  28. package/registry/effects/text-reveal/text-reveal.tsx +68 -21
  29. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  30. package/registry/text-props-undefined.test.tsx +73 -0
  31. package/registry/ui/avatar/avatar.test.tsx +316 -0
  32. package/registry/ui/avatar/avatar.tsx +235 -108
  33. package/registry/ui/avatar/index.ts +1 -1
  34. package/registry/ui/badge/badge.test.tsx +267 -32
  35. package/registry/ui/badge/badge.tsx +233 -21
  36. package/registry/ui/button/button.test.tsx +6 -0
  37. package/registry/ui/button/button.tsx +6 -23
  38. package/registry/ui/card/card.tsx +1 -1
  39. package/registry/ui/checkbox/checkbox.tsx +13 -26
  40. package/registry/ui/combobox/combobox.test.tsx +43 -0
  41. package/registry/ui/combobox/combobox.tsx +27 -55
  42. package/registry/ui/input/input.test.tsx +2 -2
  43. package/registry/ui/input/input.tsx +4 -4
  44. package/registry/ui/radio/index.ts +1 -0
  45. package/registry/ui/radio/radio.test.tsx +248 -0
  46. package/registry/ui/radio/radio.tsx +208 -0
  47. package/registry/ui/select/select.tsx +4 -14
  48. package/registry/ui/skeleton/skeleton.tsx +1 -1
  49. package/registry/ui/status-indicator/index.ts +6 -0
  50. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  51. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  52. package/registry/ui/switch/index.ts +1 -1
  53. package/registry/ui/switch/switch.test.tsx +323 -0
  54. package/registry/ui/switch/switch.tsx +254 -57
  55. package/registry/ui/textarea/textarea.tsx +1 -1
  56. package/registry.json +78 -15
  57. package/tailwind-preset.ts +143 -0
@@ -1,51 +1,286 @@
1
- import { describe, it, expect } from "vitest"
2
- import { render, screen } from "@testing-library/react"
3
- import { Badge } from "./badge"
1
+ import * as React from "react"
2
+ import { readFileSync } from "node:fs"
3
+ import { dirname, resolve } from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+ import { beforeAll, describe, it, expect, vi } from "vitest"
6
+ import { render, screen, fireEvent } from "@testing-library/react"
7
+ import { Badge, type BadgeProps } from "./badge"
8
+
9
+ const SOURCE = readFileSync(
10
+ resolve(dirname(fileURLToPath(import.meta.url)), "badge.tsx"),
11
+ "utf8"
12
+ )
13
+
14
+ const Star = () => (
15
+ <svg data-testid="icon" viewBox="0 0 24 24">
16
+ <path d="M12 2 15 9 22 9 16 14 18 21 12 17 6 21 8 14 2 9 9 9Z" />
17
+ </svg>
18
+ )
19
+
20
+ function badge(props: Partial<BadgeProps> = {}) {
21
+ render(
22
+ <Badge data-testid="badge" {...props}>
23
+ {props.children ?? "Badge"}
24
+ </Badge>
25
+ )
26
+ return screen.getByTestId("badge")
27
+ }
4
28
 
5
29
  describe("Badge", () => {
6
- it("renders with default props", () => {
7
- render(<Badge>Default</Badge>)
8
- expect(screen.getByText("Default")).toBeInTheDocument()
30
+ it("renders a span with Figma's defaults: Solid, Primary, sm, Rounded", () => {
31
+ const el = badge()
32
+ expect(el.tagName).toBe("SPAN")
33
+ expect(el).toHaveClass("bg-primary", "text-primary-foreground", "rounded-full")
34
+ expect(el).toHaveClass("min-h-5", "px-1.5", "py-0.5", "gap-0.5", "text-xs", "font-medium")
35
+ })
36
+
37
+ describe("variant × tone", () => {
38
+ const cases: Array<[BadgeProps["variant"], BadgeProps["tone"], string[]]> = [
39
+ ["solid", "primary", ["bg-primary", "text-primary-foreground"]],
40
+ ["solid", "secondary", ["bg-secondary", "text-secondary-foreground"]],
41
+ ["solid", "info", ["bg-info", "text-info-foreground"]],
42
+ ["solid", "success", ["bg-success", "text-success-foreground"]],
43
+ ["solid", "warning", ["bg-warning", "text-warning-foreground"]],
44
+ ["solid", "destructive", ["bg-destructive", "text-destructive-foreground"]],
45
+ ["soft", "primary", ["from-soft-primary", "to-soft-primary", "text-primary"]],
46
+ // Figma binds Soft Secondary's text to `foreground`, unlike Outline Secondary.
47
+ ["soft", "secondary", ["from-soft-secondary", "to-soft-secondary", "text-foreground"]],
48
+ ["soft", "info", ["from-soft-info", "to-soft-info", "text-info"]],
49
+ ["soft", "success", ["from-soft-success", "to-soft-success", "text-success"]],
50
+ ["soft", "warning", ["from-soft-warning", "to-soft-warning", "text-warning"]],
51
+ ["soft", "destructive", ["from-soft-destructive", "to-soft-destructive", "text-destructive"]],
52
+ ["outline", "primary", ["ring-primary", "text-primary"]],
53
+ ["outline", "secondary", ["ring-secondary", "text-secondary-foreground"]],
54
+ ["outline", "info", ["ring-info", "text-info"]],
55
+ ["outline", "success", ["ring-success", "text-success"]],
56
+ ["outline", "warning", ["ring-warning", "text-warning"]],
57
+ ["outline", "destructive", ["ring-destructive", "text-destructive"]],
58
+ ]
59
+
60
+ it.each(cases)("%s / %s binds the Figma tokens", (variant, tone, classes) => {
61
+ expect(badge({ variant, tone })).toHaveClass(...classes)
62
+ })
63
+
64
+ it("lays the Soft tint over card, as Figma stacks the two fills", () => {
65
+ expect(badge({ variant: "soft" })).toHaveClass("bg-card", "bg-gradient-to-r")
66
+ })
67
+
68
+ it("draws Outline's stroke inside, so it is the same size as Solid", () => {
69
+ const el = badge({ variant: "outline" })
70
+ expect(el).toHaveClass("ring-1", "ring-inset")
71
+ expect(el.className).not.toMatch(/\bborder\b/)
72
+ })
73
+
74
+ it("uses no raw colours", () => {
75
+ expect(SOURCE).not.toMatch(/#[0-9a-f]{3,8}\b/i)
76
+ expect(SOURCE).not.toMatch(/-(green|yellow|red|blue|sky|amber)-\d{2,3}/)
77
+ })
78
+ })
79
+
80
+ describe("size", () => {
81
+ it.each([
82
+ ["sm", ["min-h-5", "px-1.5", "py-0.5", "gap-0.5"]],
83
+ ["md", ["min-h-[1.375rem]", "px-2", "py-0.5", "gap-1"]],
84
+ ["lg", ["min-h-[1.625rem]", "px-3", "py-1", "gap-1.5"]],
85
+ ] as const)("%s matches Figma's padding and gap", (size, classes) => {
86
+ expect(badge({ size })).toHaveClass(...classes)
87
+ })
88
+
89
+ it("sets minimum heights, not fixed ones, so Arabic can grow", () => {
90
+ expect(SOURCE).not.toMatch(/(?<![\w-])h-(5|\[1\.375rem\]|\[1\.625rem\])/)
91
+ })
9
92
  })
10
93
 
11
- it("renders with default variant styling", () => {
12
- render(<Badge data-testid="badge">Default</Badge>)
13
- expect(screen.getByTestId("badge")).toHaveClass("bg-primary")
94
+ describe("radius", () => {
95
+ it("Rounded is a pill", () => {
96
+ expect(badge({ radius: "rounded" })).toHaveClass("rounded-full")
97
+ })
98
+
99
+ it("Standard binds border radius/sm, never a literal 6px", () => {
100
+ expect(badge({ radius: "standard" })).toHaveClass("rounded-[var(--radius-sm)]")
101
+ expect(SOURCE).not.toMatch(/\[6px\]|:\s*6px/)
102
+ })
103
+ })
104
+
105
+ describe("icon and avatar", () => {
106
+ it("wraps the icon at 12px and hides it from assistive technology", () => {
107
+ badge({ icon: <Star /> })
108
+ const wrapper = screen.getByTestId("icon").parentElement!
109
+ expect(wrapper).toHaveAttribute("aria-hidden", "true")
110
+ expect(wrapper).toHaveClass("size-3", "[&>svg]:size-3")
111
+ })
112
+
113
+ it("clips the avatar to a 12px circle and keeps it accessible", () => {
114
+ badge({ avatar: <img src="/a.png" alt="Layla" /> })
115
+ const img = screen.getByAltText("Layla")
116
+ expect(img.parentElement).toHaveClass("size-3", "rounded-full", "overflow-hidden")
117
+ expect(img.parentElement).not.toHaveAttribute("aria-hidden")
118
+ })
119
+
120
+ it("allows both at once, as Figma's two independent booleans do", () => {
121
+ badge({ icon: <Star />, avatar: <img src="/a.png" alt="Layla" /> })
122
+ expect(screen.getByTestId("icon")).toBeInTheDocument()
123
+ expect(screen.getByAltText("Layla")).toBeInTheDocument()
124
+ })
125
+
126
+ it("renders icon, avatar, label, close in DOM order, which dir mirrors", () => {
127
+ const el = badge({
128
+ icon: <Star />,
129
+ avatar: <img src="/a.png" alt="Layla" />,
130
+ onClose: () => {},
131
+ })
132
+ const kids = Array.from(el.children)
133
+ expect(kids[0]).toContainElement(screen.getByTestId("icon"))
134
+ expect(kids[1]).toContainElement(screen.getByAltText("Layla"))
135
+ expect(kids[2]).toHaveTextContent("Badge")
136
+ expect(kids[3]?.tagName).toBe("BUTTON")
137
+ })
14
138
  })
15
139
 
16
- it("renders with different variants", () => {
17
- const { rerender } = render(<Badge variant="default" data-testid="badge">Default</Badge>)
18
- expect(screen.getByTestId("badge")).toHaveClass("bg-primary")
140
+ describe("close", () => {
141
+ /*
142
+ * The default name is two hidden runs, and CSS keeps the one matching the
143
+ * nearest `lang`. jsdom loads no Tailwind, so inject exactly the compiled
144
+ * rules the name depends on; the accessible-name computation then drops
145
+ * the `display: none` run, as a browser does.
146
+ */
147
+ beforeAll(() => {
148
+ const style = document.createElement("style")
149
+ style.textContent = [
150
+ ".hidden { display: none }",
151
+ ".\\[\\&\\:lang\\(ar\\)\\]\\:hidden:lang(ar) { display: none }",
152
+ ".\\[\\&\\:lang\\(ar\\)\\]\\:inline:lang(ar) { display: inline }",
153
+ ].join("\n")
154
+ document.head.append(style)
155
+ })
19
156
 
20
- rerender(<Badge variant="secondary" data-testid="badge">Secondary</Badge>)
21
- expect(screen.getByTestId("badge")).toHaveClass("bg-secondary")
157
+ it("renders no button without onClose", () => {
158
+ badge()
159
+ expect(screen.queryByRole("button")).not.toBeInTheDocument()
160
+ })
22
161
 
23
- rerender(<Badge variant="destructive" data-testid="badge">Destructive</Badge>)
24
- expect(screen.getByTestId("badge")).toHaveClass("bg-destructive")
162
+ it("is a real button that calls onClose", () => {
163
+ const onClose = vi.fn()
164
+ badge({ onClose, children: "Cairo" })
165
+ const button = screen.getByRole("button", { name: "Remove Cairo" })
166
+ expect(button).toHaveAttribute("type", "button")
167
+ fireEvent.click(button)
168
+ expect(onClose).toHaveBeenCalledTimes(1)
169
+ })
25
170
 
26
- rerender(<Badge variant="outline" data-testid="badge">Outline</Badge>)
27
- expect(screen.getByTestId("badge")).toHaveClass("text-foreground")
171
+ it("falls back to a bare verb when the label is not plain text", () => {
172
+ render(
173
+ <Badge onClose={() => {}}>
174
+ <strong>Cairo</strong>
175
+ </Badge>
176
+ )
177
+ expect(screen.getByRole("button", { name: "Remove" })).toBeInTheDocument()
178
+ })
28
179
 
29
- rerender(<Badge variant="success" data-testid="badge">Success</Badge>)
30
- expect(screen.getByTestId("badge")).toHaveClass("bg-green-500")
180
+ it("names itself in Arabic when the page is Arabic", () => {
181
+ render(
182
+ <div lang="ar" dir="rtl">
183
+ <Badge onClose={() => {}}>القاهرة</Badge>
184
+ </div>
185
+ )
186
+ expect(screen.getByRole("button", { name: "إزالة القاهرة" })).toBeInTheDocument()
187
+ })
31
188
 
32
- rerender(<Badge variant="warning" data-testid="badge">Warning</Badge>)
33
- expect(screen.getByTestId("badge")).toHaveClass("bg-yellow-500")
189
+ it("follows the nearest lang, so an English island on an Arabic page stays English", () => {
190
+ render(
191
+ <div lang="ar">
192
+ <div lang="en">
193
+ <Badge onClose={() => {}}>Cairo</Badge>
194
+ </div>
195
+ </div>
196
+ )
197
+ expect(screen.getByRole("button", { name: "Remove Cairo" })).toBeInTheDocument()
198
+ })
199
+
200
+ it("follows a live language change without remounting", () => {
201
+ const { container } = render(
202
+ <div lang="en">
203
+ <Badge onClose={() => {}}>القاهرة</Badge>
204
+ </div>
205
+ )
206
+ expect(screen.getByRole("button", { name: "Remove القاهرة" })).toBeInTheDocument()
207
+ container.firstElementChild!.setAttribute("lang", "ar")
208
+ expect(screen.getByRole("button", { name: "إزالة القاهرة" })).toBeInTheDocument()
209
+ })
210
+
211
+ it("carries both names in the markup, so server HTML needs no script", () => {
212
+ badge({ onClose: () => {}, children: "Cairo" })
213
+ const runs = Array.from(screen.getByRole("button").querySelectorAll(".sr-only"))
214
+ expect(runs.map((r) => r.textContent)).toEqual(["Remove Cairo", "إزالة Cairo"])
215
+ expect(SOURCE).not.toMatch(/useEffect|useState|useLayoutEffect/)
216
+ })
217
+
218
+ it("takes an explicit label over the page language", () => {
219
+ render(
220
+ <div lang="ar">
221
+ <Badge onClose={() => {}} closeLabel="Remove filter: Cairo">
222
+ Cairo
223
+ </Badge>
224
+ </div>
225
+ )
226
+ const button = screen.getByRole("button", { name: "Remove filter: Cairo" })
227
+ expect(button.querySelector(".sr-only")).toBeNull()
228
+ })
229
+
230
+ it("widens the hit area to 24px without changing the 12px glyph", () => {
231
+ badge({ onClose: () => {} })
232
+ expect(screen.getByRole("button")).toHaveClass("size-3", "after:absolute", "after:-inset-1.5")
233
+ })
34
234
  })
35
235
 
36
- it("applies custom className", () => {
37
- render(<Badge className="custom-class" data-testid="badge">Badge</Badge>)
38
- expect(screen.getByTestId("badge")).toHaveClass("custom-class")
236
+ describe("focus ring", () => {
237
+ it("exists only when there is something to focus", () => {
238
+ expect(badge().querySelector(".peer-focus-visible\\:opacity-100")).toBeNull()
239
+ })
240
+
241
+ it("follows the close button as a peer, 2px out, on the badge's own radius", () => {
242
+ const el = badge({ onClose: () => {} })
243
+ const ring = screen.getByRole("button").nextElementSibling!
244
+ expect(ring.parentElement).toBe(el)
245
+ expect(ring).toHaveAttribute("aria-hidden", "true")
246
+ expect(ring).toHaveClass(
247
+ "absolute",
248
+ "-inset-0.5",
249
+ "rounded-[inherit]",
250
+ "ring-1",
251
+ "opacity-0",
252
+ "peer-focus-visible:opacity-100"
253
+ )
254
+ expect(screen.getByRole("button")).toHaveClass("peer")
255
+ })
256
+
257
+ it.each([
258
+ ["primary", "ring-ring/50"],
259
+ ["secondary", "ring-ring/50"],
260
+ ["info", "ring-info/20"],
261
+ ["success", "ring-success/20"],
262
+ ["warning", "ring-warning/20"],
263
+ ["destructive", "ring-destructive/20"],
264
+ ] as const)("%s uses Figma's ring colour and opacity", (tone, cls) => {
265
+ badge({ tone, onClose: () => {} })
266
+ expect(screen.getByRole("button").nextElementSibling).toHaveClass(cls)
267
+ })
39
268
  })
40
269
 
41
- it("renders with base styling classes", () => {
42
- render(<Badge data-testid="badge">Badge</Badge>)
43
- const badge = screen.getByTestId("badge")
44
- expect(badge).toHaveClass("inline-flex", "items-center", "rounded-md", "border", "px-2.5", "py-0.5", "text-xs", "font-semibold")
270
+ it("is a client component, so a server component can render it", () => {
271
+ expect(SOURCE.startsWith('"use client"')).toBe(true)
45
272
  })
46
273
 
47
- it("passes additional props to underlying element", () => {
48
- render(<Badge data-testid="badge" aria-label="status badge">Status</Badge>)
49
- expect(screen.getByTestId("badge")).toHaveAttribute("aria-label", "status badge")
274
+ it("forwards its ref, merges className and passes props through", () => {
275
+ const ref = React.createRef<HTMLSpanElement>()
276
+ render(
277
+ <Badge ref={ref} className="custom" aria-label="status" data-testid="badge">
278
+ Status
279
+ </Badge>
280
+ )
281
+ const el = screen.getByTestId("badge")
282
+ expect(ref.current).toBe(el)
283
+ expect(el).toHaveClass("custom")
284
+ expect(el).toHaveAttribute("aria-label", "status")
50
285
  })
51
286
  })
@@ -1,39 +1,251 @@
1
+ "use client"
2
+
1
3
  import * as React from "react"
2
- import { cva, type VariantProps } from "class-variance-authority"
4
+ import { XIcon } from "lucide-react"
5
+ import { cva } from "class-variance-authority"
3
6
  import { cn } from "../../../src/lib/utils"
4
7
 
8
+ /**
9
+ * Built against the Figma `Badge` set (326:2134): Type × Tone × Size × Radius ×
10
+ * Direction × Stats, 432 variants. Every colour below is the token the set binds.
11
+ *
12
+ * Direction is not a prop. The row is a plain flex row in DOM order — icon,
13
+ * avatar, label, close — so `dir="rtl"` on the page mirrors it into Figma's RTL
14
+ * order (close, label, avatar, icon) with no RTL-specific class, and the type
15
+ * ramp re-leads `text-xs` to Cairo's 20px line on its own.
16
+ *
17
+ * Heights are minimums, not fixed. Figma pins md and lg to 22 and 26px, which
18
+ * holds English (a 16px line) but squeezes Arabic's 20px line into md's padding.
19
+ * Here RTL is allowed to grow: 24 / 24 / 28px against LTR's 20 / 22 / 26px.
20
+ */
5
21
  const badgeVariants = cva(
6
- "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
22
+ "relative inline-flex shrink-0 items-center whitespace-nowrap text-xs font-medium",
7
23
  {
8
24
  variants: {
9
25
  variant: {
10
- default:
11
- "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
12
- secondary:
13
- "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
- destructive:
15
- "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
16
- outline: "text-foreground",
17
- success:
18
- "border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
19
- warning:
20
- "border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
26
+ solid: "",
27
+ // Figma stacks two fills: `card` underneath, the translucent tint on
28
+ // top. Doing the same keeps the chip opaque and identical on any
29
+ // surface, rather than letting whatever is behind it show through.
30
+ soft: "bg-card bg-gradient-to-r",
31
+ // Figma's stroke is 1px INSIDE, so the badge is the same size as Solid.
32
+ // An inset ring draws exactly that; a border would add 2px.
33
+ outline: "ring-1 ring-inset",
34
+ },
35
+ tone: {
36
+ primary: "",
37
+ secondary: "",
38
+ info: "",
39
+ success: "",
40
+ warning: "",
41
+ destructive: "",
42
+ },
43
+ size: {
44
+ sm: "min-h-5 gap-0.5 px-1.5 py-0.5",
45
+ // 22px and 26px have no Tailwind step; they are Figma's fixed heights.
46
+ md: "min-h-[1.375rem] gap-1 px-2 py-0.5",
47
+ lg: "min-h-[1.625rem] gap-1.5 px-3 py-1",
48
+ },
49
+ radius: {
50
+ rounded: "rounded-full",
51
+ // Figma `border radius/sm`. No class is 6px in both apps.
52
+ standard: "rounded-[var(--radius-sm)]",
21
53
  },
22
54
  },
55
+ compoundVariants: [
56
+ { variant: "solid", tone: "primary", class: "bg-primary text-primary-foreground" },
57
+ { variant: "solid", tone: "secondary", class: "bg-secondary text-secondary-foreground" },
58
+ { variant: "solid", tone: "info", class: "bg-info text-info-foreground" },
59
+ { variant: "solid", tone: "success", class: "bg-success text-success-foreground" },
60
+ { variant: "solid", tone: "warning", class: "bg-warning text-warning-foreground" },
61
+ {
62
+ variant: "solid",
63
+ tone: "destructive",
64
+ class: "bg-destructive text-destructive-foreground",
65
+ },
66
+
67
+ { variant: "soft", tone: "primary", class: "from-soft-primary to-soft-primary text-primary" },
68
+ // Figma sets Soft Secondary's text on `foreground`, not `secondary-foreground`.
69
+ {
70
+ variant: "soft",
71
+ tone: "secondary",
72
+ class: "from-soft-secondary to-soft-secondary text-foreground",
73
+ },
74
+ { variant: "soft", tone: "info", class: "from-soft-info to-soft-info text-info" },
75
+ { variant: "soft", tone: "success", class: "from-soft-success to-soft-success text-success" },
76
+ { variant: "soft", tone: "warning", class: "from-soft-warning to-soft-warning text-warning" },
77
+ {
78
+ variant: "soft",
79
+ tone: "destructive",
80
+ class: "from-soft-destructive to-soft-destructive text-destructive",
81
+ },
82
+
83
+ { variant: "outline", tone: "primary", class: "ring-primary text-primary" },
84
+ { variant: "outline", tone: "secondary", class: "ring-secondary text-secondary-foreground" },
85
+ { variant: "outline", tone: "info", class: "ring-info text-info" },
86
+ { variant: "outline", tone: "success", class: "ring-success text-success" },
87
+ { variant: "outline", tone: "warning", class: "ring-warning text-warning" },
88
+ { variant: "outline", tone: "destructive", class: "ring-destructive text-destructive" },
89
+ ],
23
90
  defaultVariants: {
24
- variant: "default",
91
+ variant: "solid",
92
+ tone: "primary",
93
+ size: "sm",
94
+ radius: "rounded",
25
95
  },
26
96
  }
27
97
  )
28
98
 
29
- export interface BadgeProps
30
- extends React.HTMLAttributes<HTMLDivElement>,
31
- VariantProps<typeof badgeVariants> {}
99
+ /**
100
+ * Figma's focus ring: its own node, 2px outside the badge on every side, a 1px
101
+ * line in the tone's colour at partial opacity — 50% on `ring` for the two
102
+ * neutral tones, 20% on the tone itself for the four status tones.
103
+ */
104
+ const focusRingTone = {
105
+ primary: "ring-ring/50",
106
+ secondary: "ring-ring/50",
107
+ info: "ring-info/20",
108
+ success: "ring-success/20",
109
+ warning: "ring-warning/20",
110
+ destructive: "ring-destructive/20",
111
+ }
32
112
 
33
- function Badge({ className, variant, ...props }: BadgeProps) {
34
- return (
35
- <div className={cn(badgeVariants({ variant }), className)} {...props} />
36
- )
113
+ /** The close button's accessible name, per script, with the badge's label when it is plain text. */
114
+ const CLOSE_LABELS = {
115
+ en: (label?: string) => (label ? `Remove ${label}` : "Remove"),
116
+ ar: (label?: string) => (label ? `إزالة ${label}` : "إزالة"),
37
117
  }
38
118
 
119
+ type Variant = "solid" | "soft" | "outline"
120
+ type Tone = keyof typeof focusRingTone
121
+ type Size = "sm" | "md" | "lg"
122
+ type Radius = "rounded" | "standard"
123
+
124
+ export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
125
+ /**
126
+ * Visual weight — Figma `Type`. Declared here rather than inherited from
127
+ * `VariantProps`, which widens every option with `| null`.
128
+ */
129
+ variant?: Variant
130
+ /** Semantic colour — Figma `Tone`. */
131
+ tone?: Tone
132
+ /** Figma `Size`. */
133
+ size?: Size
134
+ /** Figma `Radius`: `rounded` is a pill, `standard` is `border radius/sm`. */
135
+ radius?: Radius
136
+ /**
137
+ * Leading icon. Sized to 12px and coloured with `currentColor`, so pass any
138
+ * SVG icon without size or colour of its own. Decorative: hidden from
139
+ * assistive technology.
140
+ */
141
+ icon?: React.ReactNode
142
+ /**
143
+ * Leading avatar — a single image. The badge owns its 12px size and circular
144
+ * clip; the child only supplies the picture, and its own `alt`.
145
+ */
146
+ avatar?: React.ReactNode
147
+ /** Renders a close button at the trailing edge, and is called when it is pressed. */
148
+ onClose?: (event: React.MouseEvent<HTMLButtonElement>) => void
149
+ /**
150
+ * Accessible name for the close button — read by screen readers, never shown.
151
+ * Defaults to "Remove {label}" when the label is plain text, or "إزالة {label}"
152
+ * when the page's `lang` is Arabic; "Remove" / "إزالة" otherwise.
153
+ */
154
+ closeLabel?: string
155
+ }
156
+
157
+ const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
158
+ (
159
+ {
160
+ className,
161
+ variant = "solid",
162
+ tone = "primary",
163
+ size = "sm",
164
+ radius = "rounded",
165
+ icon,
166
+ avatar,
167
+ onClose,
168
+ closeLabel,
169
+ children,
170
+ ...props
171
+ },
172
+ ref
173
+ ) => {
174
+ const label =
175
+ typeof children === "string" || typeof children === "number"
176
+ ? String(children).trim() || undefined
177
+ : undefined
178
+
179
+ return (
180
+ <span
181
+ ref={ref}
182
+ className={cn(badgeVariants({ variant, tone, size, radius }), className)}
183
+ {...props}
184
+ >
185
+ {icon && (
186
+ <span
187
+ aria-hidden="true"
188
+ className="inline-flex size-3 shrink-0 items-center justify-center [&>svg]:size-3"
189
+ >
190
+ {icon}
191
+ </span>
192
+ )}
193
+ {avatar && (
194
+ <span className="inline-flex size-3 shrink-0 overflow-hidden rounded-full [&>img]:size-full [&>img]:object-cover">
195
+ {avatar}
196
+ </span>
197
+ )}
198
+ <span>{children}</span>
199
+ {onClose && (
200
+ <>
201
+ <button
202
+ type="button"
203
+ aria-label={closeLabel}
204
+ onClick={onClose}
205
+ // The ring below is the badge's focus indicator, so the button
206
+ // draws none of its own. `after:` widens the hit area to 24×24px
207
+ // (WCAG 2.5.8) without moving anything: the glyph stays Figma's
208
+ // 12px, and an absolute pseudo-element can't change the badge's
209
+ // height even where it reaches past sm's 20px.
210
+ className="peer relative inline-flex size-3 shrink-0 cursor-pointer items-center justify-center rounded-full outline-none after:absolute after:-inset-1.5 after:content-['']"
211
+ >
212
+ {/*
213
+ * Without `closeLabel`, the name comes from these two hidden
214
+ * runs, and CSS keeps the one that matches the nearest `lang` —
215
+ * the same way `dir` is inherited. That is right in the server
216
+ * HTML before any script runs, and it follows a live language
217
+ * change; `display: none` takes the other out of the name.
218
+ */}
219
+ {!closeLabel && (
220
+ <>
221
+ <span className="sr-only [&:lang(ar)]:hidden">{CLOSE_LABELS.en(label)}</span>
222
+ <span className="sr-only hidden [&:lang(ar)]:inline">
223
+ {CLOSE_LABELS.ar(label)}
224
+ </span>
225
+ </>
226
+ )}
227
+ <XIcon aria-hidden="true" size={12} strokeWidth={1.5} />
228
+ </button>
229
+ {/*
230
+ * A sibling of the button, after it, so a plain
231
+ * `peer-focus-visible:` drives it — the one form the Storybook
232
+ * pseudo-state renderer can show. `:has()` would work in a browser
233
+ * but not in the states grid. `rounded-[inherit]` keeps the ring on
234
+ * the badge's own radius, as Figma binds the same token to both.
235
+ */}
236
+ <span
237
+ aria-hidden="true"
238
+ className={cn(
239
+ "pointer-events-none absolute -inset-0.5 rounded-[inherit] opacity-0 ring-1 peer-focus-visible:opacity-100",
240
+ focusRingTone[tone]
241
+ )}
242
+ />
243
+ </>
244
+ )}
245
+ </span>
246
+ )
247
+ }
248
+ )
249
+ Badge.displayName = "Badge"
250
+
39
251
  export { Badge, badgeVariants }
@@ -51,6 +51,12 @@ describe("Button", () => {
51
51
  expect(screen.getByText("Loading")).toBeInTheDocument()
52
52
  })
53
53
 
54
+ it("announces the loading text it is given", () => {
55
+ render(<Button loading loadingLabel="جارٍ الحفظ">حفظ</Button>)
56
+ expect(screen.getByText("جارٍ الحفظ")).toBeInTheDocument()
57
+ expect(screen.queryByText("Loading")).not.toBeInTheDocument()
58
+ })
59
+
54
60
  it("is disabled when disabled prop is true", () => {
55
61
  render(<Button disabled>Disabled</Button>)
56
62
  expect(screen.getByRole("button")).toBeDisabled()
@@ -1,4 +1,5 @@
1
1
  import * as React from "react"
2
+ import { LoaderCircleIcon } from "lucide-react"
2
3
  import { Slot } from "@radix-ui/react-slot"
3
4
  import { cva, type VariantProps } from "class-variance-authority"
4
5
  import { cn } from "../../../src/lib/utils"
@@ -38,10 +39,12 @@ export interface ButtonProps
38
39
  VariantProps<typeof buttonVariants> {
39
40
  asChild?: boolean
40
41
  loading?: boolean
42
+ /** Screen-reader text announced while loading */
43
+ loadingLabel?: string
41
44
  }
42
45
 
43
46
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
44
- ({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
47
+ ({ className, variant, size, asChild = false, loading, loadingLabel = "Loading", children, disabled, ...props }, ref) => {
45
48
  const Comp = asChild ? Slot : "button"
46
49
  return (
47
50
  <Comp
@@ -52,28 +55,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
52
55
  >
53
56
  {loading ? (
54
57
  <>
55
- <svg
56
- className="animate-spin"
57
- xmlns="http://www.w3.org/2000/svg"
58
- fill="none"
59
- viewBox="0 0 24 24"
60
- aria-hidden="true"
61
- >
62
- <circle
63
- className="opacity-25"
64
- cx="12"
65
- cy="12"
66
- r="10"
67
- stroke="currentColor"
68
- strokeWidth="4"
69
- />
70
- <path
71
- className="opacity-75"
72
- fill="currentColor"
73
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
74
- />
75
- </svg>
76
- <span className="sr-only">Loading</span>
58
+ <LoaderCircleIcon aria-hidden="true" className="animate-spin motion-reduce:animate-none" strokeWidth={1.5} />
59
+ <span className="sr-only">{loadingLabel}</span>
77
60
  {children}
78
61
  </>
79
62
  ) : (
@@ -34,7 +34,7 @@ const CardTitle = React.forwardRef<
34
34
  >(({ className, ...props }, ref) => (
35
35
  <div
36
36
  ref={ref}
37
- className={cn("font-semibold leading-none tracking-tight", className)}
37
+ className={cn("font-semibold leading-none", className)}
38
38
  {...props}
39
39
  />
40
40
  ))