401 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|