summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/SidebarContextMenu.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/SidebarContextMenu.tsx')
-rw-r--r--makima/frontend/src/components/SidebarContextMenu.tsx108
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>
+ );
+}