dropdown-menu.tsx raw
1 import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
2 import { Check, ChevronDown, ChevronRight, ChevronUp, Circle } from 'lucide-react'
3 import * as React from 'react'
4
5 import { cn } from '@/lib/utils'
6 import { createPortal } from 'react-dom'
7
8 const DropdownMenu = ({
9 open: controlledOpen,
10 onOpenChange: controlledOnOpenChange,
11 ...props
12 }: React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root>) => {
13 const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
14 const isControlled = controlledOpen !== undefined
15 const open = isControlled ? controlledOpen : uncontrolledOpen
16 const backdropRef = React.useRef<HTMLDivElement>(null)
17
18 const handleOpenChange = React.useCallback(
19 (newOpen: boolean) => {
20 if (!isControlled) {
21 setUncontrolledOpen(newOpen)
22 }
23 controlledOnOpenChange?.(newOpen)
24 },
25 [isControlled, controlledOnOpenChange]
26 )
27
28 React.useEffect(() => {
29 if (open) {
30 const prevOverflow = document.body.style.overflow
31 document.body.style.overflow = 'hidden'
32
33 return () => {
34 document.body.style.overflow = prevOverflow
35 }
36 }
37 }, [open])
38
39 return (
40 <>
41 {open &&
42 createPortal(
43 <div
44 ref={backdropRef}
45 className="fixed inset-0 z-50 pointer-events-auto"
46 onClick={(e) => {
47 e.stopPropagation()
48 handleOpenChange(false)
49 }}
50 />,
51 document.body
52 )}
53 <DropdownMenuPrimitive.Root
54 {...props}
55 open={open}
56 onOpenChange={handleOpenChange}
57 modal={false}
58 />
59 </>
60 )
61 }
62 DropdownMenu.displayName = 'DropdownMenu'
63
64 const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
65
66 const DropdownMenuGroup = DropdownMenuPrimitive.Group
67
68 const DropdownMenuPortal = DropdownMenuPrimitive.Portal
69
70 const DropdownMenuSub = DropdownMenuPrimitive.Sub
71
72 const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
73
74 const DropdownMenuSubTrigger = React.forwardRef<
75 React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
76 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
77 inset?: boolean
78 }
79 >(({ className, inset, children, ...props }, ref) => (
80 <DropdownMenuPrimitive.SubTrigger
81 ref={ref}
82 className={cn(
83 'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
84 inset && 'pl-8',
85 className
86 )}
87 {...props}
88 >
89 {children}
90 <ChevronRight className="ml-auto" />
91 </DropdownMenuPrimitive.SubTrigger>
92 ))
93 DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
94
95 const DropdownMenuSubContent = React.forwardRef<
96 React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
97 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
98 showScrollButtons?: boolean
99 }
100 >(({ className, showScrollButtons = true, ...props }, ref) => {
101 const [canScrollUp, setCanScrollUp] = React.useState(false)
102 const [canScrollDown, setCanScrollDown] = React.useState(false)
103 const contentRef = React.useRef<HTMLDivElement>(null)
104 const scrollAreaRef = React.useRef<HTMLDivElement>(null)
105
106 React.useImperativeHandle(ref, () => contentRef.current!)
107
108 const checkScrollability = React.useCallback(() => {
109 requestAnimationFrame(() => {
110 const scrollArea = scrollAreaRef.current
111 if (!scrollArea) return
112
113 setCanScrollUp(scrollArea.scrollTop > 0)
114 setCanScrollDown(scrollArea.scrollTop < scrollArea.scrollHeight - scrollArea.clientHeight)
115 })
116 }, [])
117
118 const scrollUp = () => {
119 scrollAreaRef.current?.scroll({ top: 0, behavior: 'smooth' })
120 }
121
122 const scrollDown = () => {
123 scrollAreaRef.current?.scroll({
124 top: scrollAreaRef.current.scrollHeight,
125 behavior: 'smooth'
126 })
127 }
128
129 return (
130 <DropdownMenuPrimitive.Portal>
131 <DropdownMenuPrimitive.SubContent
132 ref={contentRef}
133 className={cn(
134 'relative z-50 min-w-52 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md 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'
135 )}
136 onAnimationEnd={() => {
137 if (showScrollButtons) {
138 checkScrollability()
139 }
140 }}
141 collisionPadding={10}
142 {...props}
143 >
144 {showScrollButtons && canScrollUp && (
145 <div className="absolute top-0 inset-x-0 z-10 flex items-center justify-center bg-popover">
146 <button
147 onClick={scrollUp}
148 onMouseEnter={scrollUp}
149 className="flex items-center justify-center w-full h-6 hover:bg-accent rounded-sm transition-colors"
150 type="button"
151 >
152 <ChevronUp className="h-4 w-4" />
153 </button>
154 </div>
155 )}
156
157 <div
158 ref={scrollAreaRef}
159 className={cn('p-1 overflow-y-auto scrollbar-hide', className)}
160 onScroll={checkScrollability}
161 >
162 {props.children}
163 </div>
164
165 {showScrollButtons && canScrollDown && (
166 <div className="absolute bottom-0 inset-x-0 flex items-center justify-center bg-popover">
167 <button
168 onClick={scrollDown}
169 onMouseEnter={scrollDown}
170 className="flex items-center justify-center w-full h-6 hover:bg-accent rounded-sm transition-colors"
171 type="button"
172 >
173 <ChevronDown className="h-4 w-4" />
174 </button>
175 </div>
176 )}
177 </DropdownMenuPrimitive.SubContent>
178 </DropdownMenuPrimitive.Portal>
179 )
180 })
181 DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
182
183 const DropdownMenuContent = React.forwardRef<
184 React.ElementRef<typeof DropdownMenuPrimitive.Content>,
185 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
186 showScrollButtons?: boolean
187 }
188 >(({ className, sideOffset = 4, showScrollButtons = false, ...props }, ref) => {
189 const [canScrollUp, setCanScrollUp] = React.useState(false)
190 const [canScrollDown, setCanScrollDown] = React.useState(false)
191 const contentRef = React.useRef<HTMLDivElement>(null)
192 const scrollAreaRef = React.useRef<HTMLDivElement>(null)
193
194 React.useImperativeHandle(ref, () => contentRef.current!)
195
196 const checkScrollability = React.useCallback(() => {
197 requestAnimationFrame(() => {
198 const scrollArea = scrollAreaRef.current
199 if (!scrollArea) return
200
201 setCanScrollUp(scrollArea.scrollTop > 0)
202 setCanScrollDown(scrollArea.scrollTop < scrollArea.scrollHeight - scrollArea.clientHeight)
203 })
204 }, [])
205
206 const scrollUp = () => {
207 scrollAreaRef.current?.scroll({ top: 0, behavior: 'smooth' })
208 }
209
210 const scrollDown = () => {
211 scrollAreaRef.current?.scroll({
212 top: scrollAreaRef.current.scrollHeight,
213 behavior: 'smooth'
214 })
215 }
216
217 return (
218 <DropdownMenuPrimitive.Portal>
219 <DropdownMenuPrimitive.Content
220 ref={contentRef}
221 sideOffset={sideOffset}
222 className={cn(
223 'relative z-50 min-w-52 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md 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'
224 )}
225 onAnimationEnd={() => {
226 if (showScrollButtons) {
227 checkScrollability()
228 }
229 }}
230 collisionPadding={10}
231 {...props}
232 >
233 {showScrollButtons && canScrollUp && (
234 <div className="absolute top-0 inset-x-0 z-10 flex items-center justify-center bg-popover">
235 <button
236 onClick={scrollUp}
237 onMouseEnter={scrollUp}
238 className="flex items-center justify-center w-full h-6 hover:bg-accent rounded-sm transition-colors"
239 type="button"
240 >
241 <ChevronUp className="h-4 w-4" />
242 </button>
243 </div>
244 )}
245
246 <div
247 ref={scrollAreaRef}
248 className={cn('p-1 overflow-y-auto scrollbar-hide', className)}
249 onScroll={checkScrollability}
250 onWheel={(e) => e.stopPropagation()}
251 >
252 {props.children}
253 </div>
254
255 {showScrollButtons && canScrollDown && (
256 <div className="absolute bottom-0 inset-x-0 flex items-center justify-center bg-popover">
257 <button
258 onClick={scrollDown}
259 onMouseEnter={scrollDown}
260 className="flex items-center justify-center w-full h-6 hover:bg-accent rounded-sm transition-colors"
261 type="button"
262 >
263 <ChevronDown className="h-4 w-4" />
264 </button>
265 </div>
266 )}
267 </DropdownMenuPrimitive.Content>
268 </DropdownMenuPrimitive.Portal>
269 )
270 })
271 DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
272
273 const DropdownMenuItem = React.forwardRef<
274 React.ElementRef<typeof DropdownMenuPrimitive.Item>,
275 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
276 inset?: boolean
277 }
278 >(({ className, inset, ...props }, ref) => (
279 <DropdownMenuPrimitive.Item
280 ref={ref}
281 className={cn(
282 'relative flex cursor-pointer select-none items-center gap-2 px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 rounded-md',
283 inset && 'pl-8',
284 className
285 )}
286 {...props}
287 />
288 ))
289 DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
290
291 const DropdownMenuCheckboxItem = React.forwardRef<
292 React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
293 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
294 >(({ className, children, checked, ...props }, ref) => (
295 <DropdownMenuPrimitive.CheckboxItem
296 ref={ref}
297 className={cn(
298 'relative flex cursor-pointer 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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
299 className
300 )}
301 checked={checked}
302 {...props}
303 >
304 <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
305 <DropdownMenuPrimitive.ItemIndicator>
306 <Check className="h-4 w-4" />
307 </DropdownMenuPrimitive.ItemIndicator>
308 </span>
309 {children}
310 </DropdownMenuPrimitive.CheckboxItem>
311 ))
312 DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
313
314 const DropdownMenuRadioItem = React.forwardRef<
315 React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
316 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
317 >(({ className, children, ...props }, ref) => (
318 <DropdownMenuPrimitive.RadioItem
319 ref={ref}
320 className={cn(
321 '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 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
322 className
323 )}
324 {...props}
325 >
326 <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
327 <DropdownMenuPrimitive.ItemIndicator>
328 <Circle className="h-2 w-2 fill-current" />
329 </DropdownMenuPrimitive.ItemIndicator>
330 </span>
331 {children}
332 </DropdownMenuPrimitive.RadioItem>
333 ))
334 DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
335
336 const DropdownMenuLabel = React.forwardRef<
337 React.ElementRef<typeof DropdownMenuPrimitive.Label>,
338 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
339 inset?: boolean
340 }
341 >(({ className, inset, ...props }, ref) => (
342 <DropdownMenuPrimitive.Label
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 = DropdownMenuPrimitive.Label.displayName
349
350 const DropdownMenuSeparator = React.forwardRef<
351 React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
352 React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
353 >(({ className, ...props }, ref) => (
354 <DropdownMenuPrimitive.Separator
355 ref={ref}
356 className={cn('-mx-1 my-1 h-px bg-muted', className)}
357 {...props}
358 />
359 ))
360 DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
361
362 const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
363 return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
364 }
365 DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
366
367 export {
368 DropdownMenu,
369 DropdownMenuCheckboxItem,
370 DropdownMenuContent,
371 DropdownMenuGroup,
372 DropdownMenuItem,
373 DropdownMenuLabel,
374 DropdownMenuPortal,
375 DropdownMenuRadioGroup,
376 DropdownMenuRadioItem,
377 DropdownMenuSeparator,
378 DropdownMenuShortcut,
379 DropdownMenuSub,
380 DropdownMenuSubContent,
381 DropdownMenuSubTrigger,
382 DropdownMenuTrigger
383 }
384