@smicolon/fasla-ui 0.2.4 → 0.4.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 (72) hide show
  1. package/README.md +44 -20
  2. package/dist/index.cjs +319 -0
  3. package/dist/index.d.cts +5 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +92 -24
  6. package/dist/lib/utils.cjs +11 -0
  7. package/dist/lib/utils.d.cts +9 -0
  8. package/dist/motion/index.cjs +61 -0
  9. package/dist/motion/index.d.cts +33 -0
  10. package/dist/registry/index.cjs +179 -0
  11. package/dist/registry/index.d.cts +17 -0
  12. package/dist/registry/index.js +32 -3
  13. package/dist/tokens/index.cjs +77 -0
  14. package/dist/tokens/index.d.cts +124 -0
  15. package/dist/tokens/index.d.ts +75 -6
  16. package/dist/tokens/index.js +59 -20
  17. package/package.json +17 -7
  18. package/registry/blocks/app-shell/AppShell.tsx +1 -1
  19. package/registry/blocks/data-table/DataTable.test.tsx +61 -0
  20. package/registry/blocks/data-table/DataTable.tsx +53 -16
  21. package/registry/blocks/data-table/index.ts +1 -0
  22. package/registry/blocks/empty-state/EmptyState.test.tsx +23 -0
  23. package/registry/blocks/empty-state/EmptyState.tsx +32 -33
  24. package/registry/blocks/form-section/FormSection.tsx +1 -1
  25. package/registry/blocks/navbar/Navbar.test.tsx +12 -0
  26. package/registry/blocks/navbar/Navbar.tsx +8 -23
  27. package/registry/blocks/page-header/PageHeader.tsx +5 -2
  28. package/registry/blocks/sidebar/Sidebar.test.tsx +39 -0
  29. package/registry/blocks/sidebar/Sidebar.tsx +36 -26
  30. package/registry/blocks/stats-card/StatsCard.tsx +12 -29
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +19 -8
  32. package/registry/effects/border-beam/border-beam.tsx +77 -53
  33. package/registry/effects/effects.test.tsx +266 -0
  34. package/registry/effects/glow-card/glow-card.tsx +68 -43
  35. package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
  36. package/registry/effects/spotlight/spotlight.tsx +73 -29
  37. package/registry/effects/text-reveal/index.ts +1 -0
  38. package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
  39. package/registry/effects/text-reveal/text-reveal.tsx +70 -21
  40. package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
  41. package/registry/next14-tsconfig.test.ts +43 -0
  42. package/registry/text-props-undefined.test.tsx +73 -0
  43. package/registry/ui/avatar/avatar.test.tsx +316 -0
  44. package/registry/ui/avatar/avatar.tsx +235 -108
  45. package/registry/ui/avatar/index.ts +1 -1
  46. package/registry/ui/badge/badge.test.tsx +267 -32
  47. package/registry/ui/badge/badge.tsx +233 -21
  48. package/registry/ui/button/button.test.tsx +6 -0
  49. package/registry/ui/button/button.tsx +6 -23
  50. package/registry/ui/card/card.tsx +1 -1
  51. package/registry/ui/checkbox/checkbox.test.tsx +23 -0
  52. package/registry/ui/checkbox/checkbox.tsx +16 -27
  53. package/registry/ui/combobox/combobox.test.tsx +72 -0
  54. package/registry/ui/combobox/combobox.tsx +34 -59
  55. package/registry/ui/input/input.test.tsx +3 -3
  56. package/registry/ui/input/input.tsx +5 -5
  57. package/registry/ui/radio/index.ts +1 -0
  58. package/registry/ui/radio/radio.test.tsx +248 -0
  59. package/registry/ui/radio/radio.tsx +208 -0
  60. package/registry/ui/select/select.tsx +4 -14
  61. package/registry/ui/skeleton/index.ts +1 -0
  62. package/registry/ui/skeleton/skeleton.test.tsx +113 -29
  63. package/registry/ui/skeleton/skeleton.tsx +101 -41
  64. package/registry/ui/status-indicator/index.ts +6 -0
  65. package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
  66. package/registry/ui/status-indicator/status-indicator.tsx +87 -0
  67. package/registry/ui/switch/index.ts +1 -1
  68. package/registry/ui/switch/switch.test.tsx +323 -0
  69. package/registry/ui/switch/switch.tsx +254 -57
  70. package/registry/ui/textarea/textarea.tsx +1 -1
  71. package/registry.json +79 -16
  72. package/tailwind-preset.ts +143 -0
@@ -0,0 +1,23 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { render, screen } from "@testing-library/react"
3
+ import { Checkbox } from "./checkbox"
4
+
5
+ describe("Checkbox id", () => {
6
+ it("uses the id it is given", () => {
7
+ render(<Checkbox id="terms" label="Accept the terms" />)
8
+ expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
9
+ })
10
+
11
+ it("generates an id that ties the label to the input", () => {
12
+ render(<Checkbox label="Accept the terms" />)
13
+ expect(screen.getByLabelText("Accept the terms").id).not.toBe("")
14
+ })
15
+
16
+ it("keeps working when the id is added and removed between renders", () => {
17
+ const { rerender } = render(<Checkbox label="Accept the terms" />)
18
+ rerender(<Checkbox id="terms" label="Accept the terms" />)
19
+ expect(screen.getByLabelText("Accept the terms").id).toBe("terms")
20
+ rerender(<Checkbox label="Accept the terms" />)
21
+ expect(screen.getByLabelText("Accept the terms").id).not.toBe("terms")
22
+ })
23
+ })
@@ -1,4 +1,5 @@
1
1
  import * as React from "react"
2
+ import { CheckIcon, MinusIcon } from "lucide-react"
2
3
  import { cn } from "../../../src/lib/utils"
3
4
 
4
5
  export interface CheckboxProps
@@ -25,7 +26,9 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
25
26
  }
26
27
  }, [indeterminate])
27
28
 
28
- const inputId = id || React.useId()
29
+ // Called on every render, so the hook order holds when id comes and goes
30
+ const generatedId = React.useId()
31
+ const inputId = id || generatedId
29
32
 
30
33
  const checkbox = (
31
34
  <div className="relative flex items-center">
@@ -48,34 +51,16 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
48
51
  className
49
52
  )}
50
53
  >
51
- <svg
52
- width="12"
53
- height="12"
54
- viewBox="0 0 24 24"
55
- fill="none"
56
- stroke="currentColor"
57
- strokeWidth="3"
58
- strokeLinecap="round"
59
- strokeLinejoin="round"
54
+ <CheckIcon
55
+ size={12}
56
+ strokeWidth={1.5}
60
57
  className={cn(
61
58
  "opacity-0 peer-checked:opacity-100",
62
59
  indeterminate ? "hidden" : "block"
63
60
  )}
64
- >
65
- <polyline points="20 6 9 17 4 12" />
66
- </svg>
61
+ />
67
62
  {indeterminate && (
68
- <svg
69
- width="12"
70
- height="12"
71
- viewBox="0 0 24 24"
72
- fill="none"
73
- stroke="currentColor"
74
- strokeWidth="3"
75
- strokeLinecap="round"
76
- >
77
- <line x1="5" y1="12" x2="19" y2="12" />
78
- </svg>
63
+ <MinusIcon size={12} strokeWidth={1.5} />
79
64
  )}
80
65
  </div>
81
66
  </div>
@@ -85,15 +70,19 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
85
70
  return checkbox
86
71
  }
87
72
 
73
+ // The label takes the type ramp's leading (24px Arabic, 20px English), so a
74
+ // wrapped label never overlaps. The box sits in a box one line tall, as
75
+ // Radio's control does, so it stays centred on the first line.
88
76
  return (
89
77
  <div className="flex items-start gap-3">
90
- {checkbox}
91
- <div className="grid gap-1 leading-none">
78
+ {/* One label line tall, as Radio's control; falls back to English's 20 / 14 without the Fasla preset */}
79
+ <div className="flex h-[calc(var(--leading-sm,calc(20/14))*0.875rem)] shrink-0 items-center">{checkbox}</div>
80
+ <div className="grid gap-1">
92
81
  {label && (
93
82
  <label
94
83
  htmlFor={inputId}
95
84
  className={cn(
96
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
85
+ "text-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
97
86
  error && "text-destructive"
98
87
  )}
99
88
  >
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, beforeAll } from "vitest"
2
+ import { render, screen, fireEvent } from "@testing-library/react"
3
+ import { Combobox } from "./combobox"
4
+
5
+ const options = [
6
+ { value: "riyadh", label: "Riyadh" },
7
+ { value: "jeddah", label: "Jeddah" },
8
+ ]
9
+
10
+ describe("Combobox text", () => {
11
+ it("keeps its English defaults when no text props are passed", () => {
12
+ render(<Combobox options={options} value={["riyadh"]} multiple loading />)
13
+
14
+ expect(screen.getByRole("button", { name: "Open" })).toBeTruthy()
15
+ expect(screen.getByRole("button", { name: "Remove Riyadh" })).toBeTruthy()
16
+
17
+ fireEvent.click(screen.getByRole("button", { name: "Open" }))
18
+ expect(screen.getByRole("button", { name: "Close" })).toBeTruthy()
19
+ expect(screen.getByText("Loading...")).toBeTruthy()
20
+ })
21
+
22
+ it("uses the loading, toggle and remove text it is given", () => {
23
+ render(
24
+ <Combobox
25
+ options={options}
26
+ value={["riyadh"]}
27
+ multiple
28
+ loading
29
+ loadingText="جارٍ التحميل"
30
+ openLabel="فتح القائمة"
31
+ closeLabel="إغلاق القائمة"
32
+ removeLabel={(label) => `إزالة ${label}`}
33
+ />
34
+ )
35
+
36
+ expect(screen.getByRole("button", { name: "فتح القائمة" })).toBeTruthy()
37
+ expect(screen.getByRole("button", { name: "إزالة Riyadh" })).toBeTruthy()
38
+
39
+ fireEvent.click(screen.getByRole("button", { name: "فتح القائمة" }))
40
+ expect(screen.getByRole("button", { name: "إغلاق القائمة" })).toBeTruthy()
41
+ expect(screen.getByText("جارٍ التحميل")).toBeTruthy()
42
+ })
43
+ })
44
+
45
+ describe("Combobox highlight", () => {
46
+ // The highlighted option is scrolled into view, which jsdom does not implement
47
+ beforeAll(() => {
48
+ Element.prototype.scrollIntoView ??= () => {}
49
+ })
50
+
51
+ it("moves back to the first option when the search changes", () => {
52
+ const cities = [...options, { value: "dammam", label: "Dammam" }]
53
+ render(<Combobox options={cities} />)
54
+ const input = screen.getByRole("combobox")
55
+
56
+ fireEvent.focus(input)
57
+ fireEvent.keyDown(input, { key: "ArrowDown" })
58
+ fireEvent.keyDown(input, { key: "ArrowDown" })
59
+ expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "true")
60
+
61
+ fireEvent.change(input, { target: { value: "a" } })
62
+ expect(screen.getByRole("option", { name: "Riyadh" })).toHaveAttribute("data-highlighted", "true")
63
+ expect(screen.getByRole("option", { name: "Dammam" })).toHaveAttribute("data-highlighted", "false")
64
+ })
65
+
66
+ it("quotes the search in the create option", () => {
67
+ render(<Combobox options={options} creatable onCreate={() => {}} />)
68
+ const input = screen.getByRole("combobox")
69
+ fireEvent.change(input, { target: { value: "Mecca" } })
70
+ expect(screen.getByRole("option").textContent).toBe('Create "Mecca"')
71
+ })
72
+ })
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { CheckIcon, ChevronDownIcon, PlusIcon, XIcon } from "lucide-react"
4
5
  import { cn } from "../../../src/lib/utils"
5
6
 
6
7
  export interface ComboboxOption {
@@ -36,6 +37,14 @@ export interface ComboboxProps {
36
37
  className?: string
37
38
  /** Loading state */
38
39
  loading?: boolean
40
+ /** Text shown while loading */
41
+ loadingText?: string
42
+ /** Accessible name of the toggle button while the list is closed */
43
+ openLabel?: string
44
+ /** Accessible name of the toggle button while the list is open */
45
+ closeLabel?: string
46
+ /** Accessible name of a selected chip’s remove button, given the option label */
47
+ removeLabel?: (label: string) => string
39
48
  }
40
49
 
41
50
  export function Combobox({
@@ -52,6 +61,10 @@ export function Combobox({
52
61
  disabled = false,
53
62
  className,
54
63
  loading = false,
64
+ loadingText = "Loading...",
65
+ openLabel = "Open",
66
+ closeLabel = "Close",
67
+ removeLabel = (label) => `Remove ${label}`,
55
68
  }: ComboboxProps) {
56
69
  const [open, setOpen] = React.useState(false)
57
70
  const [search, setSearch] = React.useState("")
@@ -176,10 +189,13 @@ export function Combobox({
176
189
  }
177
190
  }, [highlightedIndex, open])
178
191
 
179
- // Reset highlighted index when search changes
180
- React.useEffect(() => {
192
+ // Reset highlighted index when search changes. Done while rendering rather
193
+ // than in an effect, so the list never paints with the old highlight.
194
+ const [highlightedSearch, setHighlightedSearch] = React.useState(search)
195
+ if (highlightedSearch !== search) {
196
+ setHighlightedSearch(search)
181
197
  setHighlightedIndex(0)
182
- }, [search])
198
+ }
183
199
 
184
200
  // Close on outside click
185
201
  React.useEffect(() => {
@@ -224,22 +240,14 @@ export function Combobox({
224
240
  type="button"
225
241
  onClick={() => !disabled && setOpen(!open)}
226
242
  disabled={disabled}
227
- className="ml-2 shrink-0 text-muted-foreground hover:text-foreground"
228
- aria-label={open ? "Close" : "Open"}
243
+ className="ms-2 shrink-0 text-muted-foreground hover:text-foreground"
244
+ aria-label={open ? closeLabel : openLabel}
229
245
  >
230
- <svg
231
- width="16"
232
- height="16"
233
- viewBox="0 0 24 24"
234
- fill="none"
235
- stroke="currentColor"
236
- strokeWidth="2"
237
- strokeLinecap="round"
238
- strokeLinejoin="round"
239
- className={cn("transition-transform", open && "rotate-180")}
240
- >
241
- <polyline points="6 9 12 15 18 9" />
242
- </svg>
246
+ <ChevronDownIcon
247
+ size={16}
248
+ strokeWidth={1.5}
249
+ className={cn("transition-transform motion-reduce:transition-none", open && "rotate-180")}
250
+ />
243
251
  </button>
244
252
  </div>
245
253
 
@@ -268,7 +276,7 @@ export function Combobox({
268
276
  >
269
277
  {loading ? (
270
278
  <li className="px-2 py-4 text-center text-sm text-muted-foreground">
271
- Loading...
279
+ {loadingText}
272
280
  </li>
273
281
  ) : filteredOptions.length === 0 && !showCreate ? (
274
282
  <li className="px-2 py-4 text-center text-sm text-muted-foreground">
@@ -297,39 +305,20 @@ export function Combobox({
297
305
  {multiple && (
298
306
  <span
299
307
  className={cn(
300
- "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
308
+ "me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
301
309
  isSelected
302
310
  ? "border-primary bg-primary text-primary-foreground"
303
311
  : "border-input"
304
312
  )}
305
313
  >
306
314
  {isSelected && (
307
- <svg
308
- width="12"
309
- height="12"
310
- viewBox="0 0 24 24"
311
- fill="none"
312
- stroke="currentColor"
313
- strokeWidth="3"
314
- >
315
- <polyline points="20 6 9 17 4 12" />
316
- </svg>
315
+ <CheckIcon size={12} strokeWidth={1.5} />
317
316
  )}
318
317
  </span>
319
318
  )}
320
319
  <span className="flex-1">{option.label}</span>
321
320
  {!multiple && isSelected && (
322
- <svg
323
- width="16"
324
- height="16"
325
- viewBox="0 0 24 24"
326
- fill="none"
327
- stroke="currentColor"
328
- strokeWidth="2"
329
- className="ml-2 text-primary"
330
- >
331
- <polyline points="20 6 9 17 4 12" />
332
- </svg>
321
+ <CheckIcon size={16} strokeWidth={1.5} className="ms-2 text-primary" />
333
322
  )}
334
323
  </li>
335
324
  )
@@ -347,19 +336,8 @@ export function Combobox({
347
336
  "bg-accent text-accent-foreground"
348
337
  )}
349
338
  >
350
- <svg
351
- width="16"
352
- height="16"
353
- viewBox="0 0 24 24"
354
- fill="none"
355
- stroke="currentColor"
356
- strokeWidth="2"
357
- className="mr-2"
358
- >
359
- <line x1="12" y1="5" x2="12" y2="19" />
360
- <line x1="5" y1="12" x2="19" y2="12" />
361
- </svg>
362
- {createText} "{search}"
339
+ <PlusIcon size={16} strokeWidth={1.5} className="me-2" />
340
+ {createText} &quot;{search}&quot;
363
341
  </li>
364
342
  )}
365
343
  </>
@@ -393,12 +371,9 @@ export function Combobox({
393
371
  handleSelect(val)
394
372
  }}
395
373
  className="hover:text-destructive"
396
- aria-label={`Remove ${option?.label || val}`}
374
+ aria-label={removeLabel(option?.label || val)}
397
375
  >
398
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
399
- <line x1="18" y1="6" x2="6" y2="18" />
400
- <line x1="6" y1="6" x2="18" y2="18" />
401
- </svg>
376
+ <XIcon size={12} strokeWidth={1.5} />
402
377
  </button>
403
378
  </span>
404
379
  )
@@ -16,7 +16,7 @@ describe("Input", () => {
16
16
  expect(screen.getByTestId("input")).toHaveClass("border-destructive")
17
17
 
18
18
  rerender(<Input variant="success" data-testid="input" />)
19
- expect(screen.getByTestId("input")).toHaveClass("border-green-500")
19
+ expect(screen.getByTestId("input")).toHaveClass("border-success")
20
20
  })
21
21
 
22
22
  it("renders with different sizes", () => {
@@ -38,7 +38,7 @@ describe("Input", () => {
38
38
  />
39
39
  )
40
40
  expect(screen.getByTestId("start-icon")).toBeInTheDocument()
41
- expect(screen.getByTestId("input")).toHaveClass("pl-10")
41
+ expect(screen.getByTestId("input")).toHaveClass("ps-10")
42
42
  })
43
43
 
44
44
  it("renders with end icon", () => {
@@ -49,7 +49,7 @@ describe("Input", () => {
49
49
  />
50
50
  )
51
51
  expect(screen.getByTestId("end-icon")).toBeInTheDocument()
52
- expect(screen.getByTestId("input")).toHaveClass("pr-10")
52
+ expect(screen.getByTestId("input")).toHaveClass("pe-10")
53
53
  })
54
54
 
55
55
  it("handles onChange", () => {
@@ -9,7 +9,7 @@ const inputVariants = cva(
9
9
  variant: {
10
10
  default: "border-input shadow-sm",
11
11
  error: "border-destructive focus-visible:ring-destructive",
12
- success: "border-green-500 focus-visible:ring-green-500",
12
+ success: "border-success focus-visible:ring-success",
13
13
  },
14
14
  inputSize: {
15
15
  default: "h-9 px-3 py-1",
@@ -37,7 +37,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
37
37
  return (
38
38
  <div className="relative">
39
39
  {startIcon && (
40
- <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
40
+ <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-muted-foreground">
41
41
  {startIcon}
42
42
  </div>
43
43
  )}
@@ -45,15 +45,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
45
45
  type={type}
46
46
  className={cn(
47
47
  inputVariants({ variant, inputSize }),
48
- startIcon && "pl-10",
49
- endIcon && "pr-10",
48
+ startIcon && "ps-10",
49
+ endIcon && "pe-10",
50
50
  className
51
51
  )}
52
52
  ref={ref}
53
53
  {...props}
54
54
  />
55
55
  {endIcon && (
56
- <div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground">
56
+ <div className="pointer-events-none absolute inset-y-0 end-0 flex items-center pe-3 text-muted-foreground">
57
57
  {endIcon}
58
58
  </div>
59
59
  )}
@@ -0,0 +1 @@
1
+ export { Radio, radioVariants, type RadioProps } from "./radio"
@@ -0,0 +1,248 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import { render, screen, fireEvent } from "@testing-library/react"
3
+ import { Radio } from "./radio"
4
+
5
+ /**
6
+ * What these tests can and cannot prove.
7
+ *
8
+ * jsdom has no layout engine and no style resolution: it does not match
9
+ * `:hover`, `:focus-visible`, `:has()` or `:checked` selectors, it does not
10
+ * apply a stylesheet, and every `getBoundingClientRect` is zero. So the
11
+ * interaction tests below assert **class presence only** — that the component
12
+ * emits the right utility on the right element. They do NOT prove the rule was
13
+ * generated by Tailwind, that it resolves to the right token, or that anything
14
+ * is actually painted.
15
+ *
16
+ * Rendered proof lives outside this file:
17
+ * - hover/focus actually painting, the halo geometry (control + 8, centred),
18
+ * the 2px-gap/2px-ring focus shadow, and that no Interaction state shifts
19
+ * layout — all verified in a real browser against Storybook.
20
+ * - the label leading and control alignment across scripts — same.
21
+ * Treat a green run here as "the contract is wired", not "it looks right".
22
+ */
23
+
24
+ /** label ▸ input ▸ focusRing ▸ band ▸ [halo?, control ▸ dot] */
25
+ const parts = (input: HTMLElement) => {
26
+ const root = input.closest("label") as HTMLElement
27
+ const control = root.querySelector("[data-slot=control]") as HTMLElement
28
+ const band = control.parentElement as HTMLElement
29
+ const siblings = [...(input.parentElement?.children ?? [])] as HTMLElement[]
30
+ const focusRing =
31
+ siblings.find((el) => el.className.includes("peer-focus-visible:opacity-100")) ?? null
32
+ return {
33
+ root,
34
+ band,
35
+ control,
36
+ focusRing,
37
+ dot: control.firstElementChild as HTMLElement,
38
+ halo: band.children.length > 1 ? (band.firstElementChild as HTMLElement) : null,
39
+ }
40
+ }
41
+
42
+ const radio = () => screen.getByRole("radio")
43
+
44
+ describe("Radio", () => {
45
+ it("renders a radio input", () => {
46
+ render(<Radio aria-label="Option A" />)
47
+ expect(radio()).toBeInTheDocument()
48
+ })
49
+
50
+ it("is unchecked by default and reflects defaultChecked", () => {
51
+ const { rerender } = render(<Radio aria-label="Option A" />)
52
+ expect(radio()).not.toBeChecked()
53
+
54
+ rerender(<Radio aria-label="Option A" defaultChecked />)
55
+ expect(radio()).toBeChecked()
56
+ })
57
+
58
+ it("renders with different sizes", () => {
59
+ const { rerender } = render(<Radio size="sm" label="Small" />)
60
+ expect(parts(radio()).control).toHaveClass("size-4")
61
+
62
+ rerender(<Radio size="md" label="Medium" />)
63
+ expect(parts(radio()).control).toHaveClass("size-5")
64
+
65
+ rerender(<Radio size="lg" label="Large" />)
66
+ expect(parts(radio()).control).toHaveClass("size-6")
67
+ })
68
+
69
+ it("sizes the hover halo to the control plus 8px", () => {
70
+ // Geometry is asserted as the class Figma's "control + 8" maps to; the
71
+ // rendered box was measured in a browser, not here.
72
+ const cases = [
73
+ ["sm", "size-4", "size-6"],
74
+ ["md", "size-5", "size-7"],
75
+ ["lg", "size-6", "size-8"],
76
+ ] as const
77
+ for (const [size, control, halo] of cases) {
78
+ const { unmount } = render(<Radio size={size} label="Option" />)
79
+ const p = parts(radio())
80
+ expect(p.control).toHaveClass(control)
81
+ expect(p.halo).toHaveClass(halo)
82
+ unmount()
83
+ }
84
+ })
85
+
86
+ it("paints the dot with the primary token", () => {
87
+ // Figma paints the dot with theme/primary, the strong neutral. Guarding the
88
+ // token choice, not the colour: Figma gives primary and foreground the same
89
+ // value in both modes, so this fails only if the dot is repointed at
90
+ // something semantically different.
91
+ render(<Radio label="Option" defaultChecked />)
92
+ const { dot } = parts(radio())
93
+ expect(dot).toHaveClass("bg-primary")
94
+ })
95
+
96
+ it("lets the type ramp own the label leading", () => {
97
+ // The ramp makes `text-sm` direction-aware (20px English, 24px Arabic). A
98
+ // hardcoded leading or a fixed line height would override it and clip
99
+ // Arabic, which is the defect this component set had in Figma.
100
+ render(<Radio label="Option" description="Detail" size="sm" />)
101
+ const label = screen.getByText("Option")
102
+ const description = screen.getByText("Detail")
103
+ for (const el of [label, description]) {
104
+ expect(el).toHaveClass("text-sm")
105
+ expect(el.className).not.toMatch(/\bleading-/)
106
+ expect(el.className).not.toMatch(/\bh-\d/)
107
+ }
108
+ })
109
+
110
+ it("puts hover and focus on the control for the default variant", () => {
111
+ // Class presence only — jsdom matches neither :hover nor :focus-visible.
112
+ render(<Radio label="Option" />)
113
+ const { root, halo, focusRing } = parts(radio())
114
+ expect(halo).not.toBeNull()
115
+ expect(halo).toHaveClass("bg-accent", "group-hover:opacity-100")
116
+ // A circular ring around the control, sized to it — Figma's ellipse node.
117
+ expect(focusRing).not.toBeNull()
118
+ const ring = focusRing!.firstElementChild as HTMLElement
119
+ expect(ring).toHaveClass("rounded-full", "ring-2", "ring-ring", "ring-offset-2")
120
+ expect(ring).toHaveClass("size-5") // matches the md control
121
+ expect(root).not.toHaveClass("hover:bg-accent")
122
+ })
123
+
124
+ it("puts hover and focus on the card for the layout variant", () => {
125
+ // Class presence only — see the note at the top of this file.
126
+ render(<Radio variant="layout" label="Option" />)
127
+ const { root, halo, focusRing } = parts(radio())
128
+ expect(halo).toBeNull() // the card fills instead of showing a halo
129
+ expect(root).toHaveClass("hover:bg-accent")
130
+ // A rounded rectangle around the whole card — Figma's −1,−1 node.
131
+ expect(focusRing).not.toBeNull()
132
+ expect(focusRing).toHaveClass("-inset-[2px]", "ring-2", "ring-ring")
133
+ expect(focusRing!.className).toMatch(/rounded-\[calc\(var\(--radius\)-1px\)\]/)
134
+ })
135
+
136
+ it("drives hover and focus without a has-[] utility", () => {
137
+ // `:has()` resolves in a browser but is invisible to the pseudo-state
138
+ // renderer the Storybook states grid uses, so a `has-[:hover]` or
139
+ // `has-[:focus-visible]` here would make the grid silently stop matching the
140
+ // component. Both are `peer`- or `group`-driven instead.
141
+ //
142
+ // `has-[:disabled]` is deliberately still allowed: `disabled` is a real
143
+ // attribute, not a simulated pseudo-state, so it renders statically anyway.
144
+ for (const variant of ["default", "layout"] as const) {
145
+ const { unmount } = render(<Radio variant={variant} label="Option" />)
146
+ const html = (radio().closest("label") as HTMLElement).outerHTML
147
+ expect(html).not.toMatch(/has-\[:hover\]/)
148
+ expect(html).not.toMatch(/has-\[:focus-visible\]/)
149
+ expect(html).toMatch(/has-\[:disabled\]/)
150
+ unmount()
151
+ }
152
+ })
153
+
154
+ it("renders the layout variant with a border", () => {
155
+ const { rerender } = render(<Radio label="Default" data-testid="radio" />)
156
+ expect(screen.getByTestId("radio").closest("label")).not.toHaveClass("border")
157
+
158
+ rerender(<Radio variant="layout" label="Layout" data-testid="radio" />)
159
+ expect(screen.getByTestId("radio").closest("label")).toHaveClass("border")
160
+ })
161
+
162
+ it("defaults to the default variant when variant is omitted", () => {
163
+ render(<Radio label="Option" data-testid="radio" />)
164
+ expect(screen.getByTestId("radio").closest("label")).not.toHaveClass(
165
+ "border"
166
+ )
167
+ })
168
+
169
+ it("rejects null as a variant", () => {
170
+ // @ts-expect-error `variant` is declared on RadioProps rather than
171
+ // inherited from CVA's VariantProps, which widens every variant with
172
+ // `| null` and shows a meaningless third option in the Storybook control.
173
+ render(<Radio label="Option" variant={null} data-testid="radio" />)
174
+ expect(screen.getByTestId("radio")).toBeInTheDocument()
175
+ })
176
+
177
+ it("associates the label so clicking it selects the radio", () => {
178
+ render(<Radio label="Express delivery" />)
179
+ fireEvent.click(screen.getByText("Express delivery"))
180
+ expect(radio()).toBeChecked()
181
+ })
182
+
183
+ it("links the description via aria-describedby", () => {
184
+ render(<Radio label="Express" description="Arrives tomorrow" />)
185
+ expect(radio()).toHaveAccessibleDescription("Arrives tomorrow")
186
+ })
187
+
188
+ it("keeps the control and its decoration out of the accessibility tree", () => {
189
+ render(<Radio label="Option" />)
190
+ expect(parts(radio()).band).toHaveAttribute("aria-hidden", "true")
191
+ })
192
+
193
+ it("renders no text column when label and description are omitted", () => {
194
+ render(<Radio aria-label="Bare" />)
195
+ // input, focus ring, control band — and no text column.
196
+ expect(radio().parentElement?.childElementCount).toBe(3)
197
+ expect(screen.queryByText(/./)).toBeNull()
198
+ })
199
+
200
+ it("selects only one radio per name group", () => {
201
+ render(
202
+ <>
203
+ <Radio name="plan" value="a" label="Plan A" />
204
+ <Radio name="plan" value="b" label="Plan B" />
205
+ </>
206
+ )
207
+ const radios = screen.getAllByRole("radio")
208
+ const a = radios[0]!
209
+ const b = radios[1]!
210
+
211
+ fireEvent.click(a)
212
+ expect(a).toBeChecked()
213
+ expect(b).not.toBeChecked()
214
+
215
+ fireEvent.click(b)
216
+ expect(a).not.toBeChecked()
217
+ expect(b).toBeChecked()
218
+ })
219
+
220
+ it("handles onChange", () => {
221
+ const handleChange = vi.fn()
222
+ render(<Radio label="Option" onChange={handleChange} />)
223
+
224
+ fireEvent.click(radio())
225
+ expect(handleChange).toHaveBeenCalled()
226
+ })
227
+
228
+ it("is disabled when disabled prop is true", () => {
229
+ render(<Radio label="Option" disabled />)
230
+ expect(radio()).toBeDisabled()
231
+ expect(radio().closest("label")).toHaveClass(
232
+ "has-[:disabled]:opacity-50",
233
+ // also kills hover, so a disabled card cannot fill
234
+ "has-[:disabled]:pointer-events-none"
235
+ )
236
+ })
237
+
238
+ it("applies custom className to the root", () => {
239
+ render(<Radio label="Option" className="custom-class" />)
240
+ expect(radio().closest("label")).toHaveClass("custom-class")
241
+ })
242
+
243
+ it("forwards ref correctly", () => {
244
+ const ref = vi.fn()
245
+ render(<Radio aria-label="Option" ref={ref} />)
246
+ expect(ref).toHaveBeenCalled()
247
+ })
248
+ })