@srcroot/ui 0.0.55 → 0.0.56

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 (107) hide show
  1. package/README.md +151 -151
  2. package/dist/index.d.ts +0 -0
  3. package/dist/index.js +33 -17
  4. package/package.json +7 -2
  5. package/src/registry/analytics/google-analytics.tsx +36 -39
  6. package/src/registry/analytics/google-tag-manager.tsx +62 -65
  7. package/src/registry/analytics/meta-pixel.tsx +44 -47
  8. package/src/registry/analytics/microsoft-clarity.tsx +31 -34
  9. package/src/registry/analytics/tiktok-pixel.tsx +34 -37
  10. package/src/registry/lib/utils.ts +0 -0
  11. package/src/registry/themes/v3/blue.css +157 -157
  12. package/src/registry/themes/v3/glass.css +153 -153
  13. package/src/registry/themes/v3/gray.css +157 -157
  14. package/src/registry/themes/v3/green.css +157 -157
  15. package/src/registry/themes/v3/neutral.css +157 -157
  16. package/src/registry/themes/v3/orange.css +157 -157
  17. package/src/registry/themes/v3/rose.css +157 -157
  18. package/src/registry/themes/v3/slate.css +157 -157
  19. package/src/registry/themes/v3/stone.css +157 -157
  20. package/src/registry/themes/v3/violet.css +186 -186
  21. package/src/registry/themes/v3/zinc.css +157 -157
  22. package/src/registry/themes/v4/blue.css +184 -184
  23. package/src/registry/themes/v4/glass.css +180 -180
  24. package/src/registry/themes/v4/gray.css +184 -184
  25. package/src/registry/themes/v4/green.css +184 -184
  26. package/src/registry/themes/v4/neutral.css +184 -184
  27. package/src/registry/themes/v4/orange.css +184 -184
  28. package/src/registry/themes/v4/rose.css +184 -184
  29. package/src/registry/themes/v4/slate.css +184 -184
  30. package/src/registry/themes/v4/stone.css +184 -184
  31. package/src/registry/themes/v4/violet.css +184 -184
  32. package/src/registry/themes/v4/zinc.css +184 -184
  33. package/src/registry/ui/accordion.tsx +164 -165
  34. package/src/registry/ui/alert-dialog.tsx +213 -214
  35. package/src/registry/ui/alert.tsx +73 -76
  36. package/src/registry/ui/aspect-ratio.tsx +44 -47
  37. package/src/registry/ui/avatar.tsx +96 -97
  38. package/src/registry/ui/badge.tsx +52 -55
  39. package/src/registry/ui/breadcrumb.tsx +147 -150
  40. package/src/registry/ui/button-group.tsx +64 -67
  41. package/src/registry/ui/button.tsx +71 -72
  42. package/src/registry/ui/calendar.tsx +514 -515
  43. package/src/registry/ui/card.tsx +88 -91
  44. package/src/registry/ui/carousel.tsx +214 -214
  45. package/src/registry/ui/chart.tsx +373 -373
  46. package/src/registry/ui/chatbot.tsx +86 -13
  47. package/src/registry/ui/checkbox.tsx +93 -94
  48. package/src/registry/ui/collapsible.tsx +107 -108
  49. package/src/registry/ui/combobox.tsx +171 -171
  50. package/src/registry/ui/command.tsx +300 -300
  51. package/src/registry/ui/container.tsx +44 -47
  52. package/src/registry/ui/context-menu.tsx +221 -221
  53. package/src/registry/ui/date-picker.tsx +228 -228
  54. package/src/registry/ui/dialog.tsx +269 -270
  55. package/src/registry/ui/drawer.tsx +10 -4
  56. package/src/registry/ui/dropdown-menu.tsx +529 -530
  57. package/src/registry/ui/empty-state.tsx +0 -2
  58. package/src/registry/ui/file-upload.tsx +0 -0
  59. package/src/registry/ui/floating-dock.tsx +0 -0
  60. package/src/registry/ui/form-field.tsx +91 -94
  61. package/src/registry/ui/google-analytics.tsx +38 -0
  62. package/src/registry/ui/google-tag-manager.tsx +64 -0
  63. package/src/registry/ui/hover-card.tsx +223 -223
  64. package/src/registry/ui/image.tsx +144 -147
  65. package/src/registry/ui/input-group.tsx +82 -85
  66. package/src/registry/ui/input.tsx +125 -125
  67. package/src/registry/ui/kbd.tsx +60 -63
  68. package/src/registry/ui/label.tsx +36 -37
  69. package/src/registry/ui/loading-spinner.tsx +108 -111
  70. package/src/registry/ui/map.tsx +0 -0
  71. package/src/registry/ui/marquee.tsx +2 -0
  72. package/src/registry/ui/menubar.tsx +246 -246
  73. package/src/registry/ui/meta-pixel.tsx +46 -0
  74. package/src/registry/ui/microsoft-clarity.tsx +33 -0
  75. package/src/registry/ui/native-select.tsx +49 -52
  76. package/src/registry/ui/otp-input.tsx +152 -155
  77. package/src/registry/ui/pagination.tsx +149 -152
  78. package/src/registry/ui/patterns.tsx +28 -0
  79. package/src/registry/ui/popover.tsx +226 -227
  80. package/src/registry/ui/progress.tsx +51 -52
  81. package/src/registry/ui/radio.tsx +99 -102
  82. package/src/registry/ui/resizable.tsx +314 -314
  83. package/src/registry/ui/scroll-animation.tsx +45 -0
  84. package/src/registry/ui/scroll-area.tsx +121 -122
  85. package/src/registry/ui/scroll-to-top.tsx +0 -0
  86. package/src/registry/ui/search.tsx +147 -150
  87. package/src/registry/ui/select.tsx +292 -293
  88. package/src/registry/ui/separator.tsx +46 -47
  89. package/src/registry/ui/sheet.tsx +6 -3
  90. package/src/registry/ui/sidebar.tsx +628 -628
  91. package/src/registry/ui/skeleton.tsx +26 -29
  92. package/src/registry/ui/slider.tsx +196 -197
  93. package/src/registry/ui/slot.tsx +69 -72
  94. package/src/registry/ui/star-rating.tsx +131 -134
  95. package/src/registry/ui/switch.tsx +72 -73
  96. package/src/registry/ui/table-of-contents.tsx +96 -96
  97. package/src/registry/ui/table.tsx +138 -139
  98. package/src/registry/ui/tabs.tsx +124 -125
  99. package/src/registry/ui/text.tsx +61 -64
  100. package/src/registry/ui/textarea.tsx +41 -42
  101. package/src/registry/ui/theme-switcher.tsx +66 -66
  102. package/src/registry/ui/tiktok-pixel.tsx +36 -0
  103. package/src/registry/ui/toast.tsx +97 -98
  104. package/src/registry/ui/toggle-group.tsx +129 -129
  105. package/src/registry/ui/toggle.tsx +72 -72
  106. package/src/registry/ui/tooltip.tsx +143 -144
  107. package/src/registry/ui/whatsapp.tsx +0 -0
@@ -1,530 +1,529 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
- import { createPortal } from "react-dom"
5
- import { cn } from "@/lib/utils"
6
- import { Slot } from "@/components/ui/slot"
7
-
8
- interface DropdownMenuContextValue {
9
- open: boolean
10
- onOpenChange: (open: boolean) => void
11
- triggerRef: React.RefObject<HTMLButtonElement | null>
12
- }
13
-
14
- const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
15
-
16
- interface DropdownMenuProps {
17
- children: React.ReactNode
18
- open?: boolean
19
- onOpenChange?: (open: boolean) => void
20
- defaultOpen?: boolean
21
- className?: string
22
- }
23
-
24
- /**
25
- * DropdownMenu component with keyboard navigation and proper positioning
26
- *
27
- * @example
28
- * <DropdownMenu>
29
- * <DropdownMenuTrigger asChild>
30
- * <Button>Open Menu</Button>
31
- * </DropdownMenuTrigger>
32
- * <DropdownMenuContent>
33
- * <DropdownMenuLabel>My Account</DropdownMenuLabel>
34
- * <DropdownMenuSeparator />
35
- * <DropdownMenuItem>Profile</DropdownMenuItem>
36
- * <DropdownMenuItem>Settings</DropdownMenuItem>
37
- * </DropdownMenuContent>
38
- * </DropdownMenu>
39
- */
40
- function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpen = false, className }: DropdownMenuProps) {
41
- const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
42
- const triggerRef = React.useRef<HTMLButtonElement>(null)
43
-
44
- const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
45
- const setOpen = onOpenChange || setUncontrolledOpen
46
-
47
- return (
48
- <DropdownMenuContext.Provider value={{ open, onOpenChange: setOpen, triggerRef }}>
49
- <div className={cn("relative inline-block text-left", className)}>
50
- {children}
51
- </div>
52
- </DropdownMenuContext.Provider>
53
- )
54
- }
55
-
56
-
57
- interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
58
- asChild?: boolean
59
- }
60
-
61
- const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(
62
- ({ onClick, asChild, children, ...props }, ref) => {
63
- const context = React.useContext(DropdownMenuContext)
64
- if (!context) throw new Error("DropdownMenuTrigger must be used within DropdownMenu")
65
-
66
- const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
67
- onClick?.(e)
68
- context.onOpenChange(!context.open)
69
- }
70
-
71
- // Combine refs
72
- const combinedRef = (node: HTMLButtonElement | null) => {
73
- (context.triggerRef as any).current = node
74
- if (typeof ref === 'function') ref(node)
75
- else if (ref) ref.current = node
76
- }
77
-
78
- if (asChild) {
79
- // For asChild, we need to manually make sure the ref is passed correctly.
80
- // Slot handles merging the passed ref with the child's ref.
81
- // But here we're passing `combinedRef` as the ref to Slot.
82
- }
83
-
84
- const Comp = asChild ? Slot : "button"
85
-
86
- return (
87
- <Comp
88
- ref={combinedRef}
89
- aria-expanded={context.open}
90
- aria-haspopup="menu"
91
- onClick={handleClick}
92
- {...props}
93
- >
94
- {children}
95
- </Comp>
96
- )
97
- }
98
- )
99
- DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
100
-
101
- interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
102
- /** Alignment relative to trigger: 'start' | 'center' | 'end' */
103
- align?: 'start' | 'center' | 'end'
104
- /** Side of trigger to open: 'bottom' | 'top' */
105
- side?: 'bottom' | 'top'
106
- /** Offset from trigger in pixels */
107
- sideOffset?: number
108
- /** Whether to render in a portal (default: true) */
109
- portal?: boolean
110
- }
111
-
112
- const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
113
- ({ className, align = 'start', side = 'bottom', sideOffset = 4, portal = true, ...props }, ref) => {
114
- const context = React.useContext(DropdownMenuContext)
115
- if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
116
- const contentRef = React.useRef<HTMLDivElement>(null)
117
- const [position, setPosition] = React.useState({ top: 0, left: 0 })
118
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
119
- const [currentSide, setCurrentSide] = React.useState(side)
120
-
121
- // Reset scroll on mount/unmount if needed, but mainly we just need a portal container
122
- const [mounted, setMounted] = React.useState(false)
123
- React.useEffect(() => {
124
- setMounted(true)
125
- }, [])
126
-
127
- React.useEffect(() => {
128
- const handleClickOutside = (e: MouseEvent) => {
129
- if (context.open) {
130
- const target = e.target as Node
131
- const content = contentRef.current
132
- const trigger = context.triggerRef.current
133
-
134
- // Don't close if clicking inside content or trigger
135
- if (content?.contains(target) || trigger?.contains(target)) {
136
- return
137
- }
138
- context.onOpenChange(false)
139
- }
140
- }
141
-
142
- const handleEscape = (e: KeyboardEvent) => {
143
- if (e.key === "Escape" && context.open) {
144
- context.onOpenChange(false)
145
- }
146
- }
147
-
148
- const checkPosition = () => {
149
- if (context.open && contentRef.current && context.triggerRef.current) {
150
- const triggerRect = context.triggerRef.current.getBoundingClientRect()
151
- const contentRect = contentRef.current.getBoundingClientRect()
152
- const viewportHeight = window.innerHeight
153
- const viewportWidth = window.innerWidth
154
-
155
- let top = 0
156
- let left = 0
157
- let usedSide = side
158
-
159
- // Vertical positioning logic
160
- const spaceBelow = viewportHeight - triggerRect.bottom
161
- const spaceAbove = triggerRect.top
162
- const neededHeight = contentRect.height + sideOffset
163
-
164
- if (side === 'bottom') {
165
- if (spaceBelow < neededHeight && spaceAbove > neededHeight) {
166
- usedSide = 'top'
167
- }
168
- } else if (side === 'top') {
169
- if (spaceAbove < neededHeight && spaceBelow > neededHeight) {
170
- usedSide = 'bottom'
171
- }
172
- }
173
- setCurrentSide(usedSide)
174
-
175
- if (usedSide === 'bottom') {
176
- top = triggerRect.bottom + sideOffset
177
- } else {
178
- top = triggerRect.top - contentRect.height - sideOffset
179
- }
180
-
181
- // Horizontal positioning logic (Alignment)
182
- if (align === 'start') {
183
- left = triggerRect.left
184
- } else if (align === 'end') {
185
- left = triggerRect.right - contentRect.width
186
- } else {
187
- // center
188
- left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
189
- }
190
-
191
- // Collision detection / clamping for X axis
192
- if (left < 4) left = 4
193
- if (left + contentRect.width > viewportWidth - 4) {
194
- left = viewportWidth - contentRect.width - 4
195
- }
196
-
197
- setPosition({ top, left })
198
- }
199
- }
200
-
201
- if (context.open) {
202
- // Check position immediately after render cycle
203
- requestAnimationFrame(checkPosition)
204
- }
205
-
206
- const timer = setTimeout(() => {
207
- document.addEventListener("click", handleClickOutside)
208
- }, 0)
209
- document.addEventListener("keydown", handleEscape)
210
- window.addEventListener("resize", checkPosition)
211
- window.addEventListener("scroll", checkPosition, true)
212
-
213
- return () => {
214
- clearTimeout(timer)
215
- document.removeEventListener("click", handleClickOutside)
216
- document.removeEventListener("keydown", handleEscape)
217
- window.removeEventListener("resize", checkPosition)
218
- window.removeEventListener("scroll", checkPosition, true)
219
- }
220
- }, [context.open, context, side, sideOffset, align])
221
-
222
- if (!context.open) return null
223
- if (portal && !mounted) return null
224
-
225
- // Alignment classes are ignored if we use portal/fixed positioning,
226
- // but kept for non-portal usage if someone opts out.
227
- const alignmentClasses = {
228
- start: 'left-0',
229
- center: 'left-1/2 -translate-x-1/2',
230
- end: 'right-0',
231
- }
232
-
233
- const content = (
234
- <div
235
- ref={(node) => {
236
- (contentRef as any).current = node
237
- if (typeof ref === 'function') ref(node)
238
- else if (ref) ref.current = node
239
- }}
240
- role="menu"
241
- aria-orientation="vertical"
242
- className={cn(
243
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
244
- "animate-in fade-in-0 zoom-in-95",
245
- !portal && "absolute", // Use absolute if not portal
246
- !portal && alignmentClasses[align],
247
- !portal && (side === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1'),
248
- portal && "fixed", // Use fixed if portal
249
- className
250
- )}
251
- style={{
252
- top: portal ? position.top : undefined,
253
- left: portal ? position.left : undefined,
254
- ...props.style
255
- }}
256
- {...props}
257
- />
258
- )
259
-
260
- if (portal) {
261
- return createPortal(content, document.body)
262
- }
263
-
264
- return content
265
- }
266
- )
267
- DropdownMenuContent.displayName = "DropdownMenuContent"
268
-
269
- const DropdownMenuGroup = React.forwardRef<
270
- HTMLDivElement,
271
- React.HTMLAttributes<HTMLDivElement>
272
- >(({ className, ...props }, ref) => (
273
- <div ref={ref} className={cn("", className)} {...props} />
274
- ))
275
- DropdownMenuGroup.displayName = "DropdownMenuGroup"
276
-
277
- const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => {
278
- return <>{children}</>
279
- }
280
- DropdownMenuPortal.displayName = "DropdownMenuPortal"
281
-
282
- const DropdownMenuItem = React.forwardRef<
283
- HTMLDivElement,
284
- React.HTMLAttributes<HTMLDivElement> & { inset?: boolean; disabled?: boolean; asChild?: boolean; closeOnSelect?: boolean }
285
- >(({ className, inset, disabled, onClick, asChild = false, closeOnSelect = true, ...props }, ref) => {
286
- const context = React.useContext(DropdownMenuContext)
287
-
288
- const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
289
- if (disabled) return
290
- onClick?.(e)
291
- if (closeOnSelect) {
292
- context?.onOpenChange(false)
293
- }
294
- }
295
-
296
- if (asChild) {
297
- const child = React.Children.only(props.children) as React.ReactElement<any>
298
- return React.cloneElement(child, {
299
- ref,
300
- className: cn(
301
- "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
302
- inset && "pl-8",
303
- disabled && "pointer-events-none opacity-50",
304
- className,
305
- child.props.className
306
- ),
307
- onClick: (e: React.MouseEvent<HTMLDivElement>) => {
308
- handleClick(e)
309
- child.props.onClick?.(e)
310
- },
311
- "data-disabled": disabled || undefined,
312
- "data-inset": inset || undefined,
313
- tabIndex: disabled ? -1 : 0,
314
- ...props,
315
- children: child.props.children
316
- })
317
- }
318
-
319
- return (
320
- <div
321
- ref={ref}
322
- role="menuitem"
323
- tabIndex={disabled ? -1 : 0}
324
- aria-disabled={disabled}
325
- className={cn(
326
- "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer",
327
- inset && "pl-8",
328
- disabled && "pointer-events-none opacity-50",
329
- className
330
- )}
331
- onClick={handleClick}
332
- {...props}
333
- />
334
- )
335
- })
336
- DropdownMenuItem.displayName = "DropdownMenuItem"
337
-
338
- const DropdownMenuLabel = React.forwardRef<
339
- HTMLDivElement,
340
- React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
341
- >(({ className, inset, ...props }, ref) => (
342
- <div
343
- ref={ref}
344
- className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
345
- {...props}
346
- />
347
- ))
348
- DropdownMenuLabel.displayName = "DropdownMenuLabel"
349
-
350
- const DropdownMenuSeparator = React.forwardRef<
351
- HTMLDivElement,
352
- React.HTMLAttributes<HTMLDivElement>
353
- >(({ className, ...props }, ref) => (
354
- <div ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
355
- ))
356
- DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
357
-
358
- const DropdownMenuCheckboxItem = React.forwardRef<
359
- HTMLDivElement,
360
- React.HTMLAttributes<HTMLDivElement> & { checked?: boolean; disabled?: boolean }
361
- >(({ className, children, checked, disabled, onClick, ...props }, ref) => {
362
- const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
363
- if (disabled) return
364
- onClick?.(e)
365
- // Checkbox items don't close the menu to allow multiple selections
366
- e.preventDefault()
367
- e.stopPropagation()
368
- }
369
-
370
- return (
371
- <div
372
- ref={ref}
373
- role="menuitemcheckbox"
374
- aria-checked={checked}
375
- aria-disabled={disabled}
376
- tabIndex={disabled ? -1 : 0}
377
- className={cn(
378
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
379
- disabled && "pointer-events-none opacity-50",
380
- className
381
- )}
382
- onClick={handleClick}
383
- {...props}
384
- >
385
- <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
386
- {checked && (
387
- <svg
388
- xmlns="http://www.w3.org/2000/svg"
389
- viewBox="0 0 24 24"
390
- fill="none"
391
- stroke="currentColor"
392
- strokeWidth="2"
393
- strokeLinecap="round"
394
- strokeLinejoin="round"
395
- className="h-4 w-4"
396
- >
397
- <polyline points="20 6 9 17 4 12" />
398
- </svg>
399
- )}
400
- </span>
401
- {children}
402
- </div>
403
- )
404
- })
405
- DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
406
-
407
- const DropdownMenuRadioGroup = React.forwardRef<
408
- HTMLDivElement,
409
- React.HTMLAttributes<HTMLDivElement> & { value?: string; onValueChange?: (value: string) => void }
410
- >(({ className, children, ...props }, ref) => {
411
- return (
412
- <div ref={ref} role="group" className={className} {...props}>
413
- {children}
414
- </div>
415
- )
416
- })
417
- DropdownMenuRadioGroup.displayName = "DropdownMenuRadioGroup"
418
-
419
- const DropdownMenuRadioItem = React.forwardRef<
420
- HTMLDivElement,
421
- React.HTMLAttributes<HTMLDivElement> & { value: string; disabled?: boolean }
422
- >(({ className, children, value, disabled, onClick, ...props }, ref) => {
423
- const context = React.useContext(DropdownMenuContext)
424
-
425
- return (
426
- <div
427
- ref={ref}
428
- role="menuitemradio"
429
- aria-disabled={disabled}
430
- tabIndex={disabled ? -1 : 0}
431
- className={cn(
432
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
433
- disabled && "pointer-events-none opacity-50",
434
- className
435
- )}
436
- onClick={(e) => {
437
- if (disabled) return
438
- onClick?.(e)
439
- context?.onOpenChange(false)
440
- }}
441
- {...props}
442
- >
443
- <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
444
- <svg
445
- xmlns="http://www.w3.org/2000/svg"
446
- viewBox="0 0 24 24"
447
- fill="currentColor"
448
- className="h-2 w-2 fill-current"
449
- >
450
- <circle cx="12" cy="12" r="10" />
451
- </svg>
452
- </span>
453
- {children}
454
- </div>
455
- )
456
- })
457
- DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
458
-
459
- const DropdownMenuSub = React.forwardRef<
460
- HTMLDivElement,
461
- React.HTMLAttributes<HTMLDivElement>
462
- >(({ ...props }, ref) => (
463
- <div ref={ref} {...props} />
464
- ))
465
- DropdownMenuSub.displayName = "DropdownMenuSub"
466
-
467
- const DropdownMenuSubTrigger = React.forwardRef<
468
- HTMLDivElement,
469
- React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
470
- >(({ className, inset, children, ...props }, ref) => (
471
- <div
472
- ref={ref}
473
- className={cn(
474
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
475
- inset && "pl-8",
476
- className
477
- )}
478
- {...props}
479
- >
480
- {children}
481
- <svg
482
- xmlns="http://www.w3.org/2000/svg"
483
- width="24"
484
- height="24"
485
- viewBox="0 0 24 24"
486
- fill="none"
487
- stroke="currentColor"
488
- strokeWidth="2"
489
- strokeLinecap="round"
490
- strokeLinejoin="round"
491
- className="ml-auto h-4 w-4"
492
- >
493
- <path d="m9 18 6-6-6-6" />
494
- </svg>
495
- </div>
496
- ))
497
- DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
498
-
499
- const DropdownMenuSubContent = React.forwardRef<
500
- HTMLDivElement,
501
- React.HTMLAttributes<HTMLDivElement>
502
- >(({ className, ...props }, ref) => (
503
- <div
504
- ref={ref}
505
- className={cn(
506
- "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
507
- className
508
- )}
509
- {...props}
510
- />
511
- ))
512
- DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
513
-
514
- export {
515
- DropdownMenu,
516
- DropdownMenuTrigger,
517
- DropdownMenuContent,
518
- DropdownMenuItem,
519
- DropdownMenuCheckboxItem,
520
- DropdownMenuRadioItem,
521
- DropdownMenuRadioGroup,
522
- DropdownMenuLabel,
523
- DropdownMenuSeparator,
524
- DropdownMenuSub,
525
- DropdownMenuSubTrigger,
526
- DropdownMenuSubContent,
527
- DropdownMenuGroup,
528
- DropdownMenuPortal,
529
- }
530
-
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { createPortal } from "react-dom"
5
+ import { cn } from "@/lib/utils"
6
+ import { Slot } from "@/components/ui/slot"
7
+
8
+ interface DropdownMenuContextValue {
9
+ open: boolean
10
+ onOpenChange: (open: boolean) => void
11
+ triggerRef: React.RefObject<HTMLButtonElement | null>
12
+ }
13
+
14
+ const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
15
+
16
+ interface DropdownMenuProps {
17
+ children: React.ReactNode
18
+ open?: boolean
19
+ onOpenChange?: (open: boolean) => void
20
+ defaultOpen?: boolean
21
+ className?: string
22
+ }
23
+
24
+ /**
25
+ * DropdownMenu component with keyboard navigation and proper positioning
26
+ *
27
+ * @example
28
+ * <DropdownMenu>
29
+ * <DropdownMenuTrigger asChild>
30
+ * <Button>Open Menu</Button>
31
+ * </DropdownMenuTrigger>
32
+ * <DropdownMenuContent>
33
+ * <DropdownMenuLabel>My Account</DropdownMenuLabel>
34
+ * <DropdownMenuSeparator />
35
+ * <DropdownMenuItem>Profile</DropdownMenuItem>
36
+ * <DropdownMenuItem>Settings</DropdownMenuItem>
37
+ * </DropdownMenuContent>
38
+ * </DropdownMenu>
39
+ */
40
+ function DropdownMenu({ children, open: controlledOpen, onOpenChange, defaultOpen = false, className }: DropdownMenuProps) {
41
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
42
+ const triggerRef = React.useRef<HTMLButtonElement>(null)
43
+
44
+ const open = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen
45
+ const setOpen = onOpenChange || setUncontrolledOpen
46
+
47
+ return (
48
+ <DropdownMenuContext.Provider value={{ open, onOpenChange: setOpen, triggerRef }}>
49
+ <div className={cn("relative inline-block text-left", className)}>
50
+ {children}
51
+ </div>
52
+ </DropdownMenuContext.Provider>
53
+ )
54
+ }
55
+
56
+
57
+ interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
58
+ asChild?: boolean
59
+ }
60
+
61
+ const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(
62
+ ({ onClick, asChild, children, ...props }, ref) => {
63
+ const context = React.useContext(DropdownMenuContext)
64
+ if (!context) throw new Error("DropdownMenuTrigger must be used within DropdownMenu")
65
+
66
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
67
+ onClick?.(e)
68
+ context.onOpenChange(!context.open)
69
+ }
70
+
71
+ // Combine refs
72
+ const combinedRef = (node: HTMLButtonElement | null) => {
73
+ (context.triggerRef as any).current = node
74
+ if (typeof ref === 'function') ref(node)
75
+ else if (ref) ref.current = node
76
+ }
77
+
78
+ if (asChild) {
79
+ // For asChild, we need to manually make sure the ref is passed correctly.
80
+ // Slot handles merging the passed ref with the child's ref.
81
+ // But here we're passing `combinedRef` as the ref to Slot.
82
+ }
83
+
84
+ const Comp = asChild ? Slot : "button"
85
+
86
+ return (
87
+ <Comp
88
+ ref={combinedRef}
89
+ aria-expanded={context.open}
90
+ aria-haspopup="menu"
91
+ onClick={handleClick}
92
+ {...props}
93
+ >
94
+ {children}
95
+ </Comp>
96
+ )
97
+ }
98
+ )
99
+ DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
100
+
101
+ interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
102
+ /** Alignment relative to trigger: 'start' | 'center' | 'end' */
103
+ align?: 'start' | 'center' | 'end'
104
+ /** Side of trigger to open: 'bottom' | 'top' */
105
+ side?: 'bottom' | 'top'
106
+ /** Offset from trigger in pixels */
107
+ sideOffset?: number
108
+ /** Whether to render in a portal (default: true) */
109
+ portal?: boolean
110
+ }
111
+
112
+ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
113
+ ({ className, align = 'start', side = 'bottom', sideOffset = 4, portal = true, ...props }, ref) => {
114
+ const context = React.useContext(DropdownMenuContext)
115
+ if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
116
+ const contentRef = React.useRef<HTMLDivElement>(null)
117
+ const [position, setPosition] = React.useState({ top: 0, left: 0 })
118
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
119
+ const [currentSide, setCurrentSide] = React.useState(side)
120
+
121
+ // Reset scroll on mount/unmount if needed, but mainly we just need a portal container
122
+ const [mounted, setMounted] = React.useState(false)
123
+ React.useEffect(() => {
124
+ setMounted(true)
125
+ }, [])
126
+
127
+ React.useEffect(() => {
128
+ const handleClickOutside = (e: MouseEvent) => {
129
+ if (context.open) {
130
+ const target = e.target as Node
131
+ const content = contentRef.current
132
+ const trigger = context.triggerRef.current
133
+
134
+ // Don't close if clicking inside content or trigger
135
+ if (content?.contains(target) || trigger?.contains(target)) {
136
+ return
137
+ }
138
+ context.onOpenChange(false)
139
+ }
140
+ }
141
+
142
+ const handleEscape = (e: KeyboardEvent) => {
143
+ if (e.key === "Escape" && context.open) {
144
+ context.onOpenChange(false)
145
+ }
146
+ }
147
+
148
+ const checkPosition = () => {
149
+ if (context.open && contentRef.current && context.triggerRef.current) {
150
+ const triggerRect = context.triggerRef.current.getBoundingClientRect()
151
+ const contentRect = contentRef.current.getBoundingClientRect()
152
+ const viewportHeight = window.innerHeight
153
+ const viewportWidth = window.innerWidth
154
+
155
+ let top = 0
156
+ let left = 0
157
+ let usedSide = side
158
+
159
+ // Vertical positioning logic
160
+ const spaceBelow = viewportHeight - triggerRect.bottom
161
+ const spaceAbove = triggerRect.top
162
+ const neededHeight = contentRect.height + sideOffset
163
+
164
+ if (side === 'bottom') {
165
+ if (spaceBelow < neededHeight && spaceAbove > neededHeight) {
166
+ usedSide = 'top'
167
+ }
168
+ } else if (side === 'top') {
169
+ if (spaceAbove < neededHeight && spaceBelow > neededHeight) {
170
+ usedSide = 'bottom'
171
+ }
172
+ }
173
+ setCurrentSide(usedSide)
174
+
175
+ if (usedSide === 'bottom') {
176
+ top = triggerRect.bottom + sideOffset
177
+ } else {
178
+ top = triggerRect.top - contentRect.height - sideOffset
179
+ }
180
+
181
+ // Horizontal positioning logic (Alignment)
182
+ if (align === 'start') {
183
+ left = triggerRect.left
184
+ } else if (align === 'end') {
185
+ left = triggerRect.right - contentRect.width
186
+ } else {
187
+ // center
188
+ left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
189
+ }
190
+
191
+ // Collision detection / clamping for X axis
192
+ if (left < 4) left = 4
193
+ if (left + contentRect.width > viewportWidth - 4) {
194
+ left = viewportWidth - contentRect.width - 4
195
+ }
196
+
197
+ setPosition({ top, left })
198
+ }
199
+ }
200
+
201
+ if (context.open) {
202
+ // Check position immediately after render cycle
203
+ requestAnimationFrame(checkPosition)
204
+ }
205
+
206
+ const timer = setTimeout(() => {
207
+ document.addEventListener("click", handleClickOutside)
208
+ }, 0)
209
+ document.addEventListener("keydown", handleEscape)
210
+ window.addEventListener("resize", checkPosition)
211
+ window.addEventListener("scroll", checkPosition, true)
212
+
213
+ return () => {
214
+ clearTimeout(timer)
215
+ document.removeEventListener("click", handleClickOutside)
216
+ document.removeEventListener("keydown", handleEscape)
217
+ window.removeEventListener("resize", checkPosition)
218
+ window.removeEventListener("scroll", checkPosition, true)
219
+ }
220
+ }, [context.open, context, side, sideOffset, align])
221
+
222
+ if (!context.open) return null
223
+ if (portal && !mounted) return null
224
+
225
+ // Alignment classes are ignored if we use portal/fixed positioning,
226
+ // but kept for non-portal usage if someone opts out.
227
+ const alignmentClasses = {
228
+ start: 'left-0',
229
+ center: 'left-1/2 -translate-x-1/2',
230
+ end: 'right-0',
231
+ }
232
+
233
+ const content = (
234
+ <div
235
+ ref={(node) => {
236
+ (contentRef as any).current = node
237
+ if (typeof ref === 'function') ref(node)
238
+ else if (ref) ref.current = node
239
+ }}
240
+ role="menu"
241
+ aria-orientation="vertical"
242
+ className={cn(
243
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
244
+ "animate-in fade-in-0 zoom-in-95",
245
+ !portal && "absolute", // Use absolute if not portal
246
+ !portal && alignmentClasses[align],
247
+ !portal && (side === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1'),
248
+ portal && "fixed", // Use fixed if portal
249
+ className
250
+ )}
251
+ style={{
252
+ top: portal ? position.top : undefined,
253
+ left: portal ? position.left : undefined,
254
+ ...props.style
255
+ }}
256
+ {...props}
257
+ />
258
+ )
259
+
260
+ if (portal) {
261
+ return createPortal(content, document.body)
262
+ }
263
+
264
+ return content
265
+ }
266
+ )
267
+ DropdownMenuContent.displayName = "DropdownMenuContent"
268
+
269
+ const DropdownMenuGroup = React.forwardRef<
270
+ HTMLDivElement,
271
+ React.HTMLAttributes<HTMLDivElement>
272
+ >(({ className, ...props }, ref) => (
273
+ <div ref={ref} className={cn("", className)} {...props} />
274
+ ))
275
+ DropdownMenuGroup.displayName = "DropdownMenuGroup"
276
+
277
+ const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => {
278
+ return <>{children}</>
279
+ }
280
+ DropdownMenuPortal.displayName = "DropdownMenuPortal"
281
+
282
+ const DropdownMenuItem = React.forwardRef<
283
+ HTMLDivElement,
284
+ React.HTMLAttributes<HTMLDivElement> & { inset?: boolean; disabled?: boolean; asChild?: boolean; closeOnSelect?: boolean }
285
+ >(({ className, inset, disabled, onClick, asChild = false, closeOnSelect = true, ...props }, ref) => {
286
+ const context = React.useContext(DropdownMenuContext)
287
+
288
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
289
+ if (disabled) return
290
+ onClick?.(e)
291
+ if (closeOnSelect) {
292
+ context?.onOpenChange(false)
293
+ }
294
+ }
295
+
296
+ if (asChild) {
297
+ const child = React.Children.only(props.children) as React.ReactElement<any>
298
+ return React.cloneElement(child, {
299
+ ref,
300
+ className: cn(
301
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
302
+ inset && "pl-8",
303
+ disabled && "pointer-events-none opacity-50",
304
+ className,
305
+ child.props.className
306
+ ),
307
+ onClick: (e: React.MouseEvent<HTMLDivElement>) => {
308
+ handleClick(e)
309
+ child.props.onClick?.(e)
310
+ },
311
+ "data-disabled": disabled || undefined,
312
+ "data-inset": inset || undefined,
313
+ tabIndex: disabled ? -1 : 0,
314
+ ...props,
315
+ children: child.props.children
316
+ })
317
+ }
318
+
319
+ return (
320
+ <div
321
+ ref={ref}
322
+ role="menuitem"
323
+ tabIndex={disabled ? -1 : 0}
324
+ aria-disabled={disabled}
325
+ className={cn(
326
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-pointer",
327
+ inset && "pl-8",
328
+ disabled && "pointer-events-none opacity-50",
329
+ className
330
+ )}
331
+ onClick={handleClick}
332
+ {...props}
333
+ />
334
+ )
335
+ })
336
+ DropdownMenuItem.displayName = "DropdownMenuItem"
337
+
338
+ const DropdownMenuLabel = React.forwardRef<
339
+ HTMLDivElement,
340
+ React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
341
+ >(({ className, inset, ...props }, ref) => (
342
+ <div
343
+ ref={ref}
344
+ className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
345
+ {...props}
346
+ />
347
+ ))
348
+ DropdownMenuLabel.displayName = "DropdownMenuLabel"
349
+
350
+ const DropdownMenuSeparator = React.forwardRef<
351
+ HTMLDivElement,
352
+ React.HTMLAttributes<HTMLDivElement>
353
+ >(({ className, ...props }, ref) => (
354
+ <div ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
355
+ ))
356
+ DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
357
+
358
+ const DropdownMenuCheckboxItem = React.forwardRef<
359
+ HTMLDivElement,
360
+ React.HTMLAttributes<HTMLDivElement> & { checked?: boolean; disabled?: boolean }
361
+ >(({ className, children, checked, disabled, onClick, ...props }, ref) => {
362
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
363
+ if (disabled) return
364
+ onClick?.(e)
365
+ // Checkbox items don't close the menu to allow multiple selections
366
+ e.preventDefault()
367
+ e.stopPropagation()
368
+ }
369
+
370
+ return (
371
+ <div
372
+ ref={ref}
373
+ role="menuitemcheckbox"
374
+ aria-checked={checked}
375
+ aria-disabled={disabled}
376
+ tabIndex={disabled ? -1 : 0}
377
+ className={cn(
378
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
379
+ disabled && "pointer-events-none opacity-50",
380
+ className
381
+ )}
382
+ onClick={handleClick}
383
+ {...props}
384
+ >
385
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
386
+ {checked && (
387
+ <svg
388
+ xmlns="http://www.w3.org/2000/svg"
389
+ viewBox="0 0 24 24"
390
+ fill="none"
391
+ stroke="currentColor"
392
+ strokeWidth="2"
393
+ strokeLinecap="round"
394
+ strokeLinejoin="round"
395
+ className="h-4 w-4"
396
+ >
397
+ <polyline points="20 6 9 17 4 12" />
398
+ </svg>
399
+ )}
400
+ </span>
401
+ {children}
402
+ </div>
403
+ )
404
+ })
405
+ DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
406
+
407
+ const DropdownMenuRadioGroup = React.forwardRef<
408
+ HTMLDivElement,
409
+ React.HTMLAttributes<HTMLDivElement> & { value?: string; onValueChange?: (value: string) => void }
410
+ >(({ className, children, ...props }, ref) => {
411
+ return (
412
+ <div ref={ref} role="group" className={className} {...props}>
413
+ {children}
414
+ </div>
415
+ )
416
+ })
417
+ DropdownMenuRadioGroup.displayName = "DropdownMenuRadioGroup"
418
+
419
+ const DropdownMenuRadioItem = React.forwardRef<
420
+ HTMLDivElement,
421
+ React.HTMLAttributes<HTMLDivElement> & { value: string; disabled?: boolean }
422
+ >(({ className, children, value, disabled, onClick, ...props }, ref) => {
423
+ const context = React.useContext(DropdownMenuContext)
424
+
425
+ return (
426
+ <div
427
+ ref={ref}
428
+ role="menuitemradio"
429
+ aria-disabled={disabled}
430
+ tabIndex={disabled ? -1 : 0}
431
+ className={cn(
432
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground",
433
+ disabled && "pointer-events-none opacity-50",
434
+ className
435
+ )}
436
+ onClick={(e) => {
437
+ if (disabled) return
438
+ onClick?.(e)
439
+ context?.onOpenChange(false)
440
+ }}
441
+ {...props}
442
+ >
443
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
444
+ <svg
445
+ xmlns="http://www.w3.org/2000/svg"
446
+ viewBox="0 0 24 24"
447
+ fill="currentColor"
448
+ className="h-2 w-2 fill-current"
449
+ >
450
+ <circle cx="12" cy="12" r="10" />
451
+ </svg>
452
+ </span>
453
+ {children}
454
+ </div>
455
+ )
456
+ })
457
+ DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
458
+
459
+ const DropdownMenuSub = React.forwardRef<
460
+ HTMLDivElement,
461
+ React.HTMLAttributes<HTMLDivElement>
462
+ >(({ ...props }, ref) => (
463
+ <div ref={ref} {...props} />
464
+ ))
465
+ DropdownMenuSub.displayName = "DropdownMenuSub"
466
+
467
+ const DropdownMenuSubTrigger = React.forwardRef<
468
+ HTMLDivElement,
469
+ React.HTMLAttributes<HTMLDivElement> & { inset?: boolean }
470
+ >(({ className, inset, children, ...props }, ref) => (
471
+ <div
472
+ ref={ref}
473
+ className={cn(
474
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
475
+ inset && "pl-8",
476
+ className
477
+ )}
478
+ {...props}
479
+ >
480
+ {children}
481
+ <svg
482
+ xmlns="http://www.w3.org/2000/svg"
483
+ width="24"
484
+ height="24"
485
+ viewBox="0 0 24 24"
486
+ fill="none"
487
+ stroke="currentColor"
488
+ strokeWidth="2"
489
+ strokeLinecap="round"
490
+ strokeLinejoin="round"
491
+ className="ml-auto h-4 w-4"
492
+ >
493
+ <path d="m9 18 6-6-6-6" />
494
+ </svg>
495
+ </div>
496
+ ))
497
+ DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
498
+
499
+ const DropdownMenuSubContent = React.forwardRef<
500
+ HTMLDivElement,
501
+ React.HTMLAttributes<HTMLDivElement>
502
+ >(({ className, ...props }, ref) => (
503
+ <div
504
+ ref={ref}
505
+ className={cn(
506
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
507
+ className
508
+ )}
509
+ {...props}
510
+ />
511
+ ))
512
+ DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
513
+
514
+ export {
515
+ DropdownMenu,
516
+ DropdownMenuTrigger,
517
+ DropdownMenuContent,
518
+ DropdownMenuItem,
519
+ DropdownMenuCheckboxItem,
520
+ DropdownMenuRadioItem,
521
+ DropdownMenuRadioGroup,
522
+ DropdownMenuLabel,
523
+ DropdownMenuSeparator,
524
+ DropdownMenuSub,
525
+ DropdownMenuSubTrigger,
526
+ DropdownMenuSubContent,
527
+ DropdownMenuGroup,
528
+ DropdownMenuPortal,
529
+ }