617 lines
16 KiB
TypeScript
617 lines
16 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { createPortal } from "react-dom"
|
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
interface DropdownMenuContextValue {
|
|
open: boolean
|
|
setOpen: (open: boolean) => void
|
|
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
}
|
|
|
|
const DropdownMenuContext = React.createContext<DropdownMenuContextValue | null>(null)
|
|
|
|
function useDropdownMenu() {
|
|
const context = React.useContext(DropdownMenuContext)
|
|
if (!context) {
|
|
throw new Error("useDropdownMenu must be used within a DropdownMenu")
|
|
}
|
|
return context
|
|
}
|
|
|
|
interface DropdownMenuProps {
|
|
children: React.ReactNode
|
|
open?: boolean
|
|
defaultOpen?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
}
|
|
|
|
function DropdownMenu({
|
|
children,
|
|
open: controlledOpen,
|
|
defaultOpen = false,
|
|
onOpenChange,
|
|
}: DropdownMenuProps) {
|
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
|
|
|
const isControlled = controlledOpen !== undefined
|
|
const open = isControlled ? controlledOpen : uncontrolledOpen
|
|
|
|
const setOpen = React.useCallback((value: boolean) => {
|
|
if (!isControlled) {
|
|
setUncontrolledOpen(value)
|
|
}
|
|
onOpenChange?.(value)
|
|
}, [isControlled, onOpenChange])
|
|
|
|
return (
|
|
<DropdownMenuContext.Provider value={{ open, setOpen, triggerRef }}>
|
|
{children}
|
|
</DropdownMenuContext.Provider>
|
|
)
|
|
}
|
|
|
|
function DropdownMenuTrigger({
|
|
className,
|
|
children,
|
|
asChild,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }) {
|
|
const { open, setOpen, triggerRef } = useDropdownMenu()
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
setOpen(!open)
|
|
props.onClick?.(e)
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
|
|
e.preventDefault()
|
|
setOpen(true)
|
|
}
|
|
props.onKeyDown?.(e)
|
|
}
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement<any>, {
|
|
ref: triggerRef,
|
|
onClick: handleClick,
|
|
onKeyDown: handleKeyDown,
|
|
"aria-expanded": open,
|
|
"aria-haspopup": "menu",
|
|
"data-state": open ? "open" : "closed",
|
|
})
|
|
}
|
|
|
|
return (
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
data-slot="dropdown-menu-trigger"
|
|
aria-expanded={open}
|
|
aria-haspopup="menu"
|
|
data-state={open ? "open" : "closed"}
|
|
className={cn("relative", className)}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function DropdownMenuPortal({ children }: { children: React.ReactNode }) {
|
|
return <>{children}</>
|
|
}
|
|
|
|
interface DropdownMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
sideOffset?: number
|
|
align?: "start" | "center" | "end"
|
|
side?: "top" | "right" | "bottom" | "left"
|
|
}
|
|
|
|
function DropdownMenuContent({
|
|
className,
|
|
sideOffset = 4,
|
|
align = "start",
|
|
side = "bottom",
|
|
children,
|
|
...props
|
|
}: DropdownMenuContentProps) {
|
|
const { open, setOpen, triggerRef } = useDropdownMenu()
|
|
const contentRef = React.useRef<HTMLDivElement>(null)
|
|
const [position, setPosition] = React.useState({ top: 0, left: 0 })
|
|
const [mounted, setMounted] = React.useState(false)
|
|
|
|
React.useEffect(() => {
|
|
setMounted(true)
|
|
}, [])
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!open || !triggerRef.current || !contentRef.current) return
|
|
|
|
const trigger = triggerRef.current.getBoundingClientRect()
|
|
const content = contentRef.current.getBoundingClientRect()
|
|
|
|
let top = 0
|
|
let left = 0
|
|
|
|
// Calculate position based on side
|
|
switch (side) {
|
|
case "top":
|
|
top = trigger.top - content.height - sideOffset
|
|
break
|
|
case "bottom":
|
|
top = trigger.bottom + sideOffset
|
|
break
|
|
case "left":
|
|
left = trigger.left - content.width - sideOffset
|
|
top = trigger.top
|
|
break
|
|
case "right":
|
|
left = trigger.right + sideOffset
|
|
top = trigger.top
|
|
break
|
|
}
|
|
|
|
// Calculate alignment for top/bottom
|
|
if (side === "top" || side === "bottom") {
|
|
switch (align) {
|
|
case "start":
|
|
left = trigger.left
|
|
break
|
|
case "center":
|
|
left = trigger.left + (trigger.width - content.width) / 2
|
|
break
|
|
case "end":
|
|
left = trigger.right - content.width
|
|
break
|
|
}
|
|
}
|
|
|
|
// Calculate alignment for left/right
|
|
if (side === "left" || side === "right") {
|
|
switch (align) {
|
|
case "start":
|
|
top = trigger.top
|
|
break
|
|
case "center":
|
|
top = trigger.top + (trigger.height - content.height) / 2
|
|
break
|
|
case "end":
|
|
top = trigger.bottom - content.height
|
|
break
|
|
}
|
|
}
|
|
|
|
setPosition({ top, left })
|
|
}, [open, side, align, sideOffset, triggerRef])
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as Node
|
|
if (
|
|
contentRef.current &&
|
|
!contentRef.current.contains(target) &&
|
|
triggerRef.current &&
|
|
!triggerRef.current.contains(target)
|
|
) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleEscape = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
setOpen(false)
|
|
triggerRef.current?.focus()
|
|
}
|
|
}
|
|
|
|
document.addEventListener("mousedown", handleClickOutside)
|
|
document.addEventListener("keydown", handleEscape)
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside)
|
|
document.removeEventListener("keydown", handleEscape)
|
|
}
|
|
}, [open, setOpen, triggerRef])
|
|
|
|
if (!open || !mounted) return null
|
|
|
|
const slideClasses = {
|
|
top: "slide-in-from-bottom-2",
|
|
bottom: "slide-in-from-top-2",
|
|
left: "slide-in-from-right-2",
|
|
right: "slide-in-from-left-2",
|
|
}
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={contentRef}
|
|
data-slot="dropdown-menu-content"
|
|
data-state={open ? "open" : "closed"}
|
|
role="menu"
|
|
className={cn(
|
|
"fixed z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
|
"animate-in fade-in-0 zoom-in-95",
|
|
slideClasses[side],
|
|
className
|
|
)}
|
|
style={{
|
|
top: position.top,
|
|
left: position.left,
|
|
}}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
)
|
|
}
|
|
|
|
function DropdownMenuGroup({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
data-slot="dropdown-menu-group"
|
|
role="group"
|
|
className={className}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
inset?: boolean
|
|
variant?: "default" | "destructive"
|
|
asChild?: boolean
|
|
}
|
|
|
|
function DropdownMenuItem({
|
|
className,
|
|
inset,
|
|
variant = "default",
|
|
children,
|
|
asChild,
|
|
...props
|
|
}: DropdownMenuItemProps) {
|
|
const { setOpen } = useDropdownMenu()
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
props.onClick?.(e)
|
|
if (!e.defaultPrevented) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault()
|
|
handleClick(e as unknown as React.MouseEvent<HTMLButtonElement>)
|
|
}
|
|
props.onKeyDown?.(e)
|
|
}
|
|
|
|
const itemClasses = cn(
|
|
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
variant === "destructive" && "text-destructive focus:bg-destructive/10 focus:text-destructive dark:focus:bg-destructive/20",
|
|
inset && "pl-8",
|
|
className
|
|
)
|
|
|
|
if (asChild && React.isValidElement(children)) {
|
|
return React.cloneElement(children as React.ReactElement<any>, {
|
|
className: itemClasses,
|
|
role: "menuitem",
|
|
tabIndex: -1,
|
|
onClick: handleClick,
|
|
onKeyDown: handleKeyDown,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-slot="dropdown-menu-item"
|
|
data-inset={inset}
|
|
data-variant={variant}
|
|
role="menuitem"
|
|
tabIndex={-1}
|
|
className={itemClasses}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuCheckboxItemProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "checked"> {
|
|
checked?: boolean
|
|
onCheckedChange?: (checked: boolean) => void
|
|
}
|
|
|
|
function DropdownMenuCheckboxItem({
|
|
className,
|
|
children,
|
|
checked,
|
|
onCheckedChange,
|
|
...props
|
|
}: DropdownMenuCheckboxItemProps) {
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
onCheckedChange?.(!checked)
|
|
props.onClick?.(e)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-slot="dropdown-menu-checkbox-item"
|
|
role="menuitemcheckbox"
|
|
aria-checked={checked}
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none",
|
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
className
|
|
)}
|
|
onClick={handleClick}
|
|
{...props}
|
|
>
|
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
{checked && <CheckIcon className="size-4" />}
|
|
</span>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuRadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
value?: string
|
|
onValueChange?: (value: string) => void
|
|
}
|
|
|
|
const RadioGroupContext = React.createContext<{
|
|
value?: string
|
|
onValueChange?: (value: string) => void
|
|
} | null>(null)
|
|
|
|
function DropdownMenuRadioGroup({
|
|
value,
|
|
onValueChange,
|
|
children,
|
|
...props
|
|
}: DropdownMenuRadioGroupProps) {
|
|
return (
|
|
<RadioGroupContext.Provider value={{ value, onValueChange }}>
|
|
<div
|
|
data-slot="dropdown-menu-radio-group"
|
|
role="group"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</RadioGroupContext.Provider>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuRadioItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
value: string
|
|
}
|
|
|
|
function DropdownMenuRadioItem({
|
|
className,
|
|
children,
|
|
value,
|
|
...props
|
|
}: DropdownMenuRadioItemProps) {
|
|
const context = React.useContext(RadioGroupContext)
|
|
const checked = context?.value === value
|
|
|
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
context?.onValueChange?.(value)
|
|
props.onClick?.(e)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-slot="dropdown-menu-radio-item"
|
|
role="menuitemradio"
|
|
aria-checked={checked}
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none",
|
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
className
|
|
)}
|
|
onClick={handleClick}
|
|
{...props}
|
|
>
|
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
{checked && <CircleIcon className="size-2 fill-current" />}
|
|
</span>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuLabelProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
inset?: boolean
|
|
}
|
|
|
|
function DropdownMenuLabel({
|
|
className,
|
|
inset,
|
|
...props
|
|
}: DropdownMenuLabelProps) {
|
|
return (
|
|
<div
|
|
data-slot="dropdown-menu-label"
|
|
data-inset={inset}
|
|
className={cn(
|
|
"px-2 py-1.5 text-sm font-medium",
|
|
inset && "pl-8",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function DropdownMenuSeparator({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
return (
|
|
<div
|
|
data-slot="dropdown-menu-separator"
|
|
role="separator"
|
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function DropdownMenuShortcut({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLSpanElement>) {
|
|
return (
|
|
<span
|
|
data-slot="dropdown-menu-shortcut"
|
|
className={cn(
|
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface SubMenuContextValue {
|
|
open: boolean
|
|
setOpen: (open: boolean) => void
|
|
}
|
|
|
|
const SubMenuContext = React.createContext<SubMenuContextValue | null>(null)
|
|
|
|
function DropdownMenuSub({ children }: { children: React.ReactNode }) {
|
|
const [open, setOpen] = React.useState(false)
|
|
|
|
return (
|
|
<SubMenuContext.Provider value={{ open, setOpen }}>
|
|
<div data-slot="dropdown-menu-sub" className="relative">
|
|
{children}
|
|
</div>
|
|
</SubMenuContext.Provider>
|
|
)
|
|
}
|
|
|
|
interface DropdownMenuSubTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
inset?: boolean
|
|
}
|
|
|
|
function DropdownMenuSubTrigger({
|
|
className,
|
|
inset,
|
|
children,
|
|
...props
|
|
}: DropdownMenuSubTriggerProps) {
|
|
const context = React.useContext(SubMenuContext)
|
|
|
|
const handleMouseEnter = () => {
|
|
context?.setOpen(true)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
context?.setOpen(false)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-slot="dropdown-menu-sub-trigger"
|
|
data-inset={inset}
|
|
data-state={context?.open ? "open" : "closed"}
|
|
role="menuitem"
|
|
aria-haspopup="menu"
|
|
aria-expanded={context?.open}
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
"[&_svg:not([class*='text-'])]:text-muted-foreground flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
|
|
inset && "pl-8",
|
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
className
|
|
)}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<ChevronRightIcon className="ml-auto size-4" />
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function DropdownMenuSubContent({
|
|
className,
|
|
...props
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
const context = React.useContext(SubMenuContext)
|
|
|
|
if (!context?.open) return null
|
|
|
|
return (
|
|
<div
|
|
data-slot="dropdown-menu-sub-content"
|
|
data-state={context.open ? "open" : "closed"}
|
|
role="menu"
|
|
className={cn(
|
|
"absolute left-full top-0 z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg",
|
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-left-2",
|
|
className
|
|
)}
|
|
onMouseEnter={() => context.setOpen(true)}
|
|
onMouseLeave={() => context.setOpen(false)}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export {
|
|
DropdownMenu,
|
|
DropdownMenuPortal,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuLabel,
|
|
DropdownMenuItem,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuShortcut,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuSubContent,
|
|
}
|