feat: add shared ContextMenu component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
104
frontend/src/components/common/ContextMenu.tsx
Normal file
104
frontend/src/components/common/ContextMenu.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface ContextMenuPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
onClick: () => void
|
||||||
|
variant?: 'default' | 'danger'
|
||||||
|
separator?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
position: ContextMenuPosition
|
||||||
|
items: ContextMenuItem[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ position, items, onClose }: ContextMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const handleClickOutside = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [handleClickOutside, handleKeyDown])
|
||||||
|
|
||||||
|
// Adjust position to stay within viewport
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuRef.current) return
|
||||||
|
const rect = menuRef.current.getBoundingClientRect()
|
||||||
|
const el = menuRef.current
|
||||||
|
if (position.x + rect.width > window.innerWidth) {
|
||||||
|
el.style.left = `${position.x - rect.width}px`
|
||||||
|
}
|
||||||
|
if (position.y + rect.height > window.innerHeight) {
|
||||||
|
el.style.top = `${position.y - rect.height}px`
|
||||||
|
}
|
||||||
|
}, [position])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 100,
|
||||||
|
left: position.x,
|
||||||
|
top: position.y,
|
||||||
|
}}
|
||||||
|
className="min-w-[200px] rounded-xl border border-border bg-card p-1 shadow-lg backdrop-blur-md"
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.separator && (
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
item.onClick()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
item.variant === 'danger'
|
||||||
|
? 'text-rose-400 hover:bg-rose-500/10'
|
||||||
|
: 'text-foreground hover:bg-[rgba(255,255,255,0.06)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user