@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.
Files changed (74) hide show
  1. package/README.md +141 -0
  2. package/bin/fasla-ui.mjs +54 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +241 -0
  5. package/dist/lib/utils.d.ts +9 -0
  6. package/dist/lib/utils.js +9 -0
  7. package/dist/motion/index.d.ts +33 -0
  8. package/dist/motion/index.js +59 -0
  9. package/dist/registry/index.d.ts +17 -0
  10. package/dist/registry/index.js +145 -0
  11. package/dist/tokens/index.d.ts +55 -0
  12. package/dist/tokens/index.js +34 -0
  13. package/package.json +97 -0
  14. package/registry/blocks/app-shell/AppShell.tsx +66 -0
  15. package/registry/blocks/app-shell/index.ts +1 -0
  16. package/registry/blocks/data-table/DataTable.tsx +292 -0
  17. package/registry/blocks/data-table/index.ts +7 -0
  18. package/registry/blocks/empty-state/EmptyState.test.tsx +120 -0
  19. package/registry/blocks/empty-state/EmptyState.tsx +164 -0
  20. package/registry/blocks/empty-state/index.ts +6 -0
  21. package/registry/blocks/form-section/FormSection.tsx +140 -0
  22. package/registry/blocks/form-section/index.ts +8 -0
  23. package/registry/blocks/navbar/Navbar.tsx +190 -0
  24. package/registry/blocks/navbar/index.ts +16 -0
  25. package/registry/blocks/page-header/PageHeader.tsx +64 -0
  26. package/registry/blocks/page-header/index.ts +1 -0
  27. package/registry/blocks/sidebar/Sidebar.tsx +202 -0
  28. package/registry/blocks/sidebar/index.ts +16 -0
  29. package/registry/blocks/stats-card/StatsCard.tsx +134 -0
  30. package/registry/blocks/stats-card/index.ts +6 -0
  31. package/registry/effects/animated-gradient/animated-gradient.tsx +136 -0
  32. package/registry/effects/animated-gradient/index.ts +6 -0
  33. package/registry/effects/border-beam/border-beam.tsx +132 -0
  34. package/registry/effects/border-beam/index.ts +6 -0
  35. package/registry/effects/glow-card/glow-card.tsx +145 -0
  36. package/registry/effects/glow-card/index.ts +6 -0
  37. package/registry/effects/shimmer-button/index.ts +1 -0
  38. package/registry/effects/shimmer-button/shimmer-button.tsx +91 -0
  39. package/registry/effects/spotlight/index.ts +6 -0
  40. package/registry/effects/spotlight/spotlight.tsx +162 -0
  41. package/registry/effects/text-reveal/index.ts +6 -0
  42. package/registry/effects/text-reveal/text-reveal.tsx +139 -0
  43. package/registry/effects/typewriter-text/index.ts +6 -0
  44. package/registry/effects/typewriter-text/typewriter-text.tsx +220 -0
  45. package/registry/ui/avatar/avatar.tsx +135 -0
  46. package/registry/ui/avatar/index.ts +1 -0
  47. package/registry/ui/badge/badge.test.tsx +51 -0
  48. package/registry/ui/badge/badge.tsx +39 -0
  49. package/registry/ui/badge/index.ts +1 -0
  50. package/registry/ui/button/button.test.tsx +87 -0
  51. package/registry/ui/button/button.tsx +88 -0
  52. package/registry/ui/button/index.ts +1 -0
  53. package/registry/ui/card/card.test.tsx +119 -0
  54. package/registry/ui/card/card.tsx +75 -0
  55. package/registry/ui/card/index.ts +8 -0
  56. package/registry/ui/checkbox/checkbox.tsx +113 -0
  57. package/registry/ui/checkbox/index.ts +1 -0
  58. package/registry/ui/combobox/combobox.tsx +410 -0
  59. package/registry/ui/combobox/index.ts +1 -0
  60. package/registry/ui/input/index.ts +1 -0
  61. package/registry/ui/input/input.test.tsx +78 -0
  62. package/registry/ui/input/input.tsx +76 -0
  63. package/registry/ui/select/index.ts +1 -0
  64. package/registry/ui/select/select.tsx +94 -0
  65. package/registry/ui/skeleton/index.ts +7 -0
  66. package/registry/ui/skeleton/skeleton.test.tsx +104 -0
  67. package/registry/ui/skeleton/skeleton.tsx +88 -0
  68. package/registry/ui/switch/index.ts +1 -0
  69. package/registry/ui/switch/switch.tsx +89 -0
  70. package/registry/ui/tabs/index.ts +10 -0
  71. package/registry/ui/tabs/tabs.tsx +150 -0
  72. package/registry/ui/textarea/index.ts +1 -0
  73. package/registry/ui/textarea/textarea.tsx +73 -0
  74. 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 }
@@ -0,0 +1,7 @@
1
+ export {
2
+ Skeleton,
3
+ SkeletonText,
4
+ SkeletonAvatar,
5
+ SkeletonCard,
6
+ type SkeletonProps,
7
+ } from "./skeleton"