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