88 lines
2.6 KiB
TypeScript
88 lines
2.6 KiB
TypeScript
|
|
import { useEffect, useRef, type ReactNode } from 'react';
|
||
|
|
|
||
|
|
interface MenuItem {
|
||
|
|
label: string;
|
||
|
|
icon?: ReactNode;
|
||
|
|
onClick: () => void;
|
||
|
|
variant?: 'default' | 'danger';
|
||
|
|
checked?: boolean;
|
||
|
|
separator?: false;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SeparatorItem {
|
||
|
|
separator: true;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type ContextMenuEntry = MenuItem | SeparatorItem;
|
||
|
|
|
||
|
|
interface ContextMenuProps {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
items: ContextMenuEntry[];
|
||
|
|
onClose: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
function handleMouseDown(e: MouseEvent) {
|
||
|
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||
|
|
onClose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
function handleKeyDown(e: KeyboardEvent) {
|
||
|
|
if (e.key === 'Escape') onClose();
|
||
|
|
}
|
||
|
|
document.addEventListener('mousedown', handleMouseDown);
|
||
|
|
document.addEventListener('keydown', handleKeyDown);
|
||
|
|
return () => {
|
||
|
|
document.removeEventListener('mousedown', handleMouseDown);
|
||
|
|
document.removeEventListener('keydown', handleKeyDown);
|
||
|
|
};
|
||
|
|
}, [onClose]);
|
||
|
|
|
||
|
|
// Clamp so menu doesn't overflow right/bottom edge
|
||
|
|
const menuWidth = 192;
|
||
|
|
const menuHeight = items.length * 34;
|
||
|
|
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
||
|
|
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={menuRef}
|
||
|
|
style={{ top: clampedY, left: clampedX, zIndex: 9999 }}
|
||
|
|
className="fixed min-w-[192px] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 text-sm"
|
||
|
|
>
|
||
|
|
{items.map((item, i) => {
|
||
|
|
if ('separator' in item && item.separator) {
|
||
|
|
return <div key={i} className="my-1 border-t border-slate-700" />;
|
||
|
|
}
|
||
|
|
const mi = item as MenuItem;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={i}
|
||
|
|
className={`w-full text-left flex items-center gap-2 px-3 py-1.5 transition-colors hover:bg-slate-700 ${
|
||
|
|
mi.variant === 'danger'
|
||
|
|
? 'text-red-400 hover:text-red-300'
|
||
|
|
: 'text-slate-200'
|
||
|
|
}`}
|
||
|
|
onClick={() => {
|
||
|
|
mi.onClick();
|
||
|
|
onClose();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{mi.icon && <span className="shrink-0 w-4 text-slate-400">{mi.icon}</span>}
|
||
|
|
<span className="flex-1">{mi.label}</span>
|
||
|
|
{mi.checked !== undefined && (
|
||
|
|
<span className={`text-xs ${mi.checked ? 'text-blue-400' : 'text-slate-600'}`}>
|
||
|
|
{mi.checked ? '✓' : ''}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|