@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.
@@ -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={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
- const DropdownMenuContent = React.forwardRef<
88
- HTMLDivElement,
89
- React.HTMLAttributes<HTMLDivElement>
90
- >(({ className, ...props }, ref) => {
91
- const context = React.useContext(DropdownMenuContext)
92
- if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
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
- React.useEffect(() => {
95
- const handleClickOutside = () => {
96
- if (context.open) {
97
- context.onOpenChange(false)
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
- const handleEscape = (e: KeyboardEvent) => {
102
- if (e.key === "Escape" && context.open) {
103
- context.onOpenChange(false)
126
+ const handleEscape = (e: KeyboardEvent) => {
127
+ if (e.key === "Escape" && context.open) {
128
+ context.onOpenChange(false)
129
+ }
104
130
  }
105
- }
106
131
 
107
- const timer = setTimeout(() => {
108
- document.addEventListener("click", handleClickOutside)
109
- }, 0)
110
- document.addEventListener("keydown", handleEscape)
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
- return () => {
113
- clearTimeout(timer)
114
- document.removeEventListener("click", handleClickOutside)
115
- document.removeEventListener("keydown", handleEscape)
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
- if (!context.open) return null
153
+ // Calculate side classes
154
+ const sideClasses = {
155
+ bottom: `top-full mt-${sideOffset}`,
156
+ top: `bottom-full mb-${sideOffset}`,
157
+ }
120
158
 
121
- return (
122
- <div
123
- ref={ref}
124
- role="menu"
125
- className={cn(
126
- "absolute right-0 z-50 mt-2 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
127
- className
128
- )}
129
- onClick={(e) => e.stopPropagation()}
130
- {...props}
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 usually don't close the menu, or maybe they do?
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 }
@@ -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) {