// 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