@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.
- package/README.md +44 -20
- package/dist/index.cjs +319 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +92 -24
- package/dist/lib/utils.cjs +11 -0
- package/dist/lib/utils.d.cts +9 -0
- package/dist/motion/index.cjs +61 -0
- package/dist/motion/index.d.cts +33 -0
- package/dist/registry/index.cjs +179 -0
- package/dist/registry/index.d.cts +17 -0
- package/dist/registry/index.js +32 -3
- package/dist/tokens/index.cjs +77 -0
- package/dist/tokens/index.d.cts +124 -0
- package/dist/tokens/index.d.ts +75 -6
- package/dist/tokens/index.js +59 -20
- package/package.json +17 -7
- 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 +23 -0
- package/registry/blocks/empty-state/EmptyState.tsx +32 -33
- 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.test.tsx +39 -0
- package/registry/blocks/sidebar/Sidebar.tsx +36 -26
- package/registry/blocks/stats-card/StatsCard.tsx +12 -29
- package/registry/effects/animated-gradient/animated-gradient.tsx +19 -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 +70 -21
- package/registry/effects/typewriter-text/typewriter-text.tsx +39 -20
- package/registry/next14-tsconfig.test.ts +43 -0
- 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.test.tsx +23 -0
- package/registry/ui/checkbox/checkbox.tsx +16 -27
- package/registry/ui/combobox/combobox.test.tsx +72 -0
- package/registry/ui/combobox/combobox.tsx +34 -59
- package/registry/ui/input/input.test.tsx +3 -3
- package/registry/ui/input/input.tsx +5 -5
- 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/index.ts +1 -0
- package/registry/ui/skeleton/skeleton.test.tsx +113 -29
- package/registry/ui/skeleton/skeleton.tsx +101 -41
- 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 +79 -16
- 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
|
-
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
91
|
-
<div className="
|
|
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
|
|
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
|
-
|
|
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
|
-
}
|
|
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="
|
|
228
|
-
aria-label={open ?
|
|
243
|
+
className="ms-2 shrink-0 text-muted-foreground hover:text-foreground"
|
|
244
|
+
aria-label={open ? closeLabel : openLabel}
|
|
229
245
|
>
|
|
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>
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
351
|
-
|
|
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} "{search}"
|
|
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={
|
|
374
|
+
aria-label={removeLabel(option?.label || val)}
|
|
397
375
|
>
|
|
398
|
-
<
|
|
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-
|
|
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("
|
|
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", () => {
|
|
@@ -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-
|
|
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
|
|
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
|
+
})
|