@srcroot/ui 0.0.1 → 0.0.3
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/badge.tsx +9 -25
- package/registry/breadcrumb.tsx +1 -1
- package/registry/button-group.tsx +9 -29
- package/registry/button.tsx +20 -46
- package/registry/calendar.tsx +416 -142
- package/registry/card.tsx +21 -47
- package/registry/combobox.tsx +171 -0
- package/registry/command.tsx +300 -0
- package/registry/container.tsx +9 -25
- package/registry/context-menu.tsx +221 -0
- package/registry/date-picker.tsx +179 -0
- package/registry/drawer.tsx +241 -0
- package/registry/dropdown-menu.tsx +93 -74
- package/registry/file-upload.tsx +240 -0
- package/registry/hover-card.tsx +165 -0
- package/registry/image.tsx +2 -2
- 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 +251 -0
- package/registry/scroll-area.tsx +119 -0
- package/registry/search.tsx +2 -1
- package/registry/sheet.tsx +63 -18
- package/registry/sidebar.tsx +512 -0
- package/registry/slider.tsx +133 -54
- package/registry/text.tsx +7 -16
- package/registry/toggle-group.tsx +129 -0
- package/registry/toggle.tsx +72 -0
- package/registry/tooltip.tsx +21 -3
|
@@ -4,6 +4,7 @@ import { cn } from "@/lib/utils"
|
|
|
4
4
|
interface DropdownMenuContextValue {
|
|
5
5
|
open: boolean
|
|
6
6
|
onOpenChange: (open: boolean) => void
|
|
7
|
+
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
|
|
@@ -16,7 +17,7 @@ interface DropdownMenuProps {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
|
-
* DropdownMenu component with keyboard navigation
|
|
20
|
+
* DropdownMenu component with keyboard navigation and proper positioning
|
|
20
21
|
*
|
|
21
22
|
* @example
|
|
22
23
|
* <DropdownMenu>
|
|
@@ -33,12 +34,13 @@ interface DropdownMenuProps {
|
|
|
33
34
|
*/
|
|
34
35
|
function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: DropdownMenuProps) {
|
|
35
36
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
37
|
+
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
36
38
|
|
|
37
39
|
const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
|
|
38
40
|
const setOpen = onOpenChange || setUncontrolledOpen
|
|
39
41
|
|
|
40
42
|
return (
|
|
41
|
-
<DropdownMenuContext.Provider value={{ open, onOpenChange: setOpen }}>
|
|
43
|
+
<DropdownMenuContext.Provider value={{ open, onOpenChange: setOpen, triggerRef }}>
|
|
42
44
|
<div className="relative inline-block text-left">
|
|
43
45
|
{children}
|
|
44
46
|
</div>
|
|
@@ -60,18 +62,25 @@ const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTrig
|
|
|
60
62
|
context.onOpenChange(!context.open)
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// Combine refs
|
|
66
|
+
const combinedRef = (node: HTMLButtonElement | null) => {
|
|
67
|
+
(context.triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node
|
|
68
|
+
if (typeof ref === 'function') ref(node)
|
|
69
|
+
else if (ref) ref.current = node
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
if (asChild && React.isValidElement(children)) {
|
|
64
73
|
return React.cloneElement(children as React.ReactElement<any>, {
|
|
65
74
|
onClick: handleClick,
|
|
66
75
|
"aria-expanded": context.open,
|
|
67
76
|
"aria-haspopup": "menu",
|
|
68
|
-
ref,
|
|
77
|
+
ref: combinedRef,
|
|
69
78
|
})
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
return (
|
|
73
82
|
<button
|
|
74
|
-
ref={
|
|
83
|
+
ref={combinedRef}
|
|
75
84
|
aria-expanded={context.open}
|
|
76
85
|
aria-haspopup="menu"
|
|
77
86
|
onClick={handleClick}
|
|
@@ -84,53 +93,94 @@ const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTrig
|
|
|
84
93
|
)
|
|
85
94
|
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
96
|
+
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
97
|
+
/** Alignment relative to trigger: 'start' | 'center' | 'end' */
|
|
98
|
+
align?: 'start' | 'center' | 'end'
|
|
99
|
+
/** Side of trigger to open: 'bottom' | 'top' */
|
|
100
|
+
side?: 'bottom' | 'top'
|
|
101
|
+
/** Offset from trigger in pixels */
|
|
102
|
+
sideOffset?: number
|
|
103
|
+
}
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
|
|
106
|
+
({ className, align = 'start', side = 'bottom', sideOffset = 4, ...props }, ref) => {
|
|
107
|
+
const context = React.useContext(DropdownMenuContext)
|
|
108
|
+
if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
|
|
109
|
+
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
110
|
+
|
|
111
|
+
React.useEffect(() => {
|
|
112
|
+
const handleClickOutside = (e: MouseEvent) => {
|
|
113
|
+
if (context.open) {
|
|
114
|
+
const target = e.target as Node
|
|
115
|
+
const content = contentRef.current
|
|
116
|
+
const trigger = context.triggerRef.current
|
|
117
|
+
|
|
118
|
+
// Don't close if clicking inside content or trigger
|
|
119
|
+
if (content?.contains(target) || trigger?.contains(target)) {
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
context.onOpenChange(false)
|
|
123
|
+
}
|
|
98
124
|
}
|
|
99
|
-
}
|
|
100
125
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
126
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
127
|
+
if (e.key === "Escape" && context.open) {
|
|
128
|
+
context.onOpenChange(false)
|
|
129
|
+
}
|
|
104
130
|
}
|
|
105
|
-
}
|
|
106
131
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
document.addEventListener("click", handleClickOutside)
|
|
134
|
+
}, 0)
|
|
135
|
+
document.addEventListener("keydown", handleEscape)
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
document.removeEventListener("click", handleClickOutside)
|
|
140
|
+
document.removeEventListener("keydown", handleEscape)
|
|
141
|
+
}
|
|
142
|
+
}, [context.open, context])
|
|
143
|
+
|
|
144
|
+
if (!context.open) return null
|
|
111
145
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
// Calculate alignment classes
|
|
147
|
+
const alignmentClasses = {
|
|
148
|
+
start: 'left-0',
|
|
149
|
+
center: 'left-1/2 -translate-x-1/2',
|
|
150
|
+
end: 'right-0',
|
|
116
151
|
}
|
|
117
|
-
}, [context.open, context])
|
|
118
152
|
|
|
119
|
-
|
|
153
|
+
// Calculate side classes
|
|
154
|
+
const sideClasses = {
|
|
155
|
+
bottom: `top-full mt-${sideOffset}`,
|
|
156
|
+
top: `bottom-full mb-${sideOffset}`,
|
|
157
|
+
}
|
|
120
158
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
return (
|
|
160
|
+
<div
|
|
161
|
+
ref={(node) => {
|
|
162
|
+
(contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
|
163
|
+
if (typeof ref === 'function') ref(node)
|
|
164
|
+
else if (ref) ref.current = node
|
|
165
|
+
}}
|
|
166
|
+
role="menu"
|
|
167
|
+
aria-orientation="vertical"
|
|
168
|
+
className={cn(
|
|
169
|
+
"absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
|
170
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
171
|
+
alignmentClasses[align],
|
|
172
|
+
side === 'bottom' ? 'top-full' : 'bottom-full',
|
|
173
|
+
className
|
|
174
|
+
)}
|
|
175
|
+
style={{
|
|
176
|
+
marginTop: side === 'bottom' ? sideOffset : undefined,
|
|
177
|
+
marginBottom: side === 'top' ? sideOffset : undefined,
|
|
178
|
+
}}
|
|
179
|
+
{...props}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
)
|
|
134
184
|
DropdownMenuContent.displayName = "DropdownMenuContent"
|
|
135
185
|
|
|
136
186
|
const DropdownMenuItem = React.forwardRef<
|
|
@@ -188,23 +238,10 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|
|
188
238
|
HTMLDivElement,
|
|
189
239
|
React.HTMLAttributes<HTMLDivElement> & { checked?: boolean; disabled?: boolean }
|
|
190
240
|
>(({ className, children, checked, disabled, onClick, ...props }, ref) => {
|
|
191
|
-
const context = React.useContext(DropdownMenuContext)
|
|
192
|
-
|
|
193
241
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
194
242
|
if (disabled) return
|
|
195
243
|
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.
|
|
244
|
+
// Checkbox items don't close the menu to allow multiple selections
|
|
208
245
|
e.preventDefault()
|
|
209
246
|
e.stopPropagation()
|
|
210
247
|
}
|
|
@@ -263,13 +300,6 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
263
300
|
React.HTMLAttributes<HTMLDivElement> & { value: string; disabled?: boolean }
|
|
264
301
|
>(({ className, children, value, disabled, onClick, ...props }, ref) => {
|
|
265
302
|
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
303
|
|
|
274
304
|
return (
|
|
275
305
|
<div
|
|
@@ -290,17 +320,6 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|
|
290
320
|
{...props}
|
|
291
321
|
>
|
|
292
322
|
<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
323
|
<svg
|
|
305
324
|
xmlns="http://www.w3.org/2000/svg"
|
|
306
325
|
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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
// HoverCard Context
|
|
7
|
+
interface HoverCardContextValue {
|
|
8
|
+
open: boolean
|
|
9
|
+
triggerRef: React.RefObject<HTMLDivElement | null>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const HoverCardContext = React.createContext<HoverCardContextValue | null>(null)
|
|
13
|
+
|
|
14
|
+
function useHoverCard() {
|
|
15
|
+
const context = React.useContext(HoverCardContext)
|
|
16
|
+
if (!context) {
|
|
17
|
+
throw new Error("useHoverCard must be used within a HoverCard")
|
|
18
|
+
}
|
|
19
|
+
return context
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// HoverCard Root
|
|
23
|
+
interface HoverCardProps {
|
|
24
|
+
children: React.ReactNode
|
|
25
|
+
openDelay?: number
|
|
26
|
+
closeDelay?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HoverCard = ({ children, openDelay = 200, closeDelay = 300 }: HoverCardProps) => {
|
|
30
|
+
const [open, setOpen] = React.useState(false)
|
|
31
|
+
const triggerRef = React.useRef<HTMLDivElement>(null)
|
|
32
|
+
const openTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
33
|
+
const closeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
34
|
+
|
|
35
|
+
const handleMouseEnter = React.useCallback(() => {
|
|
36
|
+
if (closeTimeoutRef.current) {
|
|
37
|
+
clearTimeout(closeTimeoutRef.current)
|
|
38
|
+
closeTimeoutRef.current = null
|
|
39
|
+
}
|
|
40
|
+
openTimeoutRef.current = setTimeout(() => {
|
|
41
|
+
setOpen(true)
|
|
42
|
+
}, openDelay)
|
|
43
|
+
}, [openDelay])
|
|
44
|
+
|
|
45
|
+
const handleMouseLeave = React.useCallback(() => {
|
|
46
|
+
if (openTimeoutRef.current) {
|
|
47
|
+
clearTimeout(openTimeoutRef.current)
|
|
48
|
+
openTimeoutRef.current = null
|
|
49
|
+
}
|
|
50
|
+
closeTimeoutRef.current = setTimeout(() => {
|
|
51
|
+
setOpen(false)
|
|
52
|
+
}, closeDelay)
|
|
53
|
+
}, [closeDelay])
|
|
54
|
+
|
|
55
|
+
React.useEffect(() => {
|
|
56
|
+
return () => {
|
|
57
|
+
if (openTimeoutRef.current) clearTimeout(openTimeoutRef.current)
|
|
58
|
+
if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
|
|
59
|
+
}
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<HoverCardContext.Provider value={{ open, triggerRef }}>
|
|
64
|
+
<div
|
|
65
|
+
onMouseEnter={handleMouseEnter}
|
|
66
|
+
onMouseLeave={handleMouseLeave}
|
|
67
|
+
className="inline-block"
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</div>
|
|
71
|
+
</HoverCardContext.Provider>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// HoverCard Trigger
|
|
76
|
+
interface HoverCardTriggerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
77
|
+
asChild?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HoverCardTrigger = React.forwardRef<HTMLDivElement, HoverCardTriggerProps>(
|
|
81
|
+
({ children, asChild, className, ...props }, ref) => {
|
|
82
|
+
const { triggerRef } = useHoverCard()
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
ref={triggerRef}
|
|
87
|
+
className={cn("inline-block cursor-pointer", className)}
|
|
88
|
+
{...props}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
HoverCardTrigger.displayName = "HoverCardTrigger"
|
|
96
|
+
|
|
97
|
+
// HoverCard Content
|
|
98
|
+
interface HoverCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
99
|
+
align?: "start" | "center" | "end"
|
|
100
|
+
side?: "top" | "bottom"
|
|
101
|
+
sideOffset?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(
|
|
105
|
+
({ children, className, align = "center", side = "bottom", sideOffset = 8, ...props }, ref) => {
|
|
106
|
+
const { open, triggerRef } = useHoverCard()
|
|
107
|
+
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
|
108
|
+
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
109
|
+
|
|
110
|
+
React.useEffect(() => {
|
|
111
|
+
if (!open || !triggerRef.current || !contentRef.current) return
|
|
112
|
+
|
|
113
|
+
const triggerRect = triggerRef.current.getBoundingClientRect()
|
|
114
|
+
const contentRect = contentRef.current.getBoundingClientRect()
|
|
115
|
+
|
|
116
|
+
let top = 0
|
|
117
|
+
let left = 0
|
|
118
|
+
|
|
119
|
+
// Calculate vertical position
|
|
120
|
+
if (side === "bottom") {
|
|
121
|
+
top = triggerRect.bottom + sideOffset
|
|
122
|
+
} else {
|
|
123
|
+
top = triggerRect.top - contentRect.height - sideOffset
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Calculate horizontal position
|
|
127
|
+
if (align === "start") {
|
|
128
|
+
left = triggerRect.left
|
|
129
|
+
} else if (align === "end") {
|
|
130
|
+
left = triggerRect.right - contentRect.width
|
|
131
|
+
} else {
|
|
132
|
+
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Clamp to viewport
|
|
136
|
+
left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
|
|
137
|
+
top = Math.max(8, Math.min(top, window.innerHeight - contentRect.height - 8))
|
|
138
|
+
|
|
139
|
+
setPosition({ top, left })
|
|
140
|
+
}, [open, triggerRef, align, side, sideOffset])
|
|
141
|
+
|
|
142
|
+
if (!open) return null
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
ref={contentRef}
|
|
147
|
+
className={cn(
|
|
148
|
+
"fixed z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
|
|
149
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
150
|
+
className
|
|
151
|
+
)}
|
|
152
|
+
style={{
|
|
153
|
+
top: position.top,
|
|
154
|
+
left: position.left,
|
|
155
|
+
}}
|
|
156
|
+
{...props}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
)
|
|
163
|
+
HoverCardContent.displayName = "HoverCardContent"
|
|
164
|
+
|
|
165
|
+
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
package/registry/image.tsx
CHANGED
|
@@ -69,8 +69,8 @@ const Image = React.forwardRef<HTMLImageElement, ImageProps>(
|
|
|
69
69
|
}, [src])
|
|
70
70
|
|
|
71
71
|
const containerStyle: React.CSSProperties = aspectRatio
|
|
72
|
-
? { paddingBottom: `${100 / aspectRatio}%`, ...style }
|
|
73
|
-
: style
|
|
72
|
+
? { paddingBottom: `${100 / aspectRatio}%`, ...(style || {}) }
|
|
73
|
+
: (style || {})
|
|
74
74
|
|
|
75
75
|
// Render fallback
|
|
76
76
|
if (status === "error" && fallback) {
|