99 lines
2.8 KiB
TypeScript
99 lines
2.8 KiB
TypeScript
import { useState, useRef, useEffect, type ReactNode } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { MoreHorizontal } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
export interface ActionMenuItem {
|
|
label: string
|
|
icon?: ReactNode
|
|
onClick: () => void
|
|
destructive?: boolean
|
|
disabled?: boolean
|
|
}
|
|
|
|
interface ActionMenuProps {
|
|
items: ActionMenuItem[]
|
|
}
|
|
|
|
export function ActionMenu({ items }: ActionMenuProps) {
|
|
const [open, setOpen] = useState(false)
|
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const [menuPosition, setMenuPosition] = useState({ top: 0, right: 0 })
|
|
|
|
// Calculate menu position when opened
|
|
useEffect(() => {
|
|
if (open && buttonRef.current) {
|
|
const rect = buttonRef.current.getBoundingClientRect()
|
|
setMenuPosition({
|
|
top: rect.bottom + window.scrollY + 4, // 4px gap (mt-1)
|
|
right: window.innerWidth - rect.right + window.scrollX,
|
|
})
|
|
}
|
|
}, [open])
|
|
|
|
// Close menu when clicking outside
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const handler = (e: MouseEvent) => {
|
|
if (
|
|
buttonRef.current && !buttonRef.current.contains(e.target as Node) &&
|
|
menuRef.current && !menuRef.current.contains(e.target as Node)
|
|
) {
|
|
setOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [open])
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
onClick={() => setOpen(!open)}
|
|
className={cn(
|
|
'rounded-md p-1.5 text-muted-foreground transition-colors',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</button>
|
|
{open && createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
className={cn(
|
|
'fixed z-50 min-w-[160px] rounded-md border border-border',
|
|
'bg-card py-1 shadow-lg animate-scale-in'
|
|
)}
|
|
style={{
|
|
top: `${menuPosition.top}px`,
|
|
right: `${menuPosition.right}px`,
|
|
}}
|
|
>
|
|
{items.map((item) => (
|
|
<button
|
|
key={item.label}
|
|
onClick={() => { item.onClick(); setOpen(false) }}
|
|
disabled={item.disabled}
|
|
className={cn(
|
|
'flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors',
|
|
'disabled:opacity-50 disabled:pointer-events-none',
|
|
item.destructive
|
|
? 'text-red-400 hover:bg-red-400/10'
|
|
: 'text-muted-foreground hover:bg-accent'
|
|
)}
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default ActionMenu
|