"use client"; import { useEffect, useRef } from "react"; /** * FileTreeContextMenu — VSCode-style right-click menu for a single * file-tree row. Pops at the cursor's viewport coords; dismisses on * outside-click, Esc, blur, or scroll. * * Why a custom component (no library): the menu is one of several * "small popovers" in canvas; pulling in a dnd / popover lib for one * surface adds 10x the bytes of this implementation. The patterns * (outside-click + Esc + portal-free fixed position) match the * ContextMenu used in canvas/Toolbar so the keyboard-nav muscle * memory is uniform. * * Items are rendered from a `MenuItem[]` so callers can add/remove * actions without touching this component (e.g. PR-D will add an * "Upload to this folder" item for directory rows). * * Accessibility: * - role="menu" + role="menuitem" so screen readers announce the * surface as a menu, not a generic div. * - First item gets autofocus so keyboard users can ↓/↑/Enter without * reaching for the mouse. * - Esc + outside-click + Tab dismisses; behaves like every other * menu the user has touched on the canvas. */ export interface MenuItem { /** Stable identifier for testing + analytics. */ id: string; label: string; /** Optional left icon glyph; not load-bearing. */ icon?: string; /** Destructive (rendered in red) — for Delete-class actions. */ destructive?: boolean; /** Item-specific click handler. The menu auto-closes after onClick * fires so handlers don't have to call onClose themselves. */ onClick: () => void; /** Disabled items render but don't fire onClick (useful for * Delete-on-non-/configs case where the caller wants to surface * the item but explain it's gated). Currently unused — placeholder * for future options. */ disabled?: boolean; } interface Props { /** Viewport-coordinate position of the cursor that opened the menu. */ x: number; y: number; items: MenuItem[]; onClose: () => void; } export function FileTreeContextMenu({ x, y, items, onClose }: Props) { const ref = useRef(null); // First item gets initial focus for keyboard ↓/↑/Enter nav. const firstItemRef = useRef(null); useEffect(() => { firstItemRef.current?.focus(); }, []); // Outside-click + Esc dismiss. Per memory // (feedback_abort_controller_for_rerendered_listeners), use an // AbortController so re-mounts (caller toggles the menu) don't leak // listeners. useEffect(() => { const ctrl = new AbortController(); const onPointerDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { // Roving focus across .menuitem buttons. Doing this with // tabindex management because Tab / Shift+Tab leave the menu // (which is the right thing — the user is escaping the menu). e.preventDefault(); const buttons = ref.current?.querySelectorAll( "[role='menuitem']:not([disabled])", ); if (!buttons || buttons.length === 0) return; const arr = Array.from(buttons); const cur = arr.indexOf(document.activeElement as HTMLButtonElement); const next = e.key === "ArrowDown" ? (cur + 1) % arr.length : (cur - 1 + arr.length) % arr.length; arr[next].focus(); } }; // `mousedown` (not `click`) so the menu dismisses BEFORE the // tree-row's click handler would fire — otherwise clicking // outside also selects a different row, which is not what the // user expected when "outside-click closes the menu". document.addEventListener("mousedown", onPointerDown, { signal: ctrl.signal }); document.addEventListener("keydown", onKeyDown, { signal: ctrl.signal }); // Scroll inside any ancestor also dismisses — the fixed-position // menu would otherwise stay anchored to viewport coords while the // row it points at scrolled away. Use capture so we catch scroll // on inner panels (FileTree's overflow-y-auto wrapper). document.addEventListener("scroll", onClose, { signal: ctrl.signal, capture: true }); return () => ctrl.abort(); }, [onClose]); return (
{items.map((item, i) => ( ))}
); }