150 lines
5.1 KiB
TypeScript
150 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { AnimatePresence, motion } from '@/lib/motion';
|
|
import { EllipsisVertical, Edit, Trash2 } from 'lucide-react';
|
|
|
|
const MENU_WIDTH = 148;
|
|
const MENU_ESTIMATED_HEIGHT = 84;
|
|
const GAP = 6;
|
|
const VIEWPORT_PAD = 8;
|
|
|
|
interface RowActionsMenuProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
export function RowActionsMenu({ open, onOpenChange, onEdit, onDelete }: RowActionsMenuProps) {
|
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const [mounted, setMounted] = useState(false);
|
|
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const updatePosition = () => {
|
|
const trigger = triggerRef.current;
|
|
if (!trigger) return;
|
|
|
|
const rect = trigger.getBoundingClientRect();
|
|
const menuHeight = menuRef.current?.offsetHeight || MENU_ESTIMATED_HEIGHT;
|
|
const spaceBelow = window.innerHeight - rect.bottom - VIEWPORT_PAD;
|
|
const placeAbove = spaceBelow < menuHeight && rect.top > menuHeight + GAP;
|
|
|
|
let top = placeAbove ? rect.top - menuHeight - GAP : rect.bottom + GAP;
|
|
let left = rect.right - MENU_WIDTH;
|
|
|
|
if (left < VIEWPORT_PAD) left = VIEWPORT_PAD;
|
|
if (left + MENU_WIDTH > window.innerWidth - VIEWPORT_PAD) {
|
|
left = window.innerWidth - MENU_WIDTH - VIEWPORT_PAD;
|
|
}
|
|
if (top < VIEWPORT_PAD) top = VIEWPORT_PAD;
|
|
if (top + menuHeight > window.innerHeight - VIEWPORT_PAD) {
|
|
top = Math.max(VIEWPORT_PAD, window.innerHeight - menuHeight - VIEWPORT_PAD);
|
|
}
|
|
|
|
setCoords({ top, left });
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) return;
|
|
updatePosition();
|
|
const frame = requestAnimationFrame(updatePosition);
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') onOpenChange(false);
|
|
};
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
|
onOpenChange(false);
|
|
};
|
|
const onReposition = () => updatePosition();
|
|
|
|
document.addEventListener('keydown', onKeyDown);
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
window.addEventListener('resize', onReposition);
|
|
window.addEventListener('scroll', onReposition, true);
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
window.removeEventListener('resize', onReposition);
|
|
window.removeEventListener('scroll', onReposition, true);
|
|
};
|
|
}, [open, onOpenChange]);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-label="Row actions"
|
|
title="Actions"
|
|
onClick={() => onOpenChange(!open)}
|
|
className={`w-8 h-8 crm-radius-toggle border flex items-center justify-center cursor-pointer transition-colors ${
|
|
open
|
|
? 'border-primary bg-primary text-white'
|
|
: 'border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground'
|
|
}`}
|
|
>
|
|
<EllipsisVertical className="w-4 h-4" />
|
|
</button>
|
|
{mounted &&
|
|
createPortal(
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
ref={menuRef}
|
|
role="menu"
|
|
initial={{ opacity: 0, scale: 0.96, y: -4 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.96, y: -4 }}
|
|
transition={{ duration: 0.15, ease: 'easeOut' }}
|
|
style={{ top: coords.top, left: coords.left, width: MENU_WIDTH }}
|
|
className="fixed z-[90] origin-top-right crm-radius-none border border-border bg-card py-1 shadow-lg"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
onEdit();
|
|
}}
|
|
className="w-full flex items-center gap-2.5 px-3.5 py-2 text-[13px] text-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
<Edit className="w-3.5 h-3.5 text-muted-foreground" />
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
onDelete();
|
|
}}
|
|
className="w-full flex items-center gap-2.5 px-3.5 py-2 text-[13px] text-foreground hover:bg-muted cursor-pointer"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 text-muted-foreground" />
|
|
Delete
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|