Files
backspace/packages/web/src/components/ui/ContextMenu.tsx
T

128 lines
4.1 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import { useUIStore } from '../../stores/uiStore';
interface ContextMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
icon?: React.ReactNode;
}
interface ContextMenuProps {
items: ContextMenuItem[];
children: React.ReactNode;
}
export function ContextMenu({ items, children }: ContextMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const isMobile = useUIStore((s) => s.isMobile);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x < 8) newPosition.x = 8;
if (newPosition.y < 8) newPosition.y = 8;
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (
<>
<div onContextMenu={handleContextMenu}>{children}</div>
{isOpen && isMobile && (
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[300] bg-black/50" onClick={() => setIsOpen(false)} />
{/* Bottom sheet menu */}
<div
className="fixed bottom-0 left-0 right-0 z-[301] rounded-t-2xl glass-bubble animate-slide-up-sheet"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
>
<div className="w-10 h-1 bg-txt-tertiary/30 rounded-full mx-auto mt-2 mb-1" />
<div className="py-1">
{items.map((item, i) => (
<button
key={i}
className={`w-full text-left px-5 py-3 text-sm flex items-center gap-3 ${
item.danger ? 'text-txt-danger' : 'text-txt-primary'
}`}
onClick={(e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}}
>
{item.icon && <span className="w-5 h-5">{item.icon}</span>}
{item.label}
</button>
))}
</div>
</div>
</>
)}
{isOpen && !isMobile && (
<div
ref={menuRef}
className="fixed z-[200] min-w-[180px] py-1.5 glass rounded-md animate-fade-in max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${
item.danger
? 'text-txt-danger hover:bg-accent-rose hover:text-white'
: 'text-txt-secondary hover:bg-accent-primary hover:text-white'
}`}
style={{ width: 'calc(100% - 12px)' }}
onClick={(e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
))}
</div>
)}
</>
);
}