diff --git a/frontend/src/components/common/ContextMenu.tsx b/frontend/src/components/common/ContextMenu.tsx new file mode 100644 index 00000000..a7e32770 --- /dev/null +++ b/frontend/src/components/common/ContextMenu.tsx @@ -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(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 ( +
+ {items.map((item) => ( +
+ {item.separator && ( +
+ )} + +
+ ))} +
+ ) +}