// frontend\src\components\ui\Modal.tsx 'use client' import { Fragment, type ReactNode, useEffect, useRef, useState } from 'react' import { Dialog, Transition } from '@headlessui/react' import { XMarkIcon } from '@heroicons/react/24/outline' type ModalLayout = 'single' | 'split' type ModalScroll = 'body' | 'right' | 'none' type ModalProps = { open: boolean onClose: () => void title?: string children?: ReactNode footer?: ReactNode icon?: ReactNode /** * Tailwind max-width Klasse für Dialog.Panel, z.B.: * "max-w-lg" (default), "max-w-2xl", "max-w-4xl", "max-w-5xl" */ width?: string /** * Layout: * - single: klassisches Modal (ein Content-Bereich) * - split: 2 Spalten (links fix, rechts content) */ layout?: ModalLayout /** * Split-Layout: linker Inhalt (fixe Spalte) */ left?: ReactNode /** * Split-Layout: Breite der linken Spalte (Tailwind). * Default: "lg:w-80" (320px) */ leftWidthClass?: string /** * Scroll-Verhalten: * - body: der Body-Bereich scrollt (bei single default) * - right: nur rechte Spalte scrollt (bei split default) * - none: kein Scroll (nur sinnvoll wenn Inhalt garantiert passt) */ scroll?: ModalScroll /** * Optional: Zusatzklassen */ bodyClassName?: string leftClassName?: string rightClassName?: string /** * Split-Layout: Header über dem scrollbaren rechten Bereich * (z.B. Tabs/Actions) */ rightHeader?: ReactNode /** * Optional: Zusatzklassen nur für den scrollbaren RIGHT-BODY */ rightBodyClassName?: string /** Optional: kleines Bild im mobilen collapsed Header */ mobileCollapsedImageSrc?: string mobileCollapsedImageAlt?: string } function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } export default function Modal({ open, onClose, title, children, footer, icon, width = 'max-w-lg', layout = 'single', left, leftWidthClass = 'lg:w-80', scroll, bodyClassName, leftClassName, rightClassName, rightHeader, rightBodyClassName, mobileCollapsedImageSrc, mobileCollapsedImageAlt, }: ModalProps) { // sensible defaults const scrollMode: ModalScroll = scroll ?? (layout === 'split' ? 'right' : 'body') // --- mobile collapse-on-scroll (only used in split+mobile stacked) --- const mobileScrollRef = useRef(null) const [mobileCollapsed, setMobileCollapsed] = useState(false) useEffect(() => { if (!open) return const html = document.documentElement const body = document.body const prevHtmlOverflow = html.style.overflow const prevBodyOverflow = body.style.overflow const prevBodyPaddingRight = body.style.paddingRight // verhindert Layout-Shift wenn Scrollbar verschwindet const scrollBarWidth = window.innerWidth - html.clientWidth html.style.overflow = 'hidden' body.style.overflow = 'hidden' if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px` return () => { html.style.overflow = prevHtmlOverflow body.style.overflow = prevBodyOverflow body.style.paddingRight = prevBodyPaddingRight } }, [open]) useEffect(() => { if (!open) return // reset when opening setMobileCollapsed(false) }, [open]) useEffect(() => { if (!open) return const el = mobileScrollRef.current if (!el) return const THRESHOLD = 72 // px, ab wann "kompakt" wird const onScroll = () => { const y = el.scrollTop || 0 // nur updaten wenn sich der boolean ändert (verhindert re-render spam) setMobileCollapsed((prev) => { const next = y > THRESHOLD return prev === next ? prev : next }) } // initial onScroll() el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll as any) }, [open]) return ( {/* Backdrop */}
{/* Modal Panel */}
{icon ? (
{icon}
) : null} {/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
{title ? ( {title} ) : null}
{/* Body */} {layout === 'single' ? (
{children}
) : ( // split layout
{/* ========================= */} {/* MOBILE: stacked (no split) */} {/* ========================= */}
{/* Sticky top area: app bar (shrinks) + left (collapses) + tabs/actions (sticky) */}
{/* App bar (always visible, shrinks when collapsed) */}
{mobileCollapsedImageSrc ? ( {mobileCollapsedImageAlt ) : null}
{title ? (
{title}
) : null}
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */} {rightHeader ?
{rightHeader}
: null}
{/* LEFT content on mobile (scrolls away, not sticky) */} {left ? (
{left}
) : null} {/* Body (only the right content) */}
{children}
{/* ========================= */} {/* DESKTOP: real split layout */} {/* ========================= */}
{/* LEFT (fixed) */}
{left}
{/* RIGHT */}
{rightHeader ?
{rightHeader}
: null}
{children}
)} {/* Footer */} {footer ? (
{footer}
) : null}
) }