diff --git a/packages/web/src/components/ui/ContextMenuRenderer.tsx b/packages/web/src/components/ui/ContextMenuRenderer.tsx new file mode 100644 index 00000000..456f92d8 --- /dev/null +++ b/packages/web/src/components/ui/ContextMenuRenderer.tsx @@ -0,0 +1,552 @@ +import React, { useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + useContextMenuStore, + filterMenuItems, + type ContextMenuItem, + type ContextMenuLeafItem, + type ContextMenuSubmenu, +} from '../../stores/contextMenuStore'; +import { useUIStore } from '../../stores/uiStore'; + +// ── Desktop item button ────────────────────────────────────────────────────── + +const ITEM_CLASS = + 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white'; +const ITEM_DANGER_CLASS = + 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-danger hover:bg-accent-rose hover:text-white'; +const ITEM_DISABLED_CLASS = + 'w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary opacity-50 cursor-default'; +const ITEM_STYLE: React.CSSProperties = { width: 'calc(100% - 12px)' }; + +// ── Mobile item button ─────────────────────────────────────────────────────── + +const MOBILE_ITEM_CLASS = 'w-full text-left px-5 py-3 text-sm flex items-center gap-3'; + +// ── Checkbox indicator ─────────────────────────────────────────────────────── + +function CheckboxIndicator({ checked }: { checked: boolean }) { + return ( +
+ {checked && ( + + + + )} +
+ ); +} + +// ── Desktop submenu flyout ─────────────────────────────────────────────────── + +interface SubmenuFlyoutProps { + submenu: ContextMenuSubmenu; + triggerRef: React.RefObject; + onMouseEnter: () => void; + onMouseLeave: () => void; + close: () => void; +} + +function SubmenuFlyout({ submenu, triggerRef, onMouseEnter, onMouseLeave, close }: SubmenuFlyoutProps) { + const flyoutRef = useRef(null); + const filteredChildren = filterMenuItems(submenu.children); + + useLayoutEffect(() => { + const flyout = flyoutRef.current; + const trigger = triggerRef.current; + if (!flyout || !trigger) return; + + const tRect = trigger.getBoundingClientRect(); + const fRect = flyout.getBoundingClientRect(); + const gap = 4; + + let left = tRect.right + gap; + if (left + fRect.width > window.innerWidth) { + left = tRect.left - fRect.width - gap; + } + if (left < 8) left = 8; + + let top = tRect.top; + if (top + fRect.height > window.innerHeight - 8) { + top = window.innerHeight - fRect.height - 8; + } + if (top < 8) top = 8; + + flyout.style.left = `${left}px`; + flyout.style.top = `${top}px`; + }, [triggerRef]); + + if (filteredChildren.length === 0) return null; + + return createPortal( +
e.stopPropagation()} + > + {filteredChildren.map((child) => ( + + ))} +
, + document.body, + ); +} + +// ── Desktop leaf item ──────────────────────────────────────────────────────── + +interface DesktopLeafItemProps { + item: ContextMenuLeafItem; + close: () => void; +} + +function DesktopLeafItem({ item, close }: DesktopLeafItemProps) { + switch (item.type) { + case 'separator': + return
; + + case 'custom': + return
{item.render()}
; + + case 'checkbox': + return ( + + ); + + case 'action': { + const className = item.disabled + ? ITEM_DISABLED_CLASS + : item.danger + ? ITEM_DANGER_CLASS + : ITEM_CLASS; + return ( + + ); + } + } +} + +// ── Desktop submenu trigger item ───────────────────────────────────────────── + +interface DesktopSubmenuItemProps { + item: ContextMenuSubmenu; + close: () => void; +} + +function DesktopSubmenuItem({ item, close }: DesktopSubmenuItemProps) { + const triggerRef = useRef(null); + const closeTimer = useRef | null>(null); + const openTimer = useRef | null>(null); + const openSubmenuKey = useContextMenuStore((s) => s.openSubmenuKey); + const setOpenSubmenu = useContextMenuStore((s) => s.setOpenSubmenu); + const isOpen = openSubmenuKey === item.key; + + const cancelTimers = useCallback(() => { + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + if (openTimer.current) { + clearTimeout(openTimer.current); + openTimer.current = null; + } + }, []); + + const handleTriggerEnter = useCallback(() => { + cancelTimers(); + openTimer.current = setTimeout(() => { + setOpenSubmenu(item.key); + }, 150); + }, [cancelTimers, setOpenSubmenu, item.key]); + + const handleTriggerLeave = useCallback(() => { + cancelTimers(); + closeTimer.current = setTimeout(() => { + setOpenSubmenu(null); + }, 150); + }, [cancelTimers, setOpenSubmenu]); + + const handleFlyoutEnter = useCallback(() => { + cancelTimers(); + }, [cancelTimers]); + + const handleFlyoutLeave = useCallback(() => { + cancelTimers(); + closeTimer.current = setTimeout(() => { + setOpenSubmenu(null); + }, 150); + }, [cancelTimers, setOpenSubmenu]); + + useEffect(() => { + return () => { + cancelTimers(); + }; + }, [cancelTimers]); + + return ( + <> + + {isOpen && ( + + )} + + ); +} + +// ── Desktop menu item dispatcher ───────────────────────────────────────────── + +interface DesktopMenuItemProps { + item: ContextMenuItem; + close: () => void; +} + +function DesktopMenuItem({ item, close }: DesktopMenuItemProps) { + if (item.type === 'submenu') { + return ; + } + return ; +} + +// ── Desktop menu panel ─────────────────────────────────────────────────────── + +interface DesktopMenuProps { + items: ContextMenuItem[]; + position: { x: number; y: number }; + close: () => void; + closeGuard: boolean; +} + +function DesktopMenu({ items, position, close, closeGuard }: DesktopMenuProps) { + const menuRef = useRef(null); + + // Viewport-aware positioning via direct DOM mutation + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + let x = position.x; + let y = position.y; + if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8; + if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8; + if (x < 8) x = 8; + if (y < 8) y = 8; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + }, [position]); + + // Auto-focus on mount for keyboard nav + useEffect(() => { + menuRef.current?.focus(); + }, []); + + // Dismiss on scroll/resize + useEffect(() => { + const dismiss = () => close(); + window.addEventListener('scroll', dismiss, true); + window.addEventListener('resize', dismiss); + return () => { + window.removeEventListener('scroll', dismiss, true); + window.removeEventListener('resize', dismiss); + }; + }, [close]); + + // Keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const container = menuRef.current; + if (!container) return; + + const focusableSelector = 'button:not([disabled])'; + + if (e.key === 'Escape') { + e.preventDefault(); + // Close submenu first if open, otherwise close the whole menu + const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey; + if (openSubmenuKey) { + useContextMenuStore.getState().setOpenSubmenu(null); + } else { + close(); + } + return; + } + + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const buttons = Array.from(container.querySelectorAll(focusableSelector)); + if (buttons.length === 0) return; + const currentIndex = buttons.indexOf(document.activeElement as HTMLElement); + let nextIndex: number; + if (e.key === 'ArrowDown') { + nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % buttons.length; + } else { + nextIndex = currentIndex <= 0 ? buttons.length - 1 : currentIndex - 1; + } + buttons[nextIndex]?.focus(); + return; + } + + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const focused = document.activeElement; + if (focused instanceof HTMLButtonElement && container.contains(focused)) { + focused.click(); + } + return; + } + + if (e.key === 'ArrowRight') { + // Open submenu if focused item is a submenu trigger + e.preventDefault(); + const focused = document.activeElement; + if (focused instanceof HTMLButtonElement) { + // Simulate mouse enter to open the submenu + focused.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + } + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const openSubmenuKey = useContextMenuStore.getState().openSubmenuKey; + if (openSubmenuKey) { + useContextMenuStore.getState().setOpenSubmenu(null); + } + return; + } + }, + [close], + ); + + return createPortal( + <> + {/* Backdrop */} +
{ + if (!closeGuard) { + close(); + } + }} + onContextMenu={(e) => { + // Prevent browser context menu but do NOT stopPropagation. + // This allows right-clicks to reach underlying elements that may + // open a new context menu via their own onContextMenu handler. + e.preventDefault(); + }} + /> + {/* Menu panel */} +
e.stopPropagation()} + > + {items.map((item) => ( + + ))} +
+ , + document.body, + ); +} + +// ── Mobile leaf item ───────────────────────────────────────────────────────── + +interface MobileLeafItemProps { + item: ContextMenuLeafItem; + close: () => void; +} + +function MobileLeafItem({ item, close }: MobileLeafItemProps) { + switch (item.type) { + case 'separator': + return
; + + case 'custom': + return
{item.render()}
; + + case 'checkbox': + return ( + + ); + + case 'action': { + const colorClass = item.danger ? 'text-txt-danger' : 'text-txt-primary'; + return ( + + ); + } + } +} + +// ── Mobile bottom sheet menu ───────────────────────────────────────────────── + +interface MobileMenuProps { + items: ContextMenuItem[]; + close: () => void; +} + +function MobileMenu({ items, close }: MobileMenuProps) { + const [submenuStack, setSubmenuStack] = useState(null); + + // Dismiss on scroll/resize + useEffect(() => { + const dismiss = () => close(); + window.addEventListener('scroll', dismiss, true); + window.addEventListener('resize', dismiss); + return () => { + window.removeEventListener('scroll', dismiss, true); + window.removeEventListener('resize', dismiss); + }; + }, [close]); + + // Determine what items to render: root or submenu + const currentItems: ReadonlyArray = submenuStack + ? filterMenuItems(submenuStack.children) + : items; + + return createPortal( + <> + {/* Backdrop */} +
+ {/* Bottom sheet */} +
+
+ {/* Back button for submenu */} + {submenuStack && ( + + )} +
+ {currentItems.map((item) => { + if (item.type === 'submenu') { + // Render submenu trigger as a button that switches sheet content + const colorClass = 'text-txt-primary'; + return ( + + ); + } + return ; + })} +
+
+ , + document.body, + ); +} + +// ── Main renderer ──────────────────────────────────────────────────────────── + +export function ContextMenuRenderer() { + const menu = useContextMenuStore((s) => s.menu); + const close = useContextMenuStore((s) => s.close); + const closeGuard = useContextMenuStore((s) => s.closeGuard); + const isMobile = useUIStore((s) => s.isMobile); + + if (!menu) return null; + + const filteredItems = filterMenuItems(menu.items); + if (filteredItems.length === 0) return null; + + if (isMobile) { + return ; + } + + return ( + + ); +}