import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import styles from './ContextMenu.module.css'; export interface ContextMenuItem { label: string; onClick: () => void; variant?: 'default' | 'danger'; icon?: React.ReactNode; } export interface ContextMenuProps { items: ContextMenuItem[]; /** The trigger element — a button rendered by the component */ trigger: React.ReactNode; align?: 'left' | 'right'; } export function ContextMenu({ items, trigger, align = 'right' }: ContextMenuProps) { const [open, setOpen] = useState(false); const [coords, setCoords] = useState({ top: 0, left: 0, right: 0 }); const triggerRef = useRef(null); const menuRef = useRef(null); useEffect(() => { if (!open) return; function handleOutside(e: MouseEvent) { const target = e.target as Node; if ( triggerRef.current && !triggerRef.current.contains(target) && menuRef.current && !menuRef.current.contains(target) ) { setOpen(false); } } document.addEventListener('mousedown', handleOutside); return () => document.removeEventListener('mousedown', handleOutside); }, [open]); useEffect(() => { if (!open) return; function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [open]); function openMenu() { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); setCoords({ top: rect.bottom + window.scrollY + 4, left: rect.left + window.scrollX, right: window.innerWidth - rect.right - window.scrollX, }); setOpen((v) => !v); } const menuStyle = align === 'right' ? { top: coords.top, right: coords.right } : { top: coords.top, left: coords.left }; return (
{ e.stopPropagation(); openMenu(); }} > {trigger}
{open && createPortal(
    {items.map((item, i) => (
  • ))}
, document.body, )}
); }