@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.
- package/README.md +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +91 -23
- package/dist/registry/index.js +31 -2
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +4 -1
- package/registry/blocks/app-shell/AppShell.tsx +1 -1
- package/registry/blocks/data-table/DataTable.test.tsx +61 -0
- package/registry/blocks/data-table/DataTable.tsx +53 -16
- package/registry/blocks/data-table/index.ts +1 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +12 -0
- package/registry/blocks/empty-state/EmptyState.tsx +22 -31
- package/registry/blocks/form-section/FormSection.tsx +1 -1
- package/registry/blocks/navbar/Navbar.test.tsx +12 -0
- package/registry/blocks/navbar/Navbar.tsx +8 -23
- package/registry/blocks/page-header/PageHeader.tsx +5 -2
- package/registry/blocks/sidebar/Sidebar.tsx +9 -16
- package/registry/blocks/stats-card/StatsCard.tsx +9 -27
- package/registry/effects/animated-gradient/animated-gradient.tsx +23 -8
- package/registry/effects/border-beam/border-beam.tsx +77 -53
- package/registry/effects/effects.test.tsx +266 -0
- package/registry/effects/glow-card/glow-card.tsx +68 -43
- package/registry/effects/shimmer-button/shimmer-button.tsx +62 -23
- package/registry/effects/spotlight/spotlight.tsx +73 -29
- package/registry/effects/text-reveal/index.ts +1 -0
- package/registry/effects/text-reveal/text-reveal.test.tsx +66 -0
- package/registry/effects/text-reveal/text-reveal.tsx +68 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +41 -22
- package/registry/text-props-undefined.test.tsx +73 -0
- package/registry/ui/avatar/avatar.test.tsx +316 -0
- package/registry/ui/avatar/avatar.tsx +235 -108
- package/registry/ui/avatar/index.ts +1 -1
- package/registry/ui/badge/badge.test.tsx +267 -32
- package/registry/ui/badge/badge.tsx +233 -21
- package/registry/ui/button/button.test.tsx +6 -0
- package/registry/ui/button/button.tsx +6 -23
- package/registry/ui/card/card.tsx +1 -1
- package/registry/ui/checkbox/checkbox.tsx +13 -26
- package/registry/ui/combobox/combobox.test.tsx +43 -0
- package/registry/ui/combobox/combobox.tsx +27 -55
- package/registry/ui/input/input.test.tsx +2 -2
- package/registry/ui/input/input.tsx +4 -4
- package/registry/ui/radio/index.ts +1 -0
- package/registry/ui/radio/radio.test.tsx +248 -0
- package/registry/ui/radio/radio.tsx +208 -0
- package/registry/ui/select/select.tsx +4 -14
- package/registry/ui/skeleton/skeleton.tsx +1 -1
- package/registry/ui/status-indicator/index.ts +6 -0
- package/registry/ui/status-indicator/status-indicator.test.tsx +148 -0
- package/registry/ui/status-indicator/status-indicator.tsx +87 -0
- package/registry/ui/switch/index.ts +1 -1
- package/registry/ui/switch/switch.test.tsx +323 -0
- package/registry/ui/switch/switch.tsx +254 -57
- package/registry/ui/textarea/textarea.tsx +1 -1
- package/registry.json +435 -78
- 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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
91
|
-
<div className="
|
|
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
|
|
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="
|
|
228
|
-
aria-label={open ?
|
|
240
|
+
className="ms-2 shrink-0 text-muted-foreground hover:text-foreground"
|
|
241
|
+
aria-label={open ? closeLabel : openLabel}
|
|
229
242
|
>
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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={
|
|
371
|
+
aria-label={removeLabel(option?.label || val)}
|
|
397
372
|
>
|
|
398
|
-
<
|
|
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("
|
|
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("
|
|
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
|
|
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 && "
|
|
49
|
-
endIcon && "
|
|
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
|
|
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
|
+
})
|