@smicolon/fasla-ui 0.2.2
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 +141 -0
- package/bin/fasla-ui.mjs +54 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +241 -0
- package/dist/lib/utils.d.ts +9 -0
- package/dist/lib/utils.js +9 -0
- package/dist/motion/index.d.ts +33 -0
- package/dist/motion/index.js +59 -0
- package/dist/registry/index.d.ts +17 -0
- package/dist/registry/index.js +145 -0
- package/dist/tokens/index.d.ts +55 -0
- package/dist/tokens/index.js +34 -0
- package/package.json +97 -0
- package/registry/blocks/app-shell/AppShell.tsx +66 -0
- package/registry/blocks/app-shell/index.ts +1 -0
- package/registry/blocks/data-table/DataTable.tsx +292 -0
- package/registry/blocks/data-table/index.ts +7 -0
- package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
- package/registry/blocks/empty-state/EmptyState.tsx +164 -0
- package/registry/blocks/empty-state/index.ts +6 -0
- package/registry/blocks/form-section/FormSection.tsx +140 -0
- package/registry/blocks/form-section/index.ts +8 -0
- package/registry/blocks/navbar/Navbar.tsx +190 -0
- package/registry/blocks/navbar/index.ts +16 -0
- package/registry/blocks/page-header/PageHeader.tsx +64 -0
- package/registry/blocks/page-header/index.ts +1 -0
- package/registry/blocks/sidebar/Sidebar.tsx +202 -0
- package/registry/blocks/sidebar/index.ts +16 -0
- package/registry/blocks/stats-card/StatsCard.tsx +134 -0
- package/registry/blocks/stats-card/index.ts +6 -0
- package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
- package/registry/effects/animated-gradient/index.ts +6 -0
- package/registry/effects/border-beam/border-beam.tsx +132 -0
- package/registry/effects/border-beam/index.ts +6 -0
- package/registry/effects/glow-card/glow-card.tsx +145 -0
- package/registry/effects/glow-card/index.ts +6 -0
- package/registry/effects/shimmer-button/index.ts +1 -0
- package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
- package/registry/effects/spotlight/index.ts +6 -0
- package/registry/effects/spotlight/spotlight.tsx +162 -0
- package/registry/effects/text-reveal/index.ts +6 -0
- package/registry/effects/text-reveal/text-reveal.tsx +139 -0
- package/registry/effects/typewriter-text/index.ts +6 -0
- package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
- package/registry/ui/avatar/avatar.tsx +135 -0
- package/registry/ui/avatar/index.ts +1 -0
- package/registry/ui/badge/badge.test.tsx +51 -0
- package/registry/ui/badge/badge.tsx +39 -0
- package/registry/ui/badge/index.ts +1 -0
- package/registry/ui/button/button.test.tsx +87 -0
- package/registry/ui/button/button.tsx +88 -0
- package/registry/ui/button/index.ts +1 -0
- package/registry/ui/card/card.test.tsx +119 -0
- package/registry/ui/card/card.tsx +75 -0
- package/registry/ui/card/index.ts +8 -0
- package/registry/ui/checkbox/checkbox.tsx +113 -0
- package/registry/ui/checkbox/index.ts +1 -0
- package/registry/ui/combobox/combobox.tsx +410 -0
- package/registry/ui/combobox/index.ts +1 -0
- package/registry/ui/input/index.ts +1 -0
- package/registry/ui/input/input.test.tsx +78 -0
- package/registry/ui/input/input.tsx +76 -0
- package/registry/ui/select/index.ts +1 -0
- package/registry/ui/select/select.tsx +94 -0
- package/registry/ui/skeleton/index.ts +7 -0
- package/registry/ui/skeleton/skeleton.test.tsx +104 -0
- package/registry/ui/skeleton/skeleton.tsx +88 -0
- package/registry/ui/switch/index.ts +1 -0
- package/registry/ui/switch/switch.tsx +89 -0
- package/registry/ui/tabs/index.ts +10 -0
- package/registry/ui/tabs/tabs.tsx +150 -0
- package/registry/ui/textarea/index.ts +1 -0
- package/registry/ui/textarea/textarea.tsx +73 -0
- package/registry.json +247 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../../src/lib/utils"
|
|
5
|
+
|
|
6
|
+
export interface ComboboxOption {
|
|
7
|
+
value: string
|
|
8
|
+
label: string
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ComboboxProps {
|
|
13
|
+
/** Available options */
|
|
14
|
+
options: ComboboxOption[]
|
|
15
|
+
/** Currently selected value(s) */
|
|
16
|
+
value?: string | string[]
|
|
17
|
+
/** Called when selection changes */
|
|
18
|
+
onChange?: (value: string | string[]) => void
|
|
19
|
+
/** Placeholder text */
|
|
20
|
+
placeholder?: string
|
|
21
|
+
/** Search placeholder */
|
|
22
|
+
searchPlaceholder?: string
|
|
23
|
+
/** Allow multiple selections */
|
|
24
|
+
multiple?: boolean
|
|
25
|
+
/** Allow creating new options */
|
|
26
|
+
creatable?: boolean
|
|
27
|
+
/** Called when a new option is created */
|
|
28
|
+
onCreate?: (value: string) => void
|
|
29
|
+
/** Text shown when no options match */
|
|
30
|
+
emptyText?: string
|
|
31
|
+
/** Text for create option */
|
|
32
|
+
createText?: string
|
|
33
|
+
/** Disabled state */
|
|
34
|
+
disabled?: boolean
|
|
35
|
+
/** Custom class name */
|
|
36
|
+
className?: string
|
|
37
|
+
/** Loading state */
|
|
38
|
+
loading?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function Combobox({
|
|
42
|
+
options,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
placeholder = "Select option...",
|
|
46
|
+
searchPlaceholder = "Search...",
|
|
47
|
+
multiple = false,
|
|
48
|
+
creatable = false,
|
|
49
|
+
onCreate,
|
|
50
|
+
emptyText = "No options found.",
|
|
51
|
+
createText = "Create",
|
|
52
|
+
disabled = false,
|
|
53
|
+
className,
|
|
54
|
+
loading = false,
|
|
55
|
+
}: ComboboxProps) {
|
|
56
|
+
const [open, setOpen] = React.useState(false)
|
|
57
|
+
const [search, setSearch] = React.useState("")
|
|
58
|
+
const [highlightedIndex, setHighlightedIndex] = React.useState(0)
|
|
59
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
60
|
+
const listRef = React.useRef<HTMLUListElement>(null)
|
|
61
|
+
|
|
62
|
+
// Normalize value to array for easier handling
|
|
63
|
+
const selectedValues = React.useMemo(() => {
|
|
64
|
+
if (!value) return []
|
|
65
|
+
return Array.isArray(value) ? value : [value]
|
|
66
|
+
}, [value])
|
|
67
|
+
|
|
68
|
+
// Filter options based on search
|
|
69
|
+
const filteredOptions = React.useMemo(() => {
|
|
70
|
+
if (!search) return options
|
|
71
|
+
const searchLower = search.toLowerCase()
|
|
72
|
+
return options.filter(
|
|
73
|
+
(option) =>
|
|
74
|
+
option.label.toLowerCase().includes(searchLower) ||
|
|
75
|
+
option.value.toLowerCase().includes(searchLower)
|
|
76
|
+
)
|
|
77
|
+
}, [options, search])
|
|
78
|
+
|
|
79
|
+
// Check if search matches any existing option exactly
|
|
80
|
+
const exactMatch = React.useMemo(() => {
|
|
81
|
+
if (!search) return true
|
|
82
|
+
const searchLower = search.toLowerCase()
|
|
83
|
+
return options.some(
|
|
84
|
+
(option) =>
|
|
85
|
+
option.label.toLowerCase() === searchLower ||
|
|
86
|
+
option.value.toLowerCase() === searchLower
|
|
87
|
+
)
|
|
88
|
+
}, [options, search])
|
|
89
|
+
|
|
90
|
+
// Can show create option
|
|
91
|
+
const showCreate = creatable && search && !exactMatch
|
|
92
|
+
|
|
93
|
+
// Get display value
|
|
94
|
+
const displayValue = React.useMemo(() => {
|
|
95
|
+
if (selectedValues.length === 0) return ""
|
|
96
|
+
if (multiple) {
|
|
97
|
+
return selectedValues
|
|
98
|
+
.map((v) => options.find((o) => o.value === v)?.label || v)
|
|
99
|
+
.join(", ")
|
|
100
|
+
}
|
|
101
|
+
return options.find((o) => o.value === selectedValues[0])?.label || selectedValues[0]
|
|
102
|
+
}, [selectedValues, options, multiple])
|
|
103
|
+
|
|
104
|
+
// Handle option selection
|
|
105
|
+
const handleSelect = (optionValue: string) => {
|
|
106
|
+
if (multiple) {
|
|
107
|
+
const newValues = selectedValues.includes(optionValue)
|
|
108
|
+
? selectedValues.filter((v) => v !== optionValue)
|
|
109
|
+
: [...selectedValues, optionValue]
|
|
110
|
+
onChange?.(newValues)
|
|
111
|
+
} else {
|
|
112
|
+
onChange?.(optionValue)
|
|
113
|
+
setOpen(false)
|
|
114
|
+
setSearch("")
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Handle create new option
|
|
119
|
+
const handleCreate = () => {
|
|
120
|
+
if (onCreate && search) {
|
|
121
|
+
onCreate(search)
|
|
122
|
+
if (!multiple) {
|
|
123
|
+
setOpen(false)
|
|
124
|
+
}
|
|
125
|
+
setSearch("")
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Keyboard navigation
|
|
130
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
131
|
+
if (!open) {
|
|
132
|
+
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
setOpen(true)
|
|
135
|
+
}
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const totalItems = filteredOptions.length + (showCreate ? 1 : 0)
|
|
140
|
+
|
|
141
|
+
switch (e.key) {
|
|
142
|
+
case "ArrowDown":
|
|
143
|
+
e.preventDefault()
|
|
144
|
+
setHighlightedIndex((prev) => (prev + 1) % totalItems)
|
|
145
|
+
break
|
|
146
|
+
case "ArrowUp":
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
setHighlightedIndex((prev) => (prev - 1 + totalItems) % totalItems)
|
|
149
|
+
break
|
|
150
|
+
case "Enter":
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
if (highlightedIndex < filteredOptions.length) {
|
|
153
|
+
const option = filteredOptions[highlightedIndex]
|
|
154
|
+
if (option && !option.disabled) {
|
|
155
|
+
handleSelect(option.value)
|
|
156
|
+
}
|
|
157
|
+
} else if (showCreate) {
|
|
158
|
+
handleCreate()
|
|
159
|
+
}
|
|
160
|
+
break
|
|
161
|
+
case "Escape":
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
setOpen(false)
|
|
164
|
+
setSearch("")
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Scroll highlighted item into view
|
|
170
|
+
React.useEffect(() => {
|
|
171
|
+
if (open && listRef.current) {
|
|
172
|
+
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
|
|
173
|
+
if (highlighted) {
|
|
174
|
+
highlighted.scrollIntoView({ block: "nearest" })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}, [highlightedIndex, open])
|
|
178
|
+
|
|
179
|
+
// Reset highlighted index when search changes
|
|
180
|
+
React.useEffect(() => {
|
|
181
|
+
setHighlightedIndex(0)
|
|
182
|
+
}, [search])
|
|
183
|
+
|
|
184
|
+
// Close on outside click
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
187
|
+
if (inputRef.current && !inputRef.current.closest(".combobox-container")?.contains(e.target as Node)) {
|
|
188
|
+
setOpen(false)
|
|
189
|
+
setSearch("")
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
document.addEventListener("mousedown", handleClickOutside)
|
|
193
|
+
return () => document.removeEventListener("mousedown", handleClickOutside)
|
|
194
|
+
}, [])
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className={cn("combobox-container relative w-full", className)}>
|
|
198
|
+
{/* Trigger / Input */}
|
|
199
|
+
<div
|
|
200
|
+
className={cn(
|
|
201
|
+
"flex min-h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors",
|
|
202
|
+
"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
|
|
203
|
+
disabled && "cursor-not-allowed opacity-50"
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
<input
|
|
207
|
+
ref={inputRef}
|
|
208
|
+
type="text"
|
|
209
|
+
value={open ? search : displayValue}
|
|
210
|
+
onChange={(e) => {
|
|
211
|
+
setSearch(e.target.value)
|
|
212
|
+
if (!open) setOpen(true)
|
|
213
|
+
}}
|
|
214
|
+
onFocus={() => !disabled && setOpen(true)}
|
|
215
|
+
onKeyDown={handleKeyDown}
|
|
216
|
+
placeholder={placeholder}
|
|
217
|
+
disabled={disabled}
|
|
218
|
+
className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
|
219
|
+
aria-expanded={open}
|
|
220
|
+
aria-haspopup="listbox"
|
|
221
|
+
role="combobox"
|
|
222
|
+
/>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={() => !disabled && setOpen(!open)}
|
|
226
|
+
disabled={disabled}
|
|
227
|
+
className="ml-2 shrink-0 text-muted-foreground hover:text-foreground"
|
|
228
|
+
aria-label={open ? "Close" : "Open"}
|
|
229
|
+
>
|
|
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
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{/* Dropdown */}
|
|
247
|
+
{open && (
|
|
248
|
+
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md animate-in fade-in-0 zoom-in-95">
|
|
249
|
+
{/* Search input when open */}
|
|
250
|
+
{displayValue && (
|
|
251
|
+
<div className="border-b p-2">
|
|
252
|
+
<input
|
|
253
|
+
type="text"
|
|
254
|
+
value={search}
|
|
255
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
256
|
+
placeholder={searchPlaceholder}
|
|
257
|
+
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
258
|
+
autoFocus
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{/* Options list */}
|
|
264
|
+
<ul
|
|
265
|
+
ref={listRef}
|
|
266
|
+
role="listbox"
|
|
267
|
+
className="max-h-60 overflow-auto p-1"
|
|
268
|
+
>
|
|
269
|
+
{loading ? (
|
|
270
|
+
<li className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
271
|
+
Loading...
|
|
272
|
+
</li>
|
|
273
|
+
) : filteredOptions.length === 0 && !showCreate ? (
|
|
274
|
+
<li className="px-2 py-4 text-center text-sm text-muted-foreground">
|
|
275
|
+
{emptyText}
|
|
276
|
+
</li>
|
|
277
|
+
) : (
|
|
278
|
+
<>
|
|
279
|
+
{filteredOptions.map((option, index) => {
|
|
280
|
+
const isSelected = selectedValues.includes(option.value)
|
|
281
|
+
const isHighlighted = index === highlightedIndex
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<li
|
|
285
|
+
key={option.value}
|
|
286
|
+
role="option"
|
|
287
|
+
aria-selected={isSelected}
|
|
288
|
+
data-highlighted={isHighlighted}
|
|
289
|
+
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
290
|
+
className={cn(
|
|
291
|
+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
292
|
+
isHighlighted && "bg-accent text-accent-foreground",
|
|
293
|
+
isSelected && "font-medium",
|
|
294
|
+
option.disabled && "pointer-events-none opacity-50"
|
|
295
|
+
)}
|
|
296
|
+
>
|
|
297
|
+
{multiple && (
|
|
298
|
+
<span
|
|
299
|
+
className={cn(
|
|
300
|
+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
|
301
|
+
isSelected
|
|
302
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
303
|
+
: "border-input"
|
|
304
|
+
)}
|
|
305
|
+
>
|
|
306
|
+
{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>
|
|
317
|
+
)}
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
<span className="flex-1">{option.label}</span>
|
|
321
|
+
{!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>
|
|
333
|
+
)}
|
|
334
|
+
</li>
|
|
335
|
+
)
|
|
336
|
+
})}
|
|
337
|
+
|
|
338
|
+
{/* Create new option */}
|
|
339
|
+
{showCreate && (
|
|
340
|
+
<li
|
|
341
|
+
role="option"
|
|
342
|
+
data-highlighted={highlightedIndex === filteredOptions.length}
|
|
343
|
+
onClick={handleCreate}
|
|
344
|
+
className={cn(
|
|
345
|
+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
346
|
+
highlightedIndex === filteredOptions.length &&
|
|
347
|
+
"bg-accent text-accent-foreground"
|
|
348
|
+
)}
|
|
349
|
+
>
|
|
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}"
|
|
363
|
+
</li>
|
|
364
|
+
)}
|
|
365
|
+
</>
|
|
366
|
+
)}
|
|
367
|
+
</ul>
|
|
368
|
+
|
|
369
|
+
{/* Selected count for multiple */}
|
|
370
|
+
{multiple && selectedValues.length > 0 && (
|
|
371
|
+
<div className="border-t px-2 py-1.5 text-xs text-muted-foreground">
|
|
372
|
+
{selectedValues.length} selected
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
|
|
378
|
+
{/* Selected tags for multiple */}
|
|
379
|
+
{multiple && selectedValues.length > 0 && !open && (
|
|
380
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
381
|
+
{selectedValues.map((val) => {
|
|
382
|
+
const option = options.find((o) => o.value === val)
|
|
383
|
+
return (
|
|
384
|
+
<span
|
|
385
|
+
key={val}
|
|
386
|
+
className="inline-flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs"
|
|
387
|
+
>
|
|
388
|
+
{option?.label || val}
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={(e) => {
|
|
392
|
+
e.stopPropagation()
|
|
393
|
+
handleSelect(val)
|
|
394
|
+
}}
|
|
395
|
+
className="hover:text-destructive"
|
|
396
|
+
aria-label={`Remove ${option?.label || val}`}
|
|
397
|
+
>
|
|
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>
|
|
402
|
+
</button>
|
|
403
|
+
</span>
|
|
404
|
+
)
|
|
405
|
+
})}
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Combobox, type ComboboxOption, type ComboboxProps } from "./combobox"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Input, inputVariants, type InputProps } from "./input"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest"
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react"
|
|
3
|
+
import { Input } from "./input"
|
|
4
|
+
|
|
5
|
+
describe("Input", () => {
|
|
6
|
+
it("renders with default props", () => {
|
|
7
|
+
render(<Input placeholder="Enter text" />)
|
|
8
|
+
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it("renders with different variants", () => {
|
|
12
|
+
const { rerender } = render(<Input variant="default" data-testid="input" />)
|
|
13
|
+
expect(screen.getByTestId("input")).toHaveClass("border-input")
|
|
14
|
+
|
|
15
|
+
rerender(<Input variant="error" data-testid="input" />)
|
|
16
|
+
expect(screen.getByTestId("input")).toHaveClass("border-destructive")
|
|
17
|
+
|
|
18
|
+
rerender(<Input variant="success" data-testid="input" />)
|
|
19
|
+
expect(screen.getByTestId("input")).toHaveClass("border-green-500")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("renders with different sizes", () => {
|
|
23
|
+
const { rerender } = render(<Input inputSize="sm" data-testid="input" />)
|
|
24
|
+
expect(screen.getByTestId("input")).toHaveClass("h-8")
|
|
25
|
+
|
|
26
|
+
rerender(<Input inputSize="default" data-testid="input" />)
|
|
27
|
+
expect(screen.getByTestId("input")).toHaveClass("h-9")
|
|
28
|
+
|
|
29
|
+
rerender(<Input inputSize="lg" data-testid="input" />)
|
|
30
|
+
expect(screen.getByTestId("input")).toHaveClass("h-11")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("renders with start icon", () => {
|
|
34
|
+
render(
|
|
35
|
+
<Input
|
|
36
|
+
startIcon={<span data-testid="start-icon">🔍</span>}
|
|
37
|
+
data-testid="input"
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
expect(screen.getByTestId("start-icon")).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByTestId("input")).toHaveClass("pl-10")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("renders with end icon", () => {
|
|
45
|
+
render(
|
|
46
|
+
<Input
|
|
47
|
+
endIcon={<span data-testid="end-icon">✓</span>}
|
|
48
|
+
data-testid="input"
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
expect(screen.getByTestId("end-icon")).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByTestId("input")).toHaveClass("pr-10")
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("handles onChange", () => {
|
|
56
|
+
const handleChange = vi.fn()
|
|
57
|
+
render(<Input onChange={handleChange} data-testid="input" />)
|
|
58
|
+
|
|
59
|
+
fireEvent.change(screen.getByTestId("input"), { target: { value: "test" } })
|
|
60
|
+
expect(handleChange).toHaveBeenCalled()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("is disabled when disabled prop is true", () => {
|
|
64
|
+
render(<Input disabled data-testid="input" />)
|
|
65
|
+
expect(screen.getByTestId("input")).toBeDisabled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("applies custom className", () => {
|
|
69
|
+
render(<Input className="custom-class" data-testid="input" />)
|
|
70
|
+
expect(screen.getByTestId("input")).toHaveClass("custom-class")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("forwards ref correctly", () => {
|
|
74
|
+
const ref = vi.fn()
|
|
75
|
+
render(<Input ref={ref} />)
|
|
76
|
+
expect(ref).toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
import { cn } from "../../../src/lib/utils"
|
|
4
|
+
|
|
5
|
+
const inputVariants = cva(
|
|
6
|
+
"flex w-full rounded-md border bg-transparent text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-input shadow-sm",
|
|
11
|
+
error: "border-destructive focus-visible:ring-destructive",
|
|
12
|
+
success: "border-green-500 focus-visible:ring-green-500",
|
|
13
|
+
},
|
|
14
|
+
inputSize: {
|
|
15
|
+
default: "h-9 px-3 py-1",
|
|
16
|
+
sm: "h-8 px-2 py-1 text-xs",
|
|
17
|
+
lg: "h-11 px-4 py-2",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: {
|
|
21
|
+
variant: "default",
|
|
22
|
+
inputSize: "default",
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export interface InputProps
|
|
28
|
+
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size">,
|
|
29
|
+
VariantProps<typeof inputVariants> {
|
|
30
|
+
startIcon?: React.ReactNode
|
|
31
|
+
endIcon?: React.ReactNode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
35
|
+
({ className, type, variant, inputSize, startIcon, endIcon, ...props }, ref) => {
|
|
36
|
+
if (startIcon || endIcon) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="relative">
|
|
39
|
+
{startIcon && (
|
|
40
|
+
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
|
41
|
+
{startIcon}
|
|
42
|
+
</div>
|
|
43
|
+
)}
|
|
44
|
+
<input
|
|
45
|
+
type={type}
|
|
46
|
+
className={cn(
|
|
47
|
+
inputVariants({ variant, inputSize }),
|
|
48
|
+
startIcon && "pl-10",
|
|
49
|
+
endIcon && "pr-10",
|
|
50
|
+
className
|
|
51
|
+
)}
|
|
52
|
+
ref={ref}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
{endIcon && (
|
|
56
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground">
|
|
57
|
+
{endIcon}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<input
|
|
66
|
+
type={type}
|
|
67
|
+
className={cn(inputVariants({ variant, inputSize, className }))}
|
|
68
|
+
ref={ref}
|
|
69
|
+
{...props}
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
Input.displayName = "Input"
|
|
75
|
+
|
|
76
|
+
export { Input, inputVariants }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Select, type SelectOption, type SelectProps } from "./select"
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "../../../src/lib/utils"
|
|
5
|
+
|
|
6
|
+
export interface SelectOption {
|
|
7
|
+
value: string
|
|
8
|
+
label: string
|
|
9
|
+
disabled?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SelectProps
|
|
13
|
+
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
|
14
|
+
/** Options to display */
|
|
15
|
+
options: SelectOption[]
|
|
16
|
+
/** Placeholder text */
|
|
17
|
+
placeholder?: string
|
|
18
|
+
/** Size variant */
|
|
19
|
+
selectSize?: "sm" | "default" | "lg"
|
|
20
|
+
/** Error state */
|
|
21
|
+
error?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sizeClasses = {
|
|
25
|
+
sm: "h-8 px-2 py-1 text-xs",
|
|
26
|
+
default: "h-9 px-3 py-2 text-sm",
|
|
27
|
+
lg: "h-11 px-4 py-2 text-base",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
31
|
+
(
|
|
32
|
+
{
|
|
33
|
+
className,
|
|
34
|
+
options,
|
|
35
|
+
placeholder,
|
|
36
|
+
selectSize = "default",
|
|
37
|
+
error,
|
|
38
|
+
...props
|
|
39
|
+
},
|
|
40
|
+
ref
|
|
41
|
+
) => {
|
|
42
|
+
return (
|
|
43
|
+
<div className="relative">
|
|
44
|
+
<select
|
|
45
|
+
ref={ref}
|
|
46
|
+
className={cn(
|
|
47
|
+
"w-full appearance-none rounded-md border bg-transparent transition-colors",
|
|
48
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
|
49
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
50
|
+
error
|
|
51
|
+
? "border-destructive focus-visible:ring-destructive"
|
|
52
|
+
: "border-input",
|
|
53
|
+
sizeClasses[selectSize],
|
|
54
|
+
"pr-10",
|
|
55
|
+
className
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
{placeholder && (
|
|
60
|
+
<option value="" disabled>
|
|
61
|
+
{placeholder}
|
|
62
|
+
</option>
|
|
63
|
+
)}
|
|
64
|
+
{options.map((option) => (
|
|
65
|
+
<option
|
|
66
|
+
key={option.value}
|
|
67
|
+
value={option.value}
|
|
68
|
+
disabled={option.disabled}
|
|
69
|
+
>
|
|
70
|
+
{option.label}
|
|
71
|
+
</option>
|
|
72
|
+
))}
|
|
73
|
+
</select>
|
|
74
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground">
|
|
75
|
+
<svg
|
|
76
|
+
width="16"
|
|
77
|
+
height="16"
|
|
78
|
+
viewBox="0 0 24 24"
|
|
79
|
+
fill="none"
|
|
80
|
+
stroke="currentColor"
|
|
81
|
+
strokeWidth="2"
|
|
82
|
+
strokeLinecap="round"
|
|
83
|
+
strokeLinejoin="round"
|
|
84
|
+
>
|
|
85
|
+
<polyline points="6 9 12 15 18 9" />
|
|
86
|
+
</svg>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
Select.displayName = "Select"
|
|
93
|
+
|
|
94
|
+
export { Select }
|