import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; export interface MenuItem { label: string; onSelect(): void; disabled?: boolean; danger?: boolean; hint?: string; } export type MenuEntry = MenuItem | 'separator'; interface MenuProps { x: number; y: number; items: MenuEntry[]; onClose(): void; label: string; } /** A pop-up menu that keeps itself on screen and closes on the next click. */ export function Menu({ x, y, items, onClose, label }: MenuProps): ReactNode { const ref = useRef(null); const [pos, setPos] = useState({ left: x, top: y }); useLayoutEffect(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const left = Math.max(6, Math.min(x, window.innerWidth - rect.width - 6)); const top = Math.max(6, Math.min(y, window.innerHeight - rect.height - 6)); setPos({ left, top }); el.querySelector('button:not([disabled])')?.focus(); }, [x, y]); useEffect(() => { const close = () => onClose(); const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); onClose(); } }; // `mousedown` rather than `click` so the menu is gone before whatever was // underneath it reacts. window.addEventListener('mousedown', close); window.addEventListener('resize', close); window.addEventListener('keydown', onKey, true); return () => { window.removeEventListener('mousedown', close); window.removeEventListener('resize', close); window.removeEventListener('keydown', onKey, true); }; }, [onClose]); return (
e.stopPropagation()} > {items.map((item, i) => item === 'separator' ? (
) : ( ), )}
); } export function useMenu() { const [state, setState] = useState<{ x: number; y: number; payload: T } | null>(null); return { state, open: (event: { clientX: number; clientY: number }, payload: T) => setState({ x: event.clientX, y: event.clientY, payload }), close: () => setState(null), }; }