2026-02-20 18:18:59 +01:00

401 lines
14 KiB
TypeScript

// 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<string | false | null | undefined>) {
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<HTMLDivElement | null>(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 (
<Transition show={open} as={Fragment}>
<Dialog open={open} as="div" className="relative z-50" onClose={onClose}>
{/* Backdrop */}
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500/75 dark:bg-gray-900/50" />
</Transition.Child>
{/* Modal Panel */}
<div className="fixed inset-0 z-50 overflow-hidden px-4 py-6 sm:px-6">
<div className="min-h-full flex items-start justify-center sm:items-center">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel
className={cn(
'relative w-full rounded-lg bg-white text-left shadow-xl transition-all',
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
// panel is a flex column so we can create a real scroll area
'flex flex-col min-h-0',
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
width
)}
>
{icon ? (
<div className="mx-auto mb-4 mt-6 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
{icon}
</div>
) : null}
{/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
<div
className={cn(
'shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 items-start justify-between gap-3',
layout === 'split' ? 'hidden lg:flex' : 'flex'
)}
>
<div className="min-w-0">
{title ? (
<Dialog.Title className="hidden sm:block text-base font-semibold text-gray-900 dark:text-white truncate">
{title}
</Dialog.Title>
) : null}
</div>
<button
type="button"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div>
{/* Body */}
{layout === 'single' ? (
<div
className={cn(
'flex-1 min-h-0 h-full',
scrollMode === 'body'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightClassName
)}
>
{children}
</div>
) : (
// split layout
<div
className={cn(
'px-2 pb-4 pt-3 sm:px-4 sm:pb-6 sm:pt-4',
'flex-1 min-h-0',
'overflow-hidden',
'flex flex-col',
bodyClassName
)}
>
{/* ========================= */}
{/* MOBILE: stacked (no split) */}
{/* ========================= */}
<div
ref={mobileScrollRef}
className={cn(
'lg:hidden flex-1 min-h-0 relative',
// auf Mobile: EIN Scrollcontainer für alles
(scrollMode === 'right' || scrollMode === 'body') ? 'overflow-y-auto overscroll-contain' : 'overflow-hidden'
)}
>
{/* Sticky top area: app bar (shrinks) + left (collapses) + tabs/actions (sticky) */}
<div
className={cn(
'sticky top-0 z-50',
'bg-white/95 backdrop-blur dark:bg-gray-800/95',
'border-b border-gray-200/70 dark:border-white/10'
)}
>
{/* App bar (always visible, shrinks when collapsed) */}
<div
className={cn(
'flex items-center justify-between gap-3 px-3',
mobileCollapsed ? 'py-2' : 'py-3'
)}
>
<div className="min-w-0 flex items-center gap-2">
{mobileCollapsedImageSrc ? (
<img
src={mobileCollapsedImageSrc}
alt={mobileCollapsedImageAlt || title || ''}
className={cn(
'shrink-0 rounded-lg object-cover ring-1 ring-black/5 dark:ring-white/10',
mobileCollapsed ? 'size-8' : 'size-10'
)}
loading="lazy"
decoding="async"
/>
) : null}
<div className="min-w-0">
{title ? (
<div
className={cn(
'truncate font-semibold text-gray-900 dark:text-white',
mobileCollapsed ? 'text-sm' : 'text-base'
)}
>
{title}
</div>
) : null}
</div>
</div>
<button
type="button"
onClick={onClose}
className={cn(
'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5',
'text-gray-500 hover:text-gray-900 hover:bg-black/5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 dark:focus-visible:outline-indigo-500'
)}
aria-label="Schließen"
title="Schließen"
>
<XMarkIcon className="size-5" />
</button>
</div>
{/* Sticky tabs/actions (always sticky because in this sticky wrapper) */}
{rightHeader ? <div>{rightHeader}</div> : null}
</div>
{/* LEFT content on mobile (scrolls away, not sticky) */}
{left ? (
<div className={cn('lg:hidden px-2 pb-2', leftClassName)}>
{left}
</div>
) : null}
{/* Body (only the right content) */}
<div className={cn('px-2 pt-0 min-h-0', rightClassName)}>
<div className={cn('min-h-0', rightBodyClassName)}>{children}</div>
</div>
</div>
{/* ========================= */}
{/* DESKTOP: real split layout */}
{/* ========================= */}
<div className="hidden lg:flex flex-1 min-h-0 gap-3">
{/* LEFT (fixed) */}
<div
className={cn(
'min-h-0',
leftWidthClass,
'shrink-0',
'overflow-hidden',
leftClassName
)}
>
{left}
</div>
{/* RIGHT */}
<div className={cn('flex-1 min-h-0 flex flex-col', rightClassName)}>
{rightHeader ? <div className="shrink-0">{rightHeader}</div> : null}
<div
className={cn(
'flex-1 min-h-0',
scrollMode === 'right'
? 'overflow-y-auto overscroll-contain'
: 'overflow-hidden',
rightBodyClassName
)}
>
{children}
</div>
</div>
</div>
</div>
)}
{/* Footer */}
{footer ? (
<div className="shrink-0 px-6 py-4 border-t border-gray-200/70 dark:border-white/10 flex justify-end gap-3">
{footer}
</div>
) : null}
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}