diff options
Diffstat (limited to 'makima/frontend/src/components/SidebarContextMenu.tsx')
| -rw-r--r-- | makima/frontend/src/components/SidebarContextMenu.tsx | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/makima/frontend/src/components/SidebarContextMenu.tsx b/makima/frontend/src/components/SidebarContextMenu.tsx new file mode 100644 index 0000000..a7b0ae6 --- /dev/null +++ b/makima/frontend/src/components/SidebarContextMenu.tsx @@ -0,0 +1,108 @@ +import { useEffect, useRef } from "react"; + +// Generic right-click context menu for the sidebar tree. Each call site +// (directive folder, contract row, step row, task row, …) builds its own +// items array. Lifts the viewport-clamping + click-outside / Esc logic +// out of the deleted DirectiveContextMenu so we don't end up with one +// component per entity type. + +export interface ContextMenuItem { + /** Display text. Empty for separators. */ + label: string; + /** Click handler. Ignored when `separator` is true. */ + onClick?: () => void; + /** Render in red — for destructive operations (delete, archive). */ + danger?: boolean; + /** Render as a thin divider instead of a button. */ + separator?: boolean; + /** Greyed out, non-clickable. Used for items whose preconditions + * aren't met (e.g. "Reopen" on an already-draft contract). */ + disabled?: boolean; +} + +interface SidebarContextMenuProps { + x: number; + y: number; + items: ContextMenuItem[]; + onClose: () => void; +} + +export function SidebarContextMenu({ x, y, items, onClose }: SidebarContextMenuProps) { + const menuRef = useRef<HTMLDivElement>(null); + + // Close on click outside or Esc. + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + // Clamp into viewport if the menu would overflow off the right or + // bottom edge. + useEffect(() => { + if (menuRef.current) { + const rect = menuRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + if (rect.right > viewportWidth) { + menuRef.current.style.left = `${x - rect.width}px`; + } + if (rect.bottom > viewportHeight) { + menuRef.current.style.top = `${y - rect.height}px`; + } + } + }, [x, y]); + + const baseItemClass = + "w-full px-3 py-1.5 text-left text-xs font-mono flex items-center gap-2"; + const enabledClass = "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"; + const dangerClass = "text-red-400 hover:bg-[rgba(239,68,68,0.1)]"; + const disabledClass = "text-[#3a4a6a] cursor-not-allowed"; + const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1"; + + return ( + <div + ref={menuRef} + className="fixed z-50 min-w-[180px] bg-[#0a1628] border border-[rgba(117,170,252,0.3)] rounded shadow-lg py-1" + style={{ left: x, top: y }} + onContextMenu={(e) => e.preventDefault()} + > + {items.map((item, i) => { + if (item.separator) { + return <div key={`sep-${i}`} className={dividerClass} />; + } + const cls = item.disabled + ? disabledClass + : item.danger + ? dangerClass + : enabledClass; + return ( + <button + key={`item-${i}-${item.label}`} + type="button" + disabled={item.disabled} + onClick={() => { + if (item.disabled) return; + item.onClick?.(); + onClose(); + }} + className={`${baseItemClass} ${cls}`} + > + {item.label} + </button> + ); + })} + </div> + ); +} |
