/** * Menus — counterpart of vcl/source/window/menu.cxx (MenuBar, PopupMenu). * Pure presentation: entries arrive already resolved (labels, state); the * command binding lives in framework's MenuBarManager. * * Menus swallow mousedown so the document keeps focus and selection while * they are used, like VCL's floating windows. */ import { Check, ChevronRight } from "lucide-react"; import { useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; export type VclMenuEntry = | { kind: "separator" } | { kind: "item"; id: string; label: string; shortcut?: string; icon?: ReactNode; enabled: boolean; checked?: boolean; submenu?: VclMenuEntry[]; }; const keepFocus = (e: ReactMouseEvent) => e.preventDefault(); interface MenuPopupProps { entries: VclMenuEntry[]; x: number; y: number; onSelect: (id: string) => void; } function MenuPopup({ entries, x, y, onSelect }: MenuPopupProps) { const ref = useRef(null); const [place, setPlace] = useState({ x, y }); const [openSub, setOpenSub] = useState<{ index: number; x: number; y: number } | null>(null); // Keep the popup on screen (FloatingWindow::ImplCalcPos). useLayoutEffect(() => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); setPlace({ x: Math.max(0, Math.min(x, window.innerWidth - r.width - 4)), y: Math.max(0, Math.min(y, window.innerHeight - r.height - 4)), }); }, [x, y]); return (
{entries.map((entry, i) => { if (entry.kind === "separator") return
; const open = openSub?.index === i; return (
{open && entry.submenu && ( )}
); })}
); } /** Closes on mousedown outside any menu, or on Escape. */ function useDismiss(active: boolean, onClose: () => void, inside: (t: Element) => boolean) { const close = useRef(onClose); close.current = onClose; useEffect(() => { if (!active) return; const onDown = (e: MouseEvent) => { const t = e.target as Element | null; if (!t || !inside(t)) close.current(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); close.current(); } }; const onBlur = () => close.current(); document.addEventListener("mousedown", onDown, true); document.addEventListener("keydown", onKey, true); window.addEventListener("blur", onBlur); return () => { document.removeEventListener("mousedown", onDown, true); document.removeEventListener("keydown", onKey, true); window.removeEventListener("blur", onBlur); }; }, [active, inside]); } const insideMenu = (t: Element) => !!t.closest(".vcl-menu, .vcl-menubar"); export interface VclMenuBarMenu { id: string; label: string; entries: () => VclMenuEntry[]; } export function MenuBar({ menus, onSelect }: { menus: VclMenuBarMenu[]; onSelect: (id: string) => void }) { const [open, setOpen] = useState<{ index: number; x: number; y: number } | null>(null); useDismiss(open !== null, () => setOpen(null), insideMenu); const openAt = (index: number, el: HTMLElement) => { const r = el.getBoundingClientRect(); setOpen({ index, x: r.left, y: r.bottom + 1 }); }; return (
{menus.map((m, i) => ( ))} {open && ( { setOpen(null); onSelect(id); }} /> )}
); } /** A context menu (PopupMenu::Execute). */ export function PopupMenu({ entries, x, y, onSelect, onClose, }: MenuPopupProps & { onClose: () => void }) { useDismiss(true, onClose, insideMenu); return ( { onClose(); onSelect(id); }} /> ); }