"use client"; import { forwardRef, useState, useRef, useEffect, useLayoutEffect } from "react"; import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { ChevronRight } from "lucide-react"; interface Position { x: number; y: number; } interface ContextMenuProps { isOpen: boolean; position: Position; onClose: () => void; children: React.ReactNode; } const VIEWPORT_MARGIN = 10; export const ContextMenu = forwardRef( ({ isOpen, position, onClose: _onClose, children }, ref) => { const [mounted, setMounted] = useState(false); const [adjustedPosition, setAdjustedPosition] = useState(null); const localRef = useRef(null); useEffect(() => { setMounted(true); }, []); // Measure the rendered menu and clamp it inside the viewport before the // browser paints. We hide the element until this runs so the user never // sees the menu jump from an unclamped position to a clamped one. // `mounted` must be a dependency: on the very first open the menu isn't // in the DOM yet (mounted is still false), so this effect has to re-run // after the mount flip or the menu stays visibility:hidden. useLayoutEffect(() => { if (!isOpen) { setAdjustedPosition(null); return; } const node = localRef.current; if (!node) return; const rect = node.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let x = position.x; let y = position.y; if (x + rect.width > vw - VIEWPORT_MARGIN) { x = vw - rect.width - VIEWPORT_MARGIN; } if (y + rect.height > vh - VIEWPORT_MARGIN) { y = vh - rect.height - VIEWPORT_MARGIN; } x = Math.max(VIEWPORT_MARGIN, x); y = Math.max(VIEWPORT_MARGIN, y); setAdjustedPosition({ x, y }); }, [isOpen, mounted, position.x, position.y]); const setRefs = (node: HTMLDivElement | null) => { localRef.current = node; if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } }; if (!mounted || !isOpen) return null; const renderPosition = adjustedPosition ?? position; const isPositioned = adjustedPosition !== null; return createPortal(
{children}
, document.body ); } ); ContextMenu.displayName = "ContextMenu"; interface ContextMenuItemProps { icon?: React.ComponentType<{ className?: string }>; label: string; onClick: () => void; disabled?: boolean; destructive?: boolean; shortcut?: string; /** Stable hook for integration tests (not user-visible). */ testId?: string; } export function ContextMenuItem({ icon: Icon, label, onClick, disabled = false, destructive = false, shortcut, testId, }: ContextMenuItemProps) { return ( ); } export function ContextMenuSeparator() { return
; } interface ContextMenuSubMenuProps { icon?: React.ComponentType<{ className?: string }>; label: string; children: React.ReactNode; /** Stable hook for integration tests (not user-visible). */ testId?: string; } export function ContextMenuSubMenu({ icon: Icon, label, children, testId, }: ContextMenuSubMenuProps) { const [isOpen, setIsOpen] = useState(false); const [subMenuPos, setSubMenuPos] = useState(null); const itemRef = useRef(null); const subMenuRef = useRef(null); const closeTimerRef = useRef | null>(null); useLayoutEffect(() => { if (!isOpen) { setSubMenuPos(null); return; } const itemEl = itemRef.current; const subEl = subMenuRef.current; if (!itemEl || !subEl) return; const itemRect = itemEl.getBoundingClientRect(); const subRect = subEl.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let left: number; if (itemRect.right + subRect.width <= vw - VIEWPORT_MARGIN) { left = itemRect.right; } else if (itemRect.left - subRect.width >= VIEWPORT_MARGIN) { left = itemRect.left - subRect.width; } else { left = Math.max(VIEWPORT_MARGIN, vw - subRect.width - VIEWPORT_MARGIN); } let top = itemRect.top; if (top + subRect.height > vh - VIEWPORT_MARGIN) { top = vh - subRect.height - VIEWPORT_MARGIN; } top = Math.max(VIEWPORT_MARGIN, top); setSubMenuPos({ x: left, y: top }); }, [isOpen]); useEffect(() => { return () => clearTimeout(closeTimerRef.current ?? undefined); }, []); const handleMouseEnter = () => { if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; } setIsOpen(true); }; const handleMouseLeave = () => { closeTimerRef.current = setTimeout(() => { setIsOpen(false); }, 150); }; return (
{Icon && } {label}
{isOpen && (
{children}
)}
); } interface ContextMenuHeaderProps { children: React.ReactNode; } export function ContextMenuHeader({ children }: ContextMenuHeaderProps) { return (
{children}
); }