'use client'; /** * Gemeinsamer Modal-Rahmen fuer alle Dialoge der App. * * Schliessen moeglich via: * - X-Button (immer, auch waehrend einer laufenden Aktion) * - ESC-Taste (ausser waehrend isPending, um Abbrueche zu vermeiden) * - Klick auf den dunklen Hintergrund (ausser waehrend isPending) * * Alle Modals der App MUESSEN diesen Rahmen verwenden, damit das * Schliess-Verhalten einheitlich ist (DRY). */ import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; /** Verfuegbare Modal-Groessen. */ export type ModalSize = 'md' | 'lg' | 'xl'; const SIZE_CLASSES: Record = { md: 'max-w-md', lg: 'max-w-2xl', xl: 'max-w-5xl', }; export function ModalShell({ title, ariaLabel, isPending = false, size = 'md', onClose, children, }: { title: string; /** Accessibility-Beschriftung (faellt auf title zurueck). */ ariaLabel?: string; /** Blockiert ESC/Backdrop-Schliessen waehrend einer laufenden Aktion. */ isPending?: boolean; size?: ModalSize; onClose: () => void; children: React.ReactNode; }) { // ESC schliesst das Modal. Der Listener wird bei jedem Render von // onClose/isPending neu registriert (guenstig und immer aktuell). useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape' && !isPending) { onClose(); } } document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isPending, onClose]); // Merkt sich, ob der mousedown auf dem Backdrop (ausserhalb des // Modal-Inhalts) begann. Nur dann ist ein click ein echtes // "Klick ausserhalb". Grund: Bei einer Textauswahl, die IM Modal // beginnt und ausserhalb endet, feuert der Browser das click-Event // auf dem gemeinsamen Vorfahren beider Ziele - dem Backdrop. Ohne // diese Pruefung wuerde das Modal beim Markieren von Text // fälschlich geschlossen. const mouseDownOnBackdropRef = useRef(false); // Portal: Das Modal wird auf document.body gerendert, nicht innerhalb // der aufrufenden Komponente. Grund: Wird ein Modal z. B. in der // Sidebar (sticky, eigener Stacking-Kontext) gerendert, wuerde sein // z-50 nur innerhalb der Sidebar gelten - Hauptinhalte daneben // wuerden es überlappen. Auf body-Ebene gilt z-50 global. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); if (!mounted) return null; return createPortal(
{ mouseDownOnBackdropRef.current = event.target === event.currentTarget; }} onClick={(event) => { if ( event.target === event.currentTarget && mouseDownOnBackdropRef.current && !isPending ) { mouseDownOnBackdropRef.current = false; onClose(); } }} >

{title}

{children}
, document.body, ); } /** X-Icon (2D, Outline-Stil) zum Schliessen des Modals. */ function XIcon() { return ( ); }