@smicolon/fasla-ui 0.2.3 → 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 +41 -22
  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 +435 -78
  57. package/tailwind-preset.ts +143 -0
@@ -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
@@ -48,34 +49,16 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
48
49
  className
49
50
  )}
50
51
  >
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"
52
+ <CheckIcon
53
+ size={12}
54
+ strokeWidth={1.5}
60
55
  className={cn(
61
56
  "opacity-0 peer-checked:opacity-100",
62
57
  indeterminate ? "hidden" : "block"
63
58
  )}
64
- >
65
- <polyline points="20 6 9 17 4 12" />
66
- </svg>
59
+ />
67
60
  {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>
61
+ <MinusIcon size={12} strokeWidth={1.5} />
79
62
  )}
80
63
  </div>
81
64
  </div>
@@ -85,15 +68,19 @@ const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
85
68
  return checkbox
86
69
  }
87
70
 
71
+ // The label takes the type ramp's leading (24px Arabic, 20px English), so a
72
+ // wrapped label never overlaps. The box sits in a box one line tall, as
73
+ // Radio's control does, so it stays centred on the first line.
88
74
  return (
89
75
  <div className="flex items-start gap-3">
90
- {checkbox}
91
- <div className="grid gap-1 leading-none">
76
+ {/* One label line tall, as Radio's control; falls back to English's 20 / 14 without the Fasla preset */}
77
+ <div className="flex h-[calc(var(--leading-sm,calc(20/14))*0.875rem)] shrink-0 items-center">{checkbox}</div>
78
+ <div className="grid gap-1">
92
79
  {label && (
93
80
  <label
94
81
  htmlFor={inputId}
95
82
  className={cn(
96
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
83
+ "text-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
97
84
  error && "text-destructive"
98
85
  )}
99
86
  >
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } 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
+ })
@@ -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("")
@@ -224,22 +237,14 @@ export function Combobox({
224
237
  type="button"
225
238
  onClick={() => !disabled && setOpen(!open)}
226
239
  disabled={disabled}
227
- className="ml-2 shrink-0 text-muted-foreground hover:text-foreground"
228
- aria-label={open ? "Close" : "Open"}
240
+ className="ms-2 shrink-0 text-muted-foreground hover:text-foreground"
241
+ aria-label={open ? closeLabel : openLabel}
229
242
  >
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>
243
+ <ChevronDownIcon
244
+ size={16}
245
+ strokeWidth={1.5}
246
+ className={cn("transition-transform motion-reduce:transition-none", open && "rotate-180")}
247
+ />
243
248
  </button>
244
249
  </div>
245
250
 
@@ -268,7 +273,7 @@ export function Combobox({
268
273
  >
269
274
  {loading ? (
270
275
  <li className="px-2 py-4 text-center text-sm text-muted-foreground">
271
- Loading...
276
+ {loadingText}
272
277
  </li>
273
278
  ) : filteredOptions.length === 0 && !showCreate ? (
274
279
  <li className="px-2 py-4 text-center text-sm text-muted-foreground">
@@ -297,39 +302,20 @@ export function Combobox({
297
302
  {multiple && (
298
303
  <span
299
304
  className={cn(
300
- "mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
305
+ "me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
301
306
  isSelected
302
307
  ? "border-primary bg-primary text-primary-foreground"
303
308
  : "border-input"
304
309
  )}
305
310
  >
306
311
  {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>
312
+ <CheckIcon size={12} strokeWidth={1.5} />
317
313
  )}
318
314
  </span>
319
315
  )}
320
316
  <span className="flex-1">{option.label}</span>
321
317
  {!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>
318
+ <CheckIcon size={16} strokeWidth={1.5} className="ms-2 text-primary" />
333
319
  )}
334
320
  </li>
335
321
  )
@@ -347,18 +333,7 @@ export function Combobox({
347
333
  "bg-accent text-accent-foreground"
348
334
  )}
349
335
  >
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>
336
+ <PlusIcon size={16} strokeWidth={1.5} className="me-2" />
362
337
  {createText} "{search}"
363
338
  </li>
364
339
  )}
@@ -393,12 +368,9 @@ export function Combobox({
393
368
  handleSelect(val)
394
369
  }}
395
370
  className="hover:text-destructive"
396
- aria-label={`Remove ${option?.label || val}`}
371
+ aria-label={removeLabel(option?.label || val)}
397
372
  >
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>
373
+ <XIcon size={12} strokeWidth={1.5} />
402
374
  </button>
403
375
  </span>
404
376
  )
@@ -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", () => {
@@ -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
+ })