Files
silo/web/src/components/ContextMenu.tsx

126 lines
3.7 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Check } from "lucide-react";
export interface ContextMenuItem {
label: string;
checked?: boolean;
onToggle?: () => void;
onClick?: () => void;
divider?: boolean;
disabled?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
const handleScroll = () => onClose();
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleKey);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleKey);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
// Clamp position to viewport
const style: React.CSSProperties = {
position: "fixed",
left: Math.min(x, window.innerWidth - 220),
top: Math.min(y, window.innerHeight - items.length * 32 - 16),
zIndex: 9999,
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.5rem",
padding: "0.25rem 0",
minWidth: 200,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
};
return (
<div ref={ref} style={style}>
{items.map((item, i) =>
item.divider ? (
<div
key={i}
style={{
borderTop: "1px solid var(--ctp-surface1)",
margin: "0.25rem 0",
}}
/>
) : (
<button
key={i}
onClick={() => {
if (item.onToggle) item.onToggle();
else if (item.onClick) {
item.onClick();
onClose();
}
}}
disabled={item.disabled}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
width: "100%",
padding: "0.35rem 0.75rem",
background: "none",
border: "none",
color: item.disabled ? "var(--ctp-overlay0)" : "var(--ctp-text)",
fontSize: "var(--font-body)",
cursor: item.disabled ? "default" : "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
if (!item.disabled)
e.currentTarget.style.backgroundColor = "var(--ctp-surface1)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
{item.checked !== undefined && (
<span
style={{
width: 16,
height: 16,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--ctp-overlay0)",
borderRadius: 3,
backgroundColor: item.checked
? "var(--ctp-mauve)"
: "transparent",
color: item.checked ? "var(--ctp-crust)" : "transparent",
flexShrink: 0,
}}
>
{item.checked ? <Check size={14} /> : ""}
</span>
)}
{item.label}
</button>
),
)}
</div>
);
}