// Generisches, wiederverwendbares Kontextmenü — 1:1 nach DOSSIER // (docs/design/context-menu.md, Quelle src/components/ContextMenu.jsx). // // Reine Darstellung + Callbacks: der Aufrufer hält die offene Position (x/y in // Viewport-Pixeln), die Item-Liste und reagiert über `onClose`. Das Menü selbst // kümmert sich nur um Positionierung (Fixed + Rand-Clamp), das Schließen // (Außenklick · Escape · erneuter Rechtsklick · Scroll · nach Item-Klick) und // die Darstellung der Items (Icon, Shortcut, danger, divider, disabled). // // Gerendert über ein Portal in , damit es über allem liegt und nicht vom // overflow/transform eines Panels abgeschnitten wird (z-index ~300, unter // Modals). Bezeichner englisch, UI-Text/Kommentare deutsch (CONVENTIONS.md). import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ReactNode } from "react"; /** * Ein Eintrag im Kontextmenü. Ein reiner Trenner setzt nur `divider: true` * (alle anderen Felder werden dann ignoriert). Sonst ist `label` Pflicht und * `onClick` die ausgelöste Aktion. */ export interface ContextMenuItem { /** Sichtbarer Text (deutsch). Bei `divider` ungenutzt. */ label?: string; /** Optionales Icon (z. B. Material-Symbol-Name oder eigener Knoten). */ icon?: ReactNode; /** Aktion beim Klick. Wird nur ausgeführt, wenn nicht `disabled`. */ onClick?: () => void; /** Ausgegraut, nicht klickbar. */ disabled?: boolean; /** Rote Schrift (destruktive Aktion, z. B. Löschen). */ danger?: boolean; /** Optionales Tastenkürzel rechtsbündig (nur Anzeige). */ shortcut?: string; /** * Optionaler Tooltip (title) — vor allem, um bei `disabled` den Grund zu * nennen (z. B. „keine Auswahl"). Reine Anzeige. */ title?: string; /** Reiner Trenner (1px Linie); ignoriert alle anderen Felder. */ divider?: boolean; } export interface ContextMenuProps { /** Ankerposition in Viewport-Pixeln (clientX). */ x: number; /** Ankerposition in Viewport-Pixeln (clientY). */ y: number; /** Die Einträge in Anzeigereihenfolge. */ items: ContextMenuItem[]; /** Schließen-Callback (Außenklick · Esc · Scroll · nach Item-Klick). */ onClose: () => void; /** Optionaler Titel oben (caps, gedämpft) — z. B. Ebenenname/-code. */ title?: string; } /** Rand-Abstand zum Viewport beim Clampen (px). */ const EDGE_PAD = 4; /** * Material-Symbols-Icon (DOSSIER nutzt die Icon-Font). Fällt auf reinen Text * zurück; ist die Font nicht geladen, bleibt der Slot via min-width leer * ausgerichtet, sodass die Labels trotzdem fluchten. */ export function MenuIcon({ name }: { name: string }) { return ( ); } export function ContextMenu({ x, y, items, onClose, title }: ContextMenuProps) { const ref = useRef(null); // Tatsächliche Position nach dem Clampen an die Viewport-Ränder. Bis das Menü // gemessen ist, am Anker zeigen (kein Sprung — useLayoutEffect korrigiert vor // dem Paint). const [pos, setPos] = useState({ x, y }); // Nach dem Mount messen und so verschieben, dass das Menü vollständig im // Viewport bleibt (rechter/unterer Rand → nach links/oben klappen). useLayoutEffect(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let nx = x; let ny = y; if (nx + rect.width > vw - EDGE_PAD) nx = vw - rect.width - EDGE_PAD; if (ny + rect.height > vh - EDGE_PAD) ny = vh - rect.height - EDGE_PAD; if (nx < EDGE_PAD) nx = EDGE_PAD; if (ny < EDGE_PAD) ny = EDGE_PAD; setPos({ x: nx, y: ny }); }, [x, y, items]); // Schließen bei: Klick/Rechtsklick außerhalb · Escape · Scroll · Resize. // pointerdown (capture) fängt auch den Beginn eines erneuten Rechtsklicks. useEffect(() => { const onPointerDown = (e: PointerEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; const onScrollOrResize = () => onClose(); // capture: vor anderen Handlern; passive scroll reicht (wir reagieren nur). window.addEventListener("pointerdown", onPointerDown, true); window.addEventListener("keydown", onKey); window.addEventListener("scroll", onScrollOrResize, true); window.addEventListener("resize", onScrollOrResize); return () => { window.removeEventListener("pointerdown", onPointerDown, true); window.removeEventListener("keydown", onKey); window.removeEventListener("scroll", onScrollOrResize, true); window.removeEventListener("resize", onScrollOrResize); }; }, [onClose]); const onItemClick = (item: ContextMenuItem) => { if (item.disabled || item.divider) return; item.onClick?.(); onClose(); // Auto-Close nach Item-Klick. }; const menu = (
e.preventDefault()} > {title &&
{title}
} {items.map((item, i) => item.divider ? (
) : ( ), )}
); return createPortal(menu, document.body); }