333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
// components/ui/Modal.tsx
|
||
'use client';
|
||
|
||
import * as React from 'react';
|
||
import {
|
||
Dialog,
|
||
DialogBackdrop,
|
||
DialogPanel,
|
||
DialogTitle,
|
||
} from '@headlessui/react';
|
||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||
import clsx from 'clsx';
|
||
import Button from './Button';
|
||
|
||
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
||
export type ModalVariant = 'centered' | 'alert';
|
||
export type ModalSize = 'sm' | 'md' | 'lg' | 'xl';
|
||
|
||
export interface ModalAction {
|
||
label: string;
|
||
onClick?: () => void;
|
||
variant?: 'primary' | 'secondary' | 'danger';
|
||
autoFocus?: boolean;
|
||
}
|
||
|
||
export interface ModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
|
||
title?: React.ReactNode;
|
||
description?: React.ReactNode;
|
||
children?: React.ReactNode;
|
||
|
||
/** Icon (z.B. <CheckIcon /> oder <ExclamationTriangleIcon />) */
|
||
icon?: React.ReactNode;
|
||
/** Steuert Icon-Farben/Hintergrund */
|
||
tone?: ModalTone;
|
||
|
||
/** Layout: "centered" (Payment successful) oder "alert" (Deactivate account) */
|
||
variant?: ModalVariant;
|
||
/** Breite des Dialogs */
|
||
size?: ModalSize;
|
||
|
||
/** X-Button in der Ecke rechts oben */
|
||
showCloseButton?: boolean;
|
||
|
||
/** Standard-Buttons im Footer */
|
||
primaryAction?: ModalAction;
|
||
secondaryAction?: ModalAction;
|
||
|
||
/** Grauer Footer-Bereich wie im letzten Beispiel */
|
||
useGrayFooter?: boolean;
|
||
|
||
/**
|
||
* Wenn gesetzt, wird dieser Footer anstelle der auto-generierten Buttons gerendert.
|
||
*/
|
||
footer?: React.ReactNode;
|
||
|
||
/**
|
||
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
|
||
*/
|
||
sidebar?: React.ReactNode;
|
||
|
||
/**
|
||
* Zusätzlicher Inhalt direkt UNTER dem Titel im Header (z.B. Tabs).
|
||
* Liegt außerhalb des scrollbaren Body-Bereichs.
|
||
*/
|
||
headerExtras?: React.ReactNode;
|
||
}
|
||
|
||
/* ───────── Layout-Helfer ───────── */
|
||
|
||
const toneStyles: Record<
|
||
ModalTone,
|
||
{ iconBg: string; iconColor: string }
|
||
> = {
|
||
default: {
|
||
iconBg: 'bg-gray-100 dark:bg-gray-500/10',
|
||
iconColor: 'text-gray-600 dark:text-gray-400',
|
||
},
|
||
success: {
|
||
iconBg: 'bg-green-100 dark:bg-green-500/10',
|
||
iconColor: 'text-green-600 dark:text-green-400',
|
||
},
|
||
danger: {
|
||
iconBg: 'bg-red-100 dark:bg-red-500/10',
|
||
iconColor: 'text-red-600 dark:text-red-400',
|
||
},
|
||
warning: {
|
||
iconBg: 'bg-yellow-100 dark:bg-yellow-500/10',
|
||
iconColor: 'text-yellow-600 dark:text-yellow-400',
|
||
},
|
||
info: {
|
||
iconBg: 'bg-sky-100 dark:bg-sky-500/10',
|
||
iconColor: 'text-sky-600 dark:text-sky-400',
|
||
},
|
||
};
|
||
|
||
const sizeClasses: Record<ModalSize, string> = {
|
||
sm: 'sm:max-w-sm',
|
||
md: 'sm:max-w-lg',
|
||
lg: 'sm:max-w-3xl',
|
||
xl: 'sm:max-w-5xl',
|
||
};
|
||
|
||
function renderActionButton(
|
||
action: ModalAction,
|
||
extraClasses?: string,
|
||
): React.ReactNode {
|
||
const variant = action.variant ?? 'primary';
|
||
|
||
let buttonVariant: 'primary' | 'secondary' | 'soft' = 'primary';
|
||
let tone: 'indigo' | 'gray' | 'rose' = 'indigo';
|
||
|
||
if (variant === 'secondary') {
|
||
buttonVariant = 'secondary';
|
||
tone = 'gray';
|
||
} else if (variant === 'danger') {
|
||
buttonVariant = 'primary';
|
||
tone = 'rose';
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
type="button"
|
||
onClick={action.onClick}
|
||
autoFocus={action.autoFocus}
|
||
variant={buttonVariant}
|
||
tone={tone}
|
||
size="lg"
|
||
className={clsx('w-full', extraClasses)}
|
||
>
|
||
{action.label}
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
/* ───────── Modal-Komponente ───────── */
|
||
|
||
export function Modal({
|
||
open,
|
||
onClose,
|
||
title,
|
||
description,
|
||
children,
|
||
icon,
|
||
tone = 'default',
|
||
variant = 'centered',
|
||
size = 'md',
|
||
showCloseButton = false,
|
||
primaryAction,
|
||
secondaryAction,
|
||
useGrayFooter = false,
|
||
footer,
|
||
sidebar,
|
||
headerExtras,
|
||
}: ModalProps) {
|
||
const toneStyle = toneStyles[tone];
|
||
const panelSizeClasses = sizeClasses[size];
|
||
const hasActions = !!primaryAction || !!secondaryAction;
|
||
const hasBothActions = !!primaryAction && !!secondaryAction;
|
||
|
||
const isAlert = variant === 'alert';
|
||
const bodyContent = children ?? description;
|
||
|
||
return (
|
||
<Dialog open={open} onClose={onClose} className="relative z-50">
|
||
<DialogBackdrop
|
||
transition
|
||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"
|
||
/>
|
||
|
||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:p-8">
|
||
<DialogPanel
|
||
transition
|
||
className={clsx(
|
||
'relative flex w-full max-h-[calc(100vh-8rem)] lg:max-h-[800px] flex-col transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all ' +
|
||
'data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out ' +
|
||
'data-leave:duration-200 data-leave:ease-in sm:my-8 data-closed:sm:translate-y-0 data-closed:sm:scale-95 ' +
|
||
'dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10',
|
||
panelSizeClasses,
|
||
)}
|
||
>
|
||
{/* X-Button oben rechts (optional) */}
|
||
{showCloseButton && (
|
||
<div className="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-2 focus:outline-offset-2 focus:outline-indigo-600 dark:bg-gray-800 dark:hover:text-gray-300 dark:focus:outline-white"
|
||
>
|
||
<span className="sr-only">Close</span>
|
||
<XMarkIcon aria-hidden="true" className="size-6" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* HEADER + MAIN (Body+Sidebar) */}
|
||
<div className="flex-1 flex flex-col min-h-0 bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800 overflow-hidden">
|
||
{/* Header */}
|
||
<div
|
||
className={clsx(
|
||
'flex',
|
||
isAlert
|
||
? 'items-start gap-3 text-left'
|
||
: 'flex-col items-center text-center',
|
||
)}
|
||
>
|
||
{icon && (
|
||
<div
|
||
className={clsx(
|
||
'flex size-12 shrink-0 items-center justify-center rounded-full sm:size-10',
|
||
toneStyle.iconBg,
|
||
!isAlert && 'mx-auto',
|
||
)}
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className={clsx(
|
||
'flex items-center justify-center',
|
||
toneStyle.iconColor,
|
||
)}
|
||
>
|
||
{icon}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={clsx(
|
||
isAlert
|
||
? 'mt-3 sm:mt-0 sm:text-left'
|
||
: 'mt-3 sm:mt-4',
|
||
!isAlert && 'w-full',
|
||
)}
|
||
>
|
||
{title && (
|
||
<DialogTitle
|
||
as="h3"
|
||
className={clsx(
|
||
'text-base font-semibold text-gray-900 dark:text-white',
|
||
!isAlert && 'text-center',
|
||
)}
|
||
>
|
||
{title}
|
||
</DialogTitle>
|
||
)}
|
||
|
||
{!children && description && (
|
||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||
{description}
|
||
</p>
|
||
)}
|
||
|
||
{headerExtras && (
|
||
<div className="mt-4 w-full">{headerExtras}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* MAIN: Body + Sidebar – nimmt den Rest der Höhe ein */}
|
||
{(bodyContent || sidebar) && (
|
||
<div
|
||
className={clsx(
|
||
'mt-6 flex flex-col min-h-0',
|
||
sidebar
|
||
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
|
||
: 'overflow-y-auto' // nur ohne Sidebar soll der Body global scrollen
|
||
)}
|
||
>
|
||
{/* Linker Inhalt (Details / Formulare) */}
|
||
{bodyContent && (
|
||
<div
|
||
className={clsx(
|
||
'flex-1 min-h-0 overflow-y-auto text-left',
|
||
!sidebar && 'mx-auto max-w-2xl',
|
||
)}
|
||
>
|
||
{bodyContent}
|
||
</div>
|
||
)}
|
||
|
||
{/* Rechte Sidebar (QR + Verlauf) */}
|
||
{sidebar && (
|
||
<aside className="sm:min-h-0 sm:overflow-hidden">
|
||
{sidebar}
|
||
</aside>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{footer ? (
|
||
footer
|
||
) : hasActions ? (
|
||
<div
|
||
className={clsx(
|
||
useGrayFooter
|
||
? 'bg-gray-50 px-4 py-3 sm:px-6 dark:bg-gray-700/25'
|
||
: 'px-4 py-3 sm:px-6',
|
||
)}
|
||
>
|
||
<div
|
||
className={clsx(
|
||
'flex flex-col gap-3',
|
||
hasBothActions && 'sm:flex-row-reverse',
|
||
)}
|
||
>
|
||
{primaryAction &&
|
||
renderActionButton(
|
||
primaryAction,
|
||
hasBothActions ? 'flex-1' : undefined,
|
||
)}
|
||
{secondaryAction &&
|
||
renderActionButton(
|
||
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
|
||
hasBothActions ? 'flex-1' : undefined,
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</DialogPanel>
|
||
</div>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
export default Modal;
|
||
|