import { createPortal } from 'react-dom'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { NavIcon } from '../../NavIcon'; export type RowActionLinkItem = { key: string; label: string; href: string; external?: boolean; }; export type RowActionButtonItem = { key: string; label: string; onClick: () => void; danger?: boolean; /** Renders a non-interactive row (still visible so users see the action exists). */ disabled?: boolean; }; export type RowActionItem = RowActionLinkItem | RowActionButtonItem; function isLink(i: RowActionItem): i is RowActionLinkItem { return 'href' in i; } /** * ⋮ row menu. Panel is portaled to `document.body` with fixed positioning so * parent `overflow: hidden` / scroll containers (e.g. list tables) cannot clip it. */ export function RowActionsMenu({ items, ariaLabel }: { items: RowActionItem[]; ariaLabel: string }) { const [open, setOpen] = useState(false); const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); const wrapRef = useRef(null); const menuRef = useRef(null); useLayoutEffect(() => { if (!open || !wrapRef.current) { setMenuPos(null); return; } const update = () => { const el = wrapRef.current; if (!el) { return; } const rect = el.getBoundingClientRect(); const gap = 4; setMenuPos({ top: rect.bottom + gap, right: window.innerWidth - rect.right }); }; update(); const id = window.requestAnimationFrame(update); window.addEventListener('resize', update); window.addEventListener('scroll', update, true); return () => { window.cancelAnimationFrame(id); window.removeEventListener('resize', update); window.removeEventListener('scroll', update, true); }; }, [open, items.length]); useEffect(() => { if (!open) { return; } const onPointer = (e: MouseEvent | TouchEvent) => { const t = e.target; if (!(t instanceof Node)) { return; } if (wrapRef.current?.contains(t) || menuRef.current?.contains(t)) { return; } setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false); } }; document.addEventListener('mousedown', onPointer); document.addEventListener('touchstart', onPointer, { passive: true }); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onPointer); document.removeEventListener('touchstart', onPointer); document.removeEventListener('keydown', onKey); }; }, [open]); if (items.length === 0) { return null; } const menu = open && menuPos ? (
{items.map((item) => isLink(item) ? ( setOpen(false)} > {item.label} ) : ( ) )}
) : null; return ( <>
{typeof document !== 'undefined' && menu ? createPortal(menu, document.body) : null} ); }