"use client"; import type { LucideIcon } from "lucide-react"; import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; export type GridContextMenuEntry = | { kind: "action"; label: string; icon: LucideIcon; onSelect: () => void; shortcut?: string; disabled?: boolean; title?: string; variant?: "default" | "danger"; } | { kind: "separator" }; /** * Right-click menu of the circuit grid at fixed viewport coordinates. It * stays inside the viewport, closes on outside click, Escape, scroll or * resize, and supports arrow-key navigation between enabled actions. */ export function GridContextMenu({ x, y, entries, onClose, ariaLabel, }: { x: number; y: number; entries: GridContextMenuEntry[]; /** `restoreFocus` is true when the menu was dismissed by keyboard. */ onClose: (restoreFocus: boolean) => void; ariaLabel: string; }) { const menuRef = useRef(null); // Optional groups may leave leading, trailing or doubled separators. const visibleEntries = entries.filter( (entry, index) => entry.kind !== "separator" || (index > 0 && index < entries.length - 1 && entries[index - 1].kind !== "separator") ); const [position, setPosition] = useState({ left: x, top: y }); useLayoutEffect(() => { const menu = menuRef.current; if (!menu) { return; } const { width, height } = menu.getBoundingClientRect(); const margin = 8; setPosition({ left: Math.max(margin, Math.min(x, window.innerWidth - width - margin)), top: Math.max(margin, Math.min(y, window.innerHeight - height - margin)), }); menu .querySelector("button:not(:disabled)") ?.focus({ preventScroll: true }); }, [x, y]); useEffect(() => { function handlePointerDown(event: PointerEvent) { if (!menuRef.current?.contains(event.target as Node)) { onClose(false); } } function handleViewportChange() { onClose(true); } document.addEventListener("pointerdown", handlePointerDown, true); window.addEventListener("resize", handleViewportChange); window.addEventListener("scroll", handleViewportChange, true); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); window.removeEventListener("resize", handleViewportChange); window.removeEventListener("scroll", handleViewportChange, true); }; }, [onClose]); function handleKeyDown(event: React.KeyboardEvent) { if (event.key === "Escape" || event.key === "Tab") { event.preventDefault(); onClose(true); return; } if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } event.preventDefault(); const buttons = [ ...(menuRef.current?.querySelectorAll( "button:not(:disabled)" ) ?? []), ]; if (buttons.length === 0) { return; } const index = buttons.indexOf(document.activeElement as HTMLButtonElement); const next = event.key === "ArrowDown" ? (index + 1) % buttons.length : (index - 1 + buttons.length) % buttons.length; buttons[next].focus(); } return (
event.preventDefault()} onKeyDown={handleKeyDown} ref={menuRef} role="menu" style={{ left: position.left, top: position.top }} > {visibleEntries.map((entry, index) => entry.kind === "separator" ? ( ); }