@srcroot/ui 0.0.49 → 0.0.53

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.
@@ -1,7 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { createPortal } from "react-dom"
4
5
  import { cn } from "@/lib/utils"
6
+ import { Slot } from "@/components/ui/slot"
5
7
 
6
8
  // Drawer Context
7
9
  interface DrawerContextValue {
@@ -27,6 +29,25 @@ interface DrawerProps {
27
29
  defaultOpen?: boolean
28
30
  }
29
31
 
32
+ /**
33
+ * Drawer component for mobile-first bottom sheets
34
+ *
35
+ * @example
36
+ * <Drawer>
37
+ * <DrawerTrigger>Open</DrawerTrigger>
38
+ * <DrawerContent>
39
+ * <DrawerHeader>
40
+ * <DrawerTitle>Title</DrawerTitle>
41
+ * <DrawerDescription>Description</DrawerDescription>
42
+ * </DrawerHeader>
43
+ * <div>Content</div>
44
+ * <DrawerFooter>
45
+ * <Button>Submit</Button>
46
+ * <DrawerClose>Cancel</DrawerClose>
47
+ * </DrawerFooter>
48
+ * </DrawerContent>
49
+ * </Drawer>
50
+ */
30
51
  const Drawer = ({ children, open: controlledOpen, onOpenChange, defaultOpen = false }: DrawerProps) => {
31
52
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
32
53
  const open = controlledOpen ?? uncontrolledOpen
@@ -53,17 +74,12 @@ const DrawerTrigger = React.forwardRef<HTMLButtonElement, DrawerTriggerProps>(
53
74
  onOpenChange(true)
54
75
  }
55
76
 
56
- if (asChild && React.isValidElement(children)) {
57
- return React.cloneElement(children as React.ReactElement<any>, {
58
- onClick: handleClick,
59
- ref,
60
- })
61
- }
77
+ const Comp = asChild ? Slot : "button"
62
78
 
63
79
  return (
64
- <button ref={ref} onClick={handleClick} {...props}>
80
+ <Comp ref={ref} onClick={handleClick} {...props}>
65
81
  {children}
66
- </button>
82
+ </Comp>
67
83
  )
68
84
  }
69
85
  )
@@ -166,9 +182,16 @@ const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(
166
182
  }
167
183
  }, [open])
168
184
 
185
+ const [mounted, setMounted] = React.useState(false)
186
+
187
+ React.useEffect(() => {
188
+ setMounted(true)
189
+ }, [])
190
+
169
191
  if (!isVisible) return null
192
+ if (!mounted) return null
170
193
 
171
- return (
194
+ return createPortal(
172
195
  <>
173
196
  <DrawerOverlay isAnimating={isAnimating} />
174
197
  <div
@@ -194,7 +217,8 @@ const DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(
194
217
  <div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-muted" />
195
218
  )}
196
219
  </div>
197
- </>
220
+ </>,
221
+ document.body
198
222
  )
199
223
  }
200
224
  )
@@ -1,5 +1,7 @@
1
1
  import * as React from "react"
2
+ import { createPortal } from "react-dom"
2
3
  import { cn } from "@/lib/utils"
4
+ import { Slot } from "@/components/ui/slot"
3
5
 
4
6
  interface DropdownMenuContextValue {
5
7
  open: boolean
@@ -66,22 +68,21 @@ const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTrig
66
68
 
67
69
  // Combine refs
68
70
  const combinedRef = (node: HTMLButtonElement | null) => {
69
- (context.triggerRef as React.MutableRefObject<HTMLButtonElement | null>).current = node
71
+ (context.triggerRef as any).current = node
70
72
  if (typeof ref === 'function') ref(node)
71
73
  else if (ref) ref.current = node
72
74
  }
73
75
 
74
- if (asChild && React.isValidElement(children)) {
75
- return React.cloneElement(children as React.ReactElement<any>, {
76
- onClick: handleClick,
77
- "aria-expanded": context.open,
78
- "aria-haspopup": "menu",
79
- ref: combinedRef,
80
- })
76
+ if (asChild) {
77
+ // For asChild, we need to manually make sure the ref is passed correctly.
78
+ // Slot handles merging the passed ref with the child's ref.
79
+ // But here we're passing `combinedRef` as the ref to Slot.
81
80
  }
82
81
 
82
+ const Comp = asChild ? Slot : "button"
83
+
83
84
  return (
84
- <button
85
+ <Comp
85
86
  ref={combinedRef}
86
87
  aria-expanded={context.open}
87
88
  aria-haspopup="menu"
@@ -89,7 +90,7 @@ const DropdownMenuTrigger = React.forwardRef<HTMLButtonElement, DropdownMenuTrig
89
90
  {...props}
90
91
  >
91
92
  {children}
92
- </button>
93
+ </Comp>
93
94
  )
94
95
  }
95
96
  )
@@ -102,18 +103,24 @@ interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement>
102
103
  side?: 'bottom' | 'top'
103
104
  /** Offset from trigger in pixels */
104
105
  sideOffset?: number
106
+ /** Whether to render in a portal (default: true) */
107
+ portal?: boolean
105
108
  }
106
109
 
107
110
  const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContentProps>(
108
- ({ className, align = 'start', side = 'bottom', sideOffset = 4, ...props }, ref) => {
111
+ ({ className, align = 'start', side = 'bottom', sideOffset = 4, portal = true, ...props }, ref) => {
109
112
  const context = React.useContext(DropdownMenuContext)
110
113
  if (!context) throw new Error("DropdownMenuContent must be used within DropdownMenu")
111
114
  const contentRef = React.useRef<HTMLDivElement>(null)
115
+ const [position, setPosition] = React.useState({ top: 0, left: 0 })
116
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
112
117
  const [currentSide, setCurrentSide] = React.useState(side)
113
118
 
119
+ // Reset scroll on mount/unmount if needed, but mainly we just need a portal container
120
+ const [mounted, setMounted] = React.useState(false)
114
121
  React.useEffect(() => {
115
- setCurrentSide(side)
116
- }, [side])
122
+ setMounted(true)
123
+ }, [])
117
124
 
118
125
  React.useEffect(() => {
119
126
  const handleClickOutside = (e: MouseEvent) => {
@@ -141,30 +148,56 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
141
148
  const triggerRect = context.triggerRef.current.getBoundingClientRect()
142
149
  const contentRect = contentRef.current.getBoundingClientRect()
143
150
  const viewportHeight = window.innerHeight
151
+ const viewportWidth = window.innerWidth
152
+
153
+ let top = 0
154
+ let left = 0
155
+ let usedSide = side
144
156
 
157
+ // Vertical positioning logic
145
158
  const spaceBelow = viewportHeight - triggerRect.bottom
146
159
  const spaceAbove = triggerRect.top
147
160
  const neededHeight = contentRect.height + sideOffset
148
161
 
149
162
  if (side === 'bottom') {
150
163
  if (spaceBelow < neededHeight && spaceAbove > neededHeight) {
151
- setCurrentSide('top')
152
- } else {
153
- setCurrentSide('bottom')
164
+ usedSide = 'top'
154
165
  }
155
166
  } else if (side === 'top') {
156
167
  if (spaceAbove < neededHeight && spaceBelow > neededHeight) {
157
- setCurrentSide('bottom')
158
- } else {
159
- setCurrentSide('top')
168
+ usedSide = 'bottom'
160
169
  }
161
170
  }
171
+ setCurrentSide(usedSide)
172
+
173
+ if (usedSide === 'bottom') {
174
+ top = triggerRect.bottom + sideOffset
175
+ } else {
176
+ top = triggerRect.top - contentRect.height - sideOffset
177
+ }
178
+
179
+ // Horizontal positioning logic (Alignment)
180
+ if (align === 'start') {
181
+ left = triggerRect.left
182
+ } else if (align === 'end') {
183
+ left = triggerRect.right - contentRect.width
184
+ } else {
185
+ // center
186
+ left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
187
+ }
188
+
189
+ // Collision detection / clamping for X axis
190
+ if (left < 4) left = 4
191
+ if (left + contentRect.width > viewportWidth - 4) {
192
+ left = viewportWidth - contentRect.width - 4
193
+ }
194
+
195
+ setPosition({ top, left })
162
196
  }
163
197
  }
164
198
 
165
199
  if (context.open) {
166
- // Check position immediately after render cycle (via timeout to allow ref to populate and layout to occur)
167
- // Using requestAnimationFrame for better timing in layout cycle
200
+ // Check position immediately after render cycle
168
201
  requestAnimationFrame(checkPosition)
169
202
  }
170
203
 
@@ -173,7 +206,7 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
173
206
  }, 0)
174
207
  document.addEventListener("keydown", handleEscape)
175
208
  window.addEventListener("resize", checkPosition)
176
- window.addEventListener("scroll", checkPosition, true) // Capture scroll to update pos
209
+ window.addEventListener("scroll", checkPosition, true)
177
210
 
178
211
  return () => {
179
212
  clearTimeout(timer)
@@ -182,40 +215,51 @@ const DropdownMenuContent = React.forwardRef<HTMLDivElement, DropdownMenuContent
182
215
  window.removeEventListener("resize", checkPosition)
183
216
  window.removeEventListener("scroll", checkPosition, true)
184
217
  }
185
- }, [context.open, context, side, sideOffset])
218
+ }, [context.open, context, side, sideOffset, align])
186
219
 
187
220
  if (!context.open) return null
221
+ if (portal && !mounted) return null
188
222
 
189
- // Calculate alignment classes
223
+ // Alignment classes are ignored if we use portal/fixed positioning,
224
+ // but kept for non-portal usage if someone opts out.
190
225
  const alignmentClasses = {
191
226
  start: 'left-0',
192
227
  center: 'left-1/2 -translate-x-1/2',
193
228
  end: 'right-0',
194
229
  }
195
230
 
196
- return (
231
+ const content = (
197
232
  <div
198
233
  ref={(node) => {
199
- (contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node
234
+ (contentRef as any).current = node
200
235
  if (typeof ref === 'function') ref(node)
201
236
  else if (ref) ref.current = node
202
237
  }}
203
238
  role="menu"
204
239
  aria-orientation="vertical"
205
240
  className={cn(
206
- "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
241
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
207
242
  "animate-in fade-in-0 zoom-in-95",
208
- alignmentClasses[align],
209
- currentSide === 'bottom' ? 'top-full' : 'bottom-full',
243
+ !portal && "absolute", // Use absolute if not portal
244
+ !portal && alignmentClasses[align],
245
+ !portal && (side === 'bottom' ? 'top-full mt-1' : 'bottom-full mb-1'),
246
+ portal && "fixed", // Use fixed if portal
210
247
  className
211
248
  )}
212
249
  style={{
213
- marginTop: currentSide === 'bottom' ? sideOffset : undefined,
214
- marginBottom: currentSide === 'top' ? sideOffset : undefined,
250
+ top: portal ? position.top : undefined,
251
+ left: portal ? position.left : undefined,
252
+ ...props.style
215
253
  }}
216
254
  {...props}
217
255
  />
218
256
  )
257
+
258
+ if (portal) {
259
+ return createPortal(content, document.body)
260
+ }
261
+
262
+ return content
219
263
  }
220
264
  )
221
265
  DropdownMenuContent.displayName = "DropdownMenuContent"
@@ -1,7 +1,9 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { createPortal } from "react-dom"
4
5
  import { cn } from "@/lib/utils"
6
+ import { Slot } from "@/components/ui/slot"
5
7
 
6
8
  // HoverCard Context
7
9
  interface HoverCardContextValue {
@@ -26,6 +28,19 @@ interface HoverCardProps {
26
28
  closeDelay?: number
27
29
  }
28
30
 
31
+ /**
32
+ * HoverCard component for displaying content on hover
33
+ *
34
+ * @example
35
+ * <HoverCard>
36
+ * <HoverCardTrigger asChild>
37
+ * <Button variant="link">@nextjs</Button>
38
+ * </HoverCardTrigger>
39
+ * <HoverCardContent>
40
+ * The React Framework – created and maintained by @vercel.
41
+ * </HoverCardContent>
42
+ * </HoverCard>
43
+ */
29
44
  const HoverCard = ({ children, openDelay = 200, closeDelay = 300 }: HoverCardProps) => {
30
45
  const [open, setOpen] = React.useState(false)
31
46
  const triggerRef = React.useRef<HTMLDivElement>(null)
@@ -81,14 +96,24 @@ const HoverCardTrigger = React.forwardRef<HTMLDivElement, HoverCardTriggerProps>
81
96
  ({ children, asChild, className, ...props }, ref) => {
82
97
  const { triggerRef } = useHoverCard()
83
98
 
99
+ // Merge refs manually since we have two refs to attach (triggerRef and ref)
100
+ const combinedRef = React.useCallback((node: HTMLDivElement | null) => {
101
+ if (triggerRef) (triggerRef as any).current = node
102
+
103
+ if (typeof ref === "function") ref(node)
104
+ else if (ref) (ref as any).current = node
105
+ }, [triggerRef, ref])
106
+
107
+ const Comp = asChild ? Slot : "div"
108
+
84
109
  return (
85
- <div
86
- ref={triggerRef}
110
+ <Comp
111
+ ref={combinedRef}
87
112
  className={cn("inline-block cursor-pointer", className)}
88
113
  {...props}
89
114
  >
90
115
  {children}
91
- </div>
116
+ </Comp>
92
117
  )
93
118
  }
94
119
  )
@@ -101,63 +126,96 @@ interface HoverCardContentProps extends React.HTMLAttributes<HTMLDivElement> {
101
126
  sideOffset?: number
102
127
  }
103
128
 
104
- const HoverCardContent = React.forwardRef<HTMLDivElement, HoverCardContentProps>(
105
- ({ children, className, align = "center", side = "bottom", sideOffset = 8, ...props }, ref) => {
129
+ const HoverCardContent = React.forwardRef<
130
+ HTMLDivElement,
131
+ HoverCardContentProps & { portal?: boolean }
132
+ >(
133
+ ({ children, className, align = "center", side = "bottom", sideOffset = 4, portal = true, ...props }, ref) => {
106
134
  const { open, triggerRef } = useHoverCard()
107
135
  const [position, setPosition] = React.useState({ top: 0, left: 0 })
108
136
  const contentRef = React.useRef<HTMLDivElement>(null)
137
+ const [mounted, setMounted] = React.useState(false)
109
138
 
110
139
  React.useEffect(() => {
111
- if (!open || !triggerRef.current || !contentRef.current) return
112
-
113
- const triggerRect = triggerRef.current.getBoundingClientRect()
114
- const contentRect = contentRef.current.getBoundingClientRect()
140
+ setMounted(true)
141
+ }, [])
115
142
 
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
- }
143
+ React.useEffect(() => {
144
+ if (!open || !triggerRef.current || !contentRef.current) return
125
145
 
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
146
+ const checkPosition = () => {
147
+ if (!triggerRef.current || !contentRef.current) return
148
+ const triggerRect = triggerRef.current.getBoundingClientRect()
149
+ const contentRect = contentRef.current.getBoundingClientRect()
150
+
151
+ let top = 0
152
+ let left = 0
153
+
154
+ // Calculate vertical position
155
+ if (side === "bottom") {
156
+ top = triggerRect.bottom + sideOffset
157
+ } else {
158
+ top = triggerRect.top - contentRect.height - sideOffset
159
+ }
160
+
161
+ // Calculate horizontal position
162
+ if (align === "start") {
163
+ left = triggerRect.left
164
+ } else if (align === "end") {
165
+ left = triggerRect.right - contentRect.width
166
+ } else {
167
+ left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
168
+ }
169
+
170
+ // Clamp to viewport
171
+ left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
172
+ top = Math.max(8, Math.min(top, window.innerHeight - contentRect.height - 8))
173
+
174
+ setPosition({ top, left })
133
175
  }
134
176
 
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))
177
+ checkPosition()
178
+ window.addEventListener('resize', checkPosition)
179
+ window.addEventListener('scroll', checkPosition, true)
138
180
 
139
- setPosition({ top, left })
181
+ return () => {
182
+ window.removeEventListener('resize', checkPosition)
183
+ window.removeEventListener('scroll', checkPosition, true)
184
+ }
140
185
  }, [open, triggerRef, align, side, sideOffset])
141
186
 
142
187
  if (!open) return null
143
188
 
144
- return (
189
+ const content = (
145
190
  <div
146
- ref={contentRef}
191
+ ref={(node) => {
192
+ (contentRef as any).current = node
193
+ if (typeof ref === 'function') ref(node)
194
+ else if (ref) (ref as any).current = node
195
+ }}
147
196
  className={cn(
148
- "fixed z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
197
+ "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none",
149
198
  "animate-in fade-in-0 zoom-in-95",
199
+ !portal && "absolute",
200
+ portal && "fixed",
150
201
  className
151
202
  )}
152
203
  style={{
153
- top: position.top,
154
- left: position.left,
204
+ top: portal ? position.top : undefined,
205
+ left: portal ? position.left : undefined,
206
+ ...props.style
155
207
  }}
156
208
  {...props}
157
209
  >
158
210
  {children}
159
211
  </div>
160
212
  )
213
+
214
+ if (portal && mounted) {
215
+ return createPortal(content, document.body)
216
+ }
217
+
218
+ return content
161
219
  }
162
220
  )
163
221
  HoverCardContent.displayName = "HoverCardContent"