/** * @file ContextMenu — the one shell every right-click (and `⋯`) menu opens * in: a fixed list at a viewport point, rendered into `document.body` so no * transformed or clipped ancestor moves it, nudged back inside the viewport * once measured, closed by Escape, a press outside it, or after an item runs. * `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint` * turns a mouse event into that point (and stops the browser's own menu). * * Keyboard (WAI-ARIA menu): the first enabled item takes focus on open; * Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to * what held it before the menu opened — unless an item moved it elsewhere * (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or * the Menu key reports clientX/Y 0) anchors under the element instead of the * viewport's corner. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react"; export type MenuPoint = { x: number; y: number }; /** The pointer of a right-click, the browser's own menu suppressed. A * keyboard-opened menu has no pointer, so it anchors under the element. */ export function menuPoint(e: ReactMouseEvent): MenuPoint { e.preventDefault(); e.stopPropagation(); if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) { const r = e.currentTarget.getBoundingClientRect(); return { x: r.left, y: r.bottom }; } return { x: e.clientX, y: e.clientY }; } /** Menu state for a list of rows: which row's menu is open, and where. */ export function useContextMenu() { const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null); const open = useCallback((e: ReactMouseEvent, item: T) => { setMenu({ ...menuPoint(e), item }); }, []); const close = useCallback(() => setMenu(null), []); return { menu, open, close }; } const MARGIN = 8; const stop = (e: { stopPropagation: () => void }) => e.stopPropagation(); const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])'; export function ContextMenu({ x, y, onClose, testId, className = "w-56", children, }: MenuPoint & { onClose: () => void; testId?: string; className?: string; children: ReactNode; }) { const ref = useRef(null); const [pos, setPos] = useState({ left: x, top: y }); // Focus in on open, back out on close. Runs once per mount: a menu that // moves to a new point keeps its focus. useEffect(() => { const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; ref.current?.querySelector(ITEMS)?.focus({ preventScroll: true }); return () => { // Only when focus would otherwise be lost (it sat on a removed item). const now = document.activeElement; if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.(); }; }, []); const onKeyDown = (e: ReactKeyboardEvent) => { const inField = (e.target as HTMLElement).closest( "input, textarea, select, [contenteditable='true']" ); if (e.key === "Escape" || (e.key === "Tab" && !inField)) { // Handled here so a dialog behind the menu does not close as well. e.preventDefault(); e.stopPropagation(); onClose(); return; } // A field inside a menu (a size slider, a search box) keeps its own keys. if (inField) return; const items = [...(ref.current?.querySelectorAll(ITEMS) ?? [])]; if (items.length === 0) return; const i = items.indexOf(document.activeElement as HTMLElement); const next = e.key === "ArrowDown" ? (i + 1) % items.length : e.key === "ArrowUp" ? (i - 1 + items.length) % items.length : e.key === "Home" ? 0 : e.key === "End" ? items.length - 1 : -1; if (next < 0) return; e.preventDefault(); items[next]!.focus(); }; useLayoutEffect(() => { const r = ref.current?.getBoundingClientRect(); if (!r) return; setPos({ left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)), top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)), }); }, [x, y]); useEffect(() => { const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) onClose(); }; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); window.addEventListener("blur", onClose); window.addEventListener("resize", onClose); return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); window.removeEventListener("blur", onClose); window.removeEventListener("resize", onClose); }; }, [onClose]); return createPortal(
{ e.preventDefault(); e.stopPropagation(); }} className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`} > {children}
, document.body ); } export const MENU_ITEM = "flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50"; export function MenuItem({ onSelect, danger, disabled, testId, children, }: { onSelect: () => void; danger?: boolean; disabled?: boolean; testId?: string; children: ReactNode; }) { return ( ); } export function MenuSeparator() { return
; }