@srcroot/ui 0.0.1 → 0.0.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/dist/index.js +91 -0
- package/package.json +9 -3
- package/registry/calendar.tsx +416 -142
- package/registry/combobox.tsx +174 -0
- package/registry/command.tsx +298 -0
- package/registry/context-menu.tsx +221 -0
- package/registry/date-picker.tsx +179 -0
- package/registry/drawer.tsx +217 -0
- package/registry/dropdown-menu.tsx +1 -30
- package/registry/file-upload.tsx +240 -0
- package/registry/hover-card.tsx +165 -0
- package/registry/kbd.tsx +60 -0
- package/registry/menubar.tsx +246 -0
- package/registry/native-select.tsx +49 -0
- package/registry/pagination.tsx +3 -0
- package/registry/resizable.tsx +213 -0
- package/registry/scroll-area.tsx +60 -0
- package/registry/search.tsx +2 -1
- package/registry/sheet.tsx +1 -0
- package/registry/sidebar.tsx +505 -0
- package/registry/slider.tsx +82 -18
- package/registry/toggle-group.tsx +129 -0
- package/registry/toggle.tsx +72 -0
- package/registry/tooltip.tsx +21 -3
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Calendar } from "./calendar"
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
|
6
|
+
import { Button } from "./button"
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
// Calendar icon
|
|
10
|
+
const CalendarIcon = () => (
|
|
11
|
+
<svg
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
width="16"
|
|
14
|
+
height="16"
|
|
15
|
+
viewBox="0 0 24 24"
|
|
16
|
+
fill="none"
|
|
17
|
+
stroke="currentColor"
|
|
18
|
+
strokeWidth="2"
|
|
19
|
+
strokeLinecap="round"
|
|
20
|
+
strokeLinejoin="round"
|
|
21
|
+
className="mr-2 h-4 w-4 opacity-50"
|
|
22
|
+
>
|
|
23
|
+
<rect width="18" height="18" x="3" y="4" rx="2" ry="2" />
|
|
24
|
+
<line x1="16" x2="16" y1="2" y2="6" />
|
|
25
|
+
<line x1="8" x2="8" y1="2" y2="6" />
|
|
26
|
+
<line x1="3" x2="21" y1="10" y2="10" />
|
|
27
|
+
</svg>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// Format helpers
|
|
31
|
+
const formatDate = (date: Date) => {
|
|
32
|
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const formatRange = (dates: Date[]) => {
|
|
36
|
+
if (dates.length === 0) return null
|
|
37
|
+
if (dates.length === 1) return formatDate(dates[0])
|
|
38
|
+
return `${formatDate(dates[0])} → ${formatDate(dates[1])}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const formatMultiple = (dates: Date[]) => {
|
|
42
|
+
if (dates.length === 0) return null
|
|
43
|
+
return `${dates.length} date${dates.length > 1 ? 's' : ''} selected`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// DatePicker Props
|
|
47
|
+
interface DatePickerBaseProps {
|
|
48
|
+
/** Placeholder text when no date selected */
|
|
49
|
+
placeholder?: string
|
|
50
|
+
/** Whether the picker is disabled */
|
|
51
|
+
disabled?: boolean
|
|
52
|
+
/** Custom class name for the trigger button */
|
|
53
|
+
className?: string
|
|
54
|
+
/** Number of months to display */
|
|
55
|
+
numberOfMonths?: 1 | 2
|
|
56
|
+
/** Calendar size */
|
|
57
|
+
size?: "xs" | "sm" | "default" | "md" | "lg"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DatePickerSingleProps extends DatePickerBaseProps {
|
|
61
|
+
mode?: "single"
|
|
62
|
+
selected?: Date
|
|
63
|
+
onSelect?: (date: Date | undefined) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface DatePickerMultipleProps extends DatePickerBaseProps {
|
|
67
|
+
mode: "multiple"
|
|
68
|
+
selected?: Date[]
|
|
69
|
+
onSelect?: (dates: Date[]) => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface DatePickerRangeProps extends DatePickerBaseProps {
|
|
73
|
+
mode: "range"
|
|
74
|
+
selected?: Date[]
|
|
75
|
+
onSelect?: (dates: Date[]) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type DatePickerProps = DatePickerSingleProps | DatePickerMultipleProps | DatePickerRangeProps
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* DatePicker - A complete date picker component
|
|
82
|
+
*
|
|
83
|
+
* Combines Calendar + Popover for a ready-to-use date selection experience.
|
|
84
|
+
* Supports single, multiple, and range selection modes.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* // Single date
|
|
88
|
+
* <DatePicker selected={date} onSelect={setDate} />
|
|
89
|
+
*
|
|
90
|
+
* // Multiple dates
|
|
91
|
+
* <DatePicker mode="multiple" selected={dates} onSelect={setDates} />
|
|
92
|
+
*
|
|
93
|
+
* // Date range with dual months
|
|
94
|
+
* <DatePicker mode="range" numberOfMonths={2} selected={range} onSelect={setRange} />
|
|
95
|
+
*/
|
|
96
|
+
const DatePicker = React.forwardRef<HTMLButtonElement, DatePickerProps>(
|
|
97
|
+
({
|
|
98
|
+
mode = "single",
|
|
99
|
+
selected,
|
|
100
|
+
onSelect,
|
|
101
|
+
placeholder,
|
|
102
|
+
disabled = false,
|
|
103
|
+
className,
|
|
104
|
+
numberOfMonths = 1,
|
|
105
|
+
size = "default",
|
|
106
|
+
...props
|
|
107
|
+
}, ref) => {
|
|
108
|
+
const [open, setOpen] = React.useState(false)
|
|
109
|
+
|
|
110
|
+
// Determine display text
|
|
111
|
+
const getDisplayText = () => {
|
|
112
|
+
if (mode === "single") {
|
|
113
|
+
return selected ? formatDate(selected as Date) : null
|
|
114
|
+
} else if (mode === "multiple") {
|
|
115
|
+
const dates = (selected as Date[]) || []
|
|
116
|
+
return dates.length > 0 ? formatMultiple(dates) : null
|
|
117
|
+
} else {
|
|
118
|
+
const dates = (selected as Date[]) || []
|
|
119
|
+
return dates.length > 0 ? formatRange(dates) : null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const displayText = getDisplayText()
|
|
124
|
+
const defaultPlaceholder = mode === "single" ? "Pick a date"
|
|
125
|
+
: mode === "multiple" ? "Pick dates"
|
|
126
|
+
: "Pick a date range"
|
|
127
|
+
|
|
128
|
+
// Handle selection
|
|
129
|
+
const handleSelect = (value: any) => {
|
|
130
|
+
if (mode === "single") {
|
|
131
|
+
(onSelect as ((date: Date | undefined) => void))?.(value)
|
|
132
|
+
setOpen(false) // Close on single selection
|
|
133
|
+
} else if (mode === "multiple") {
|
|
134
|
+
(onSelect as ((dates: Date[]) => void))?.(value || [])
|
|
135
|
+
} else {
|
|
136
|
+
const dates = value || []
|
|
137
|
+
; (onSelect as ((dates: Date[]) => void))?.(dates)
|
|
138
|
+
// Close when range is complete (2 dates)
|
|
139
|
+
if (dates.length === 2) {
|
|
140
|
+
setOpen(false)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
147
|
+
<PopoverTrigger asChild>
|
|
148
|
+
<Button
|
|
149
|
+
ref={ref}
|
|
150
|
+
variant="outline"
|
|
151
|
+
disabled={disabled}
|
|
152
|
+
className={cn(
|
|
153
|
+
"w-[280px] justify-start text-left font-normal",
|
|
154
|
+
!displayText && "text-muted-foreground",
|
|
155
|
+
numberOfMonths === 2 && "w-[320px]",
|
|
156
|
+
className
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
<CalendarIcon />
|
|
160
|
+
{displayText || <span>{placeholder || defaultPlaceholder}</span>}
|
|
161
|
+
</Button>
|
|
162
|
+
</PopoverTrigger>
|
|
163
|
+
<PopoverContent className="w-auto p-0">
|
|
164
|
+
<Calendar
|
|
165
|
+
mode={mode}
|
|
166
|
+
numberOfMonths={numberOfMonths}
|
|
167
|
+
size={size}
|
|
168
|
+
selected={selected}
|
|
169
|
+
onSelect={handleSelect}
|
|
170
|
+
className="rounded-md border-0 shadow-none"
|
|
171
|
+
/>
|
|
172
|
+
</PopoverContent>
|
|
173
|
+
</Popover>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
DatePicker.displayName = "DatePicker"
|
|
178
|
+
|
|
179
|
+
export { DatePicker, type DatePickerProps }
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
// Drawer Context
|
|
7
|
+
interface DrawerContextValue {
|
|
8
|
+
open: boolean
|
|
9
|
+
onOpenChange: (open: boolean) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DrawerContext = React.createContext<DrawerContextValue | null>(null)
|
|
13
|
+
|
|
14
|
+
function useDrawer() {
|
|
15
|
+
const context = React.useContext(DrawerContext)
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("useDrawer must be used within a Drawer")
|
|
18
|
+
}
|
|
19
|
+
return context
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Drawer Root
|
|
23
|
+
interface DrawerProps {
|
|
24
|
+
children: React.ReactNode
|
|
25
|
+
open?: boolean
|
|
26
|
+
onOpenChange?: (open: boolean) => void
|
|
27
|
+
defaultOpen?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const Drawer = ({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: DrawerProps) => {
|
|
31
|
+
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
32
|
+
const open = controlledOpen ?? uncontrolledOpen
|
|
33
|
+
const setOpen = onOpenChange ?? setUncontrolledOpen
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<DrawerContext.Provider value={{ open, onOpenChange: setOpen }}>
|
|
37
|
+
{children}
|
|
38
|
+
</DrawerContext.Provider>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Drawer Trigger
|
|
43
|
+
interface DrawerTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
44
|
+
asChild?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DrawerTrigger = React.forwardRef<HTMLButtonElement, DrawerTriggerProps>(
|
|
48
|
+
({ children, asChild, onClick, ...props }, ref) => {
|
|
49
|
+
const { onOpenChange } = useDrawer()
|
|
50
|
+
|
|
51
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
52
|
+
onClick?.(e)
|
|
53
|
+
onOpenChange(true)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (asChild && React.isValidElement(children)) {
|
|
57
|
+
return React.cloneElement(children as React.ReactElement<any>, {
|
|
58
|
+
onClick: handleClick,
|
|
59
|
+
ref,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<button ref={ref} onClick={handleClick} {...props}>
|
|
65
|
+
{children}
|
|
66
|
+
</button>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
DrawerTrigger.displayName = "DrawerTrigger"
|
|
71
|
+
|
|
72
|
+
// Drawer Close
|
|
73
|
+
const DrawerClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
|
|
74
|
+
({ children, onClick, ...props }, ref) => {
|
|
75
|
+
const { onOpenChange } = useDrawer()
|
|
76
|
+
|
|
77
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
78
|
+
onClick?.(e)
|
|
79
|
+
onOpenChange(false)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<button ref={ref} onClick={handleClick} {...props}>
|
|
84
|
+
{children}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
DrawerClose.displayName = "DrawerClose"
|
|
90
|
+
|
|
91
|
+
// Drawer Overlay
|
|
92
|
+
const DrawerOverlay = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
93
|
+
({ className, ...props }, ref) => {
|
|
94
|
+
const { open, onOpenChange } = useDrawer()
|
|
95
|
+
|
|
96
|
+
if (!open) return null
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
ref={ref}
|
|
101
|
+
className={cn(
|
|
102
|
+
"fixed inset-0 z-50 bg-black/80",
|
|
103
|
+
"animate-in fade-in-0",
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
onClick={() => onOpenChange(false)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
DrawerOverlay.displayName = "DrawerOverlay"
|
|
113
|
+
|
|
114
|
+
// Drawer Content
|
|
115
|
+
interface DrawerContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
116
|
+
side?: "bottom" | "top"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(
|
|
120
|
+
({ className, children, side = "bottom", ...props }, ref) => {
|
|
121
|
+
const { open, onOpenChange } = useDrawer()
|
|
122
|
+
|
|
123
|
+
// Close on Escape
|
|
124
|
+
React.useEffect(() => {
|
|
125
|
+
if (!open) return
|
|
126
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
127
|
+
if (e.key === "Escape") onOpenChange(false)
|
|
128
|
+
}
|
|
129
|
+
document.addEventListener("keydown", handleEscape)
|
|
130
|
+
return () => document.removeEventListener("keydown", handleEscape)
|
|
131
|
+
}, [open, onOpenChange])
|
|
132
|
+
|
|
133
|
+
// Prevent body scroll when open
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
if (open) {
|
|
136
|
+
document.body.style.overflow = "hidden"
|
|
137
|
+
} else {
|
|
138
|
+
document.body.style.overflow = ""
|
|
139
|
+
}
|
|
140
|
+
return () => {
|
|
141
|
+
document.body.style.overflow = ""
|
|
142
|
+
}
|
|
143
|
+
}, [open])
|
|
144
|
+
|
|
145
|
+
if (!open) return null
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<>
|
|
149
|
+
<DrawerOverlay />
|
|
150
|
+
<div
|
|
151
|
+
ref={ref}
|
|
152
|
+
className={cn(
|
|
153
|
+
"fixed z-50 flex flex-col bg-background shadow-lg",
|
|
154
|
+
"transition-transform duration-300 ease-out",
|
|
155
|
+
side === "bottom" && "inset-x-0 bottom-0 rounded-t-xl border-t",
|
|
156
|
+
side === "top" && "inset-x-0 top-0 rounded-b-xl border-b",
|
|
157
|
+
// Animation
|
|
158
|
+
side === "bottom" && "animate-in slide-in-from-bottom",
|
|
159
|
+
side === "top" && "animate-in slide-in-from-top",
|
|
160
|
+
className
|
|
161
|
+
)}
|
|
162
|
+
{...props}
|
|
163
|
+
>
|
|
164
|
+
{/* Handle indicator */}
|
|
165
|
+
{side === "bottom" && (
|
|
166
|
+
<div className="mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted" />
|
|
167
|
+
)}
|
|
168
|
+
{children}
|
|
169
|
+
{side === "top" && (
|
|
170
|
+
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-muted" />
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
DrawerContent.displayName = "DrawerContent"
|
|
178
|
+
|
|
179
|
+
// Drawer Header
|
|
180
|
+
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
181
|
+
<div className={cn("flex flex-col space-y-1.5 p-4 text-center sm:text-left", className)} {...props} />
|
|
182
|
+
)
|
|
183
|
+
DrawerHeader.displayName = "DrawerHeader"
|
|
184
|
+
|
|
185
|
+
// Drawer Footer
|
|
186
|
+
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
187
|
+
<div className={cn("flex flex-col-reverse gap-2 p-4 sm:flex-row sm:justify-end", className)} {...props} />
|
|
188
|
+
)
|
|
189
|
+
DrawerFooter.displayName = "DrawerFooter"
|
|
190
|
+
|
|
191
|
+
// Drawer Title
|
|
192
|
+
const DrawerTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
193
|
+
({ className, ...props }, ref) => (
|
|
194
|
+
<h2 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
DrawerTitle.displayName = "DrawerTitle"
|
|
198
|
+
|
|
199
|
+
// Drawer Description
|
|
200
|
+
const DrawerDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
201
|
+
({ className, ...props }, ref) => (
|
|
202
|
+
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
DrawerDescription.displayName = "DrawerDescription"
|
|
206
|
+
|
|
207
|
+
export {
|
|
208
|
+
Drawer,
|
|
209
|
+
DrawerTrigger,
|
|
210
|
+
DrawerClose,
|
|
211
|
+
DrawerOverlay,
|
|
212
|
+
DrawerContent,
|
|
213
|
+
DrawerHeader,
|
|
214
|
+
DrawerFooter,
|
|
215
|
+
DrawerTitle,
|
|
216
|
+
DrawerDescription,
|
|
217
|
+
}
|
|
@@ -193,18 +193,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
|
193
193
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
194
194
|
if (disabled) return
|
|
195
195
|
onClick?.(e)
|
|
196
|
-
// Checkbox items
|
|
197
|
-
// Standard behavior is often to keep open for multiple selections,
|
|
198
|
-
// but Radix primitives usually don't close.
|
|
199
|
-
// Let's assume user wants to toggle and keep open or close depending on UX.
|
|
200
|
-
// For this simple implementation, let's NOT close it automatically.
|
|
201
|
-
// Actually, for a "native-like" feel, single click usually toggles and keeps open?
|
|
202
|
-
// No, standard non-native dropdowns usually close.
|
|
203
|
-
// But for checkboxes, you might want to select multiple.
|
|
204
|
-
// Let's stick to closing for now to be safe, or check standard behavior.
|
|
205
|
-
// Radix UI defaults to NOT closing on selection for CheckboxItem.
|
|
206
|
-
// But here we are building a custom one.
|
|
207
|
-
// Let's NOT close it.
|
|
196
|
+
// Checkbox items don't close the menu to allow multiple selections
|
|
208
197
|
e.preventDefault()
|
|
209
198
|
e.stopPropagation()
|
|
210
199
|
}
|
|
@@ -263,13 +252,6 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
263
252
|
React.HTMLAttributes<HTMLDivElement> & { value: string; disabled?: boolean }
|
|
264
253
|
>(({ className, children, value, disabled, onClick, ...props }, ref) => {
|
|
265
254
|
const context = React.useContext(DropdownMenuContext)
|
|
266
|
-
// We strictly don't have a RadioGroup context here in this simple implementation,
|
|
267
|
-
// so we rely on the parent RadioGroup to handle state via context if we were using Radix.
|
|
268
|
-
// However, since this is "copy/paste" simple code, we might just style it.
|
|
269
|
-
// Realistically, to support `onValueChange` properly, we need a Context for RadioGroup.
|
|
270
|
-
// Let's just implement the UI part for now as requested, assuming controlled state is handled by parent.
|
|
271
|
-
// Wait, the playground likely expects it to work.
|
|
272
|
-
// The request is about "exported member", implying it just needs to exist.
|
|
273
255
|
|
|
274
256
|
return (
|
|
275
257
|
<div
|
|
@@ -290,17 +272,6 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
290
272
|
{...props}
|
|
291
273
|
>
|
|
292
274
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
293
|
-
{/* We don't have 'checked' state passed here easily without context.
|
|
294
|
-
Consumers usually pass `checked={value === itemValue}` etc if using primitive.
|
|
295
|
-
But look at the error: "no exported member".
|
|
296
|
-
It seems they just want the component definitions.
|
|
297
|
-
Standard Radix RadioItem has a `checked` prop too?
|
|
298
|
-
Actually, let's assume the user passes a `checked` prop or handles logic.
|
|
299
|
-
But wait, `value` is passed.
|
|
300
|
-
Let's update the signature to accept `checked` for visual indicator if needed,
|
|
301
|
-
or just render a circle.
|
|
302
|
-
The previous error log didn't complain about props, just missing export.
|
|
303
|
-
*/}
|
|
304
275
|
<svg
|
|
305
276
|
xmlns="http://www.w3.org/2000/svg"
|
|
306
277
|
viewBox="0 0 24 24"
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Upload, X, File, Image, FileText, Film, Music } from "lucide-react"
|
|
5
|
+
import { cn } from "@/lib/utils"
|
|
6
|
+
import { Button } from "@/components/ui/button"
|
|
7
|
+
|
|
8
|
+
interface FileUploadProps {
|
|
9
|
+
onChange?: (files: File[]) => void
|
|
10
|
+
accept?: string
|
|
11
|
+
multiple?: boolean
|
|
12
|
+
maxSize?: number // in bytes
|
|
13
|
+
maxFiles?: number
|
|
14
|
+
className?: string
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface UploadedFile {
|
|
19
|
+
file: File
|
|
20
|
+
preview?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const getFileIcon = (type: string) => {
|
|
24
|
+
if (type.startsWith("image/")) return Image
|
|
25
|
+
if (type.startsWith("video/")) return Film
|
|
26
|
+
if (type.startsWith("audio/")) return Music
|
|
27
|
+
if (type.includes("pdf") || type.includes("document")) return FileText
|
|
28
|
+
return File
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const formatFileSize = (bytes: number): string => {
|
|
32
|
+
if (bytes === 0) return "0 Bytes"
|
|
33
|
+
const k = 1024
|
|
34
|
+
const sizes = ["Bytes", "KB", "MB", "GB"]
|
|
35
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
36
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function FileUpload({
|
|
40
|
+
onChange,
|
|
41
|
+
accept,
|
|
42
|
+
multiple = false,
|
|
43
|
+
maxSize = 10 * 1024 * 1024, // 10MB default
|
|
44
|
+
maxFiles = 5,
|
|
45
|
+
className,
|
|
46
|
+
disabled = false,
|
|
47
|
+
}: FileUploadProps) {
|
|
48
|
+
const [files, setFiles] = React.useState<UploadedFile[]>([])
|
|
49
|
+
const [isDragging, setIsDragging] = React.useState(false)
|
|
50
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
51
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
52
|
+
|
|
53
|
+
const handleFiles = React.useCallback(
|
|
54
|
+
(newFiles: FileList | null) => {
|
|
55
|
+
if (!newFiles) return
|
|
56
|
+
setError(null)
|
|
57
|
+
|
|
58
|
+
const fileArray = Array.from(newFiles)
|
|
59
|
+
|
|
60
|
+
// Validate file count
|
|
61
|
+
if (!multiple && fileArray.length > 1) {
|
|
62
|
+
setError("Only one file allowed")
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (multiple && files.length + fileArray.length > maxFiles) {
|
|
67
|
+
setError(`Maximum ${maxFiles} files allowed`)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Validate file sizes
|
|
72
|
+
const oversizedFiles = fileArray.filter(f => f.size > maxSize)
|
|
73
|
+
if (oversizedFiles.length > 0) {
|
|
74
|
+
setError(`File(s) exceed maximum size of ${formatFileSize(maxSize)}`)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const uploadedFiles: UploadedFile[] = fileArray.map(file => ({
|
|
79
|
+
file,
|
|
80
|
+
preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined
|
|
81
|
+
}))
|
|
82
|
+
|
|
83
|
+
const newFileList = multiple ? [...files, ...uploadedFiles] : uploadedFiles
|
|
84
|
+
setFiles(newFileList)
|
|
85
|
+
onChange?.(newFileList.map(f => f.file))
|
|
86
|
+
},
|
|
87
|
+
[files, multiple, maxFiles, maxSize, onChange]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const handleDragOver = (e: React.DragEvent) => {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
if (!disabled) {
|
|
93
|
+
setIsDragging(true)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const handleDragLeave = (e: React.DragEvent) => {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
setIsDragging(false)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
setIsDragging(false)
|
|
105
|
+
if (!disabled) {
|
|
106
|
+
handleFiles(e.dataTransfer.files)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const removeFile = (index: number) => {
|
|
111
|
+
const newFiles = files.filter((_, i) => i !== index)
|
|
112
|
+
// Revoke object URL to prevent memory leaks
|
|
113
|
+
if (files[index].preview) {
|
|
114
|
+
URL.revokeObjectURL(files[index].preview!)
|
|
115
|
+
}
|
|
116
|
+
setFiles(newFiles)
|
|
117
|
+
onChange?.(newFiles.map(f => f.file))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const handleClick = () => {
|
|
121
|
+
if (!disabled) {
|
|
122
|
+
inputRef.current?.click()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Cleanup previews on unmount
|
|
127
|
+
React.useEffect(() => {
|
|
128
|
+
return () => {
|
|
129
|
+
files.forEach(f => {
|
|
130
|
+
if (f.preview) URL.revokeObjectURL(f.preview)
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn("space-y-4", className)}>
|
|
137
|
+
{/* Drop Zone */}
|
|
138
|
+
<div
|
|
139
|
+
onClick={handleClick}
|
|
140
|
+
onDragOver={handleDragOver}
|
|
141
|
+
onDragLeave={handleDragLeave}
|
|
142
|
+
onDrop={handleDrop}
|
|
143
|
+
className={cn(
|
|
144
|
+
"relative flex flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer",
|
|
145
|
+
isDragging
|
|
146
|
+
? "border-primary bg-primary/5"
|
|
147
|
+
: "border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/50",
|
|
148
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
149
|
+
error && "border-destructive"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
<input
|
|
153
|
+
ref={inputRef}
|
|
154
|
+
type="file"
|
|
155
|
+
accept={accept}
|
|
156
|
+
multiple={multiple}
|
|
157
|
+
onChange={(e) => handleFiles(e.target.files)}
|
|
158
|
+
className="hidden"
|
|
159
|
+
disabled={disabled}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
<div className={cn(
|
|
163
|
+
"flex h-14 w-14 items-center justify-center rounded-full bg-muted transition-colors",
|
|
164
|
+
isDragging && "bg-primary/10"
|
|
165
|
+
)}>
|
|
166
|
+
<Upload className={cn(
|
|
167
|
+
"h-6 w-6 text-muted-foreground",
|
|
168
|
+
isDragging && "text-primary"
|
|
169
|
+
)} />
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="text-center">
|
|
173
|
+
<p className="text-sm font-medium">
|
|
174
|
+
{isDragging ? "Drop files here" : "Drag & drop files here"}
|
|
175
|
+
</p>
|
|
176
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
177
|
+
or click to browse
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<p className="text-xs text-muted-foreground">
|
|
182
|
+
{accept ? `Accepted: ${accept}` : "All file types accepted"} • Max {formatFileSize(maxSize)}
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Error Message */}
|
|
187
|
+
{error && (
|
|
188
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
189
|
+
)}
|
|
190
|
+
|
|
191
|
+
{/* File List */}
|
|
192
|
+
{files.length > 0 && (
|
|
193
|
+
<div className="space-y-2">
|
|
194
|
+
{files.map((uploadedFile, index) => {
|
|
195
|
+
const FileIcon = getFileIcon(uploadedFile.file.type)
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
key={index}
|
|
199
|
+
className="flex items-center gap-3 rounded-lg border bg-card p-3"
|
|
200
|
+
>
|
|
201
|
+
{uploadedFile.preview ? (
|
|
202
|
+
<img
|
|
203
|
+
src={uploadedFile.preview}
|
|
204
|
+
alt={uploadedFile.file.name}
|
|
205
|
+
className="h-10 w-10 rounded object-cover"
|
|
206
|
+
/>
|
|
207
|
+
) : (
|
|
208
|
+
<div className="flex h-10 w-10 items-center justify-center rounded bg-muted">
|
|
209
|
+
<FileIcon className="h-5 w-5 text-muted-foreground" />
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
<div className="flex-1 min-w-0">
|
|
214
|
+
<p className="text-sm font-medium truncate">
|
|
215
|
+
{uploadedFile.file.name}
|
|
216
|
+
</p>
|
|
217
|
+
<p className="text-xs text-muted-foreground">
|
|
218
|
+
{formatFileSize(uploadedFile.file.size)}
|
|
219
|
+
</p>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<Button
|
|
223
|
+
variant="ghost"
|
|
224
|
+
size="icon"
|
|
225
|
+
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
226
|
+
onClick={(e: React.MouseEvent) => {
|
|
227
|
+
e.stopPropagation()
|
|
228
|
+
removeFile(index)
|
|
229
|
+
}}
|
|
230
|
+
>
|
|
231
|
+
<X className="h-4 w-4" />
|
|
232
|
+
</Button>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
}
|