"use client"; // UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts // und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall // identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden. // // Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der // drei Farbschemata und wirken wie ein Fremdkörper. import { createContext, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { AlertTriangle, CheckCircle2, X } from "lucide-react"; // --- ESC-Verwaltung ---------------------------------------------------------------------- // Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die // OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte. const escStack: (() => void)[] = []; let escListenerAttached = false; function ensureEscListener() { if (escListenerAttached || typeof window === "undefined") return; escListenerAttached = true; window.addEventListener("keydown", (e) => { if (e.key !== "Escape" || escStack.length === 0) return; e.stopPropagation(); escStack[escStack.length - 1](); }); } function useEscClose(onClose: () => void) { const closeRef = useRef(onClose); useEffect(() => { closeRef.current = onClose; }, [onClose]); useEffect(() => { ensureEscListener(); const handler = () => closeRef.current(); escStack.push(handler); return () => { const i = escStack.indexOf(handler); if (i >= 0) escStack.splice(i, 1); }; }, []); } // Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren, // Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück. function useFocusTrap(ref: React.RefObject) { useEffect(() => { const el = ref.current; if (!el) return; const previous = document.activeElement as HTMLElement | null; const focusables = () => [...el.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' )].filter((x) => !x.hasAttribute("disabled")); (focusables()[0] ?? el).focus({ preventScroll: true }); function onKey(e: KeyboardEvent) { if (e.key !== "Tab") return; const f = focusables(); if (f.length === 0) return; const first = f[0]; const last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } el.addEventListener("keydown", onKey); return () => { el.removeEventListener("keydown", onKey); previous?.focus?.({ preventScroll: true }); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); } // --- Button ------------------------------------------------------------------------------ type ButtonVariant = "primary" | "secondary" | "danger" | "ghost"; const BUTTON_CLASSES: Record = { primary: "bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]", secondary: "border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]", danger: "border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]", ghost: "text-muted hover:bg-surface-2 hover:text-fg", }; export function Button({ variant = "primary", size = "md", className = "", children, ...rest }: React.ButtonHTMLAttributes & { variant?: ButtonVariant; size?: "sm" | "md"; }) { const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm"; return ( ); } // --- Modal ------------------------------------------------------------------------------- export function Modal({ title, subtitle, onClose, children, wide, }: { title: string; subtitle?: string; onClose: () => void; children: React.ReactNode; wide?: boolean; }) { const panelRef = useRef(null); useEscClose(onClose); useFocusTrap(panelRef); return (
e.stopPropagation()} className={`ui-pop flex w-full ${wide ? "max-w-2xl" : "max-w-md"} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`} >

{title}

{subtitle &&

{subtitle}

}
{children}
); } // --- Inspector --------------------------------------------------------------------------- // Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil). // Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine // andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen. // Auf schmalen Screens deckt das Panel die volle Breite ab. export function InspectorShell({ title, subtitle, onClose, children, }: { title: string; subtitle?: string; onClose: () => void; children: React.ReactNode; }) { const panelRef = useRef(null); useEscClose(onClose); return ( ); } // --- Bestätigungs-Dialog (ersetzt window.confirm) --------------------------------------- interface ConfirmOptions { title: string; message: string; confirmLabel?: string; danger?: boolean; } const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise) | null>(null); export function useConfirm() { const fn = useContext(ConfirmContext); if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders"); return fn; } export function ConfirmProvider({ children }: { children: React.ReactNode }) { const [pending, setPending] = useState<{ opts: ConfirmOptions; resolve: (ok: boolean) => void; } | null>(null); const confirm = useCallback( (opts: ConfirmOptions) => new Promise((resolve) => setPending({ opts, resolve })), [] ); function settle(ok: boolean) { pending?.resolve(ok); setPending(null); } return ( {children} {pending && ( settle(false)}>

{pending.opts.message}

)}
); } // --- Toasts (ersetzt alert und stumme Erfolge) ------------------------------------------- interface ToastItem { id: number; kind: "success" | "error"; text: string; } const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null); export function useToast() { const fn = useContext(ToastContext); if (!fn) throw new Error("useToast ausserhalb des ToastProviders"); return fn; } export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]); const nextId = useRef(1); const push = useCallback((kind: ToastItem["kind"], text: string) => { const id = nextId.current++; setToasts((prev) => [...prev, { id, kind, text }]); // Fehler bleiben länger stehen als Erfolge. setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200); }, []); return ( {children}
{toasts.map((t) => (
{t.kind === "error" ? ( ) : ( )} {t.text}
))}
); } // --- Ladezustände und leere Zustände --------------------------------------------------- export function Skeleton({ className = "" }: { className?: string }) { return
; } // Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur // "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit). export function PlanSkeleton() { return (
); } export function EmptyState({ icon, title, text, children, }: { icon: React.ReactNode; title: string; text: string; children?: React.ReactNode; }) { return (
{icon}

{title}

{text}

{children &&
{children}
}
); }