329 lines
10 KiB
TypeScript
329 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';
|
|
|
|
export type ModalTone = 'default' | 'success' | 'danger' | 'warning' | 'info';
|
|
export type ModalVariant = 'centered' | 'alert';
|
|
export type ModalSize = 'sm' | 'md' | 'lg';
|
|
|
|
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.
|
|
* Damit kannst du komplett eigene Layouts bauen.
|
|
*/
|
|
footer?: React.ReactNode;
|
|
|
|
/**
|
|
* Optionaler Inhalt für eine rechte Sidebar (z.B. Geräthistorie).
|
|
* Auf kleinen Screens unten angehängt, ab sm rechts als Spalte.
|
|
*/
|
|
sidebar?: 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', // ein bisschen breiter für Sidebar
|
|
};
|
|
|
|
const baseButtonClasses =
|
|
'inline-flex w-full justify-center rounded-md px-3 py-2 text-sm font-semibold shadow-xs ' +
|
|
'focus-visible:outline-2 focus-visible:outline-offset-2 ';
|
|
|
|
const buttonVariantClasses: Record<
|
|
NonNullable<ModalAction['variant']>,
|
|
string
|
|
> = {
|
|
primary:
|
|
'bg-indigo-600 text-white hover:bg-indigo-500 focus-visible:outline-indigo-600 ' +
|
|
'dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500 dark:shadow-none',
|
|
secondary:
|
|
'bg-white text-gray-900 inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 ' +
|
|
'dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20',
|
|
danger:
|
|
'bg-red-600 text-white hover:bg-red-500 focus-visible:outline-red-600 ' +
|
|
'dark:bg-red-500 dark:hover:bg-red-400 dark:shadow-none',
|
|
};
|
|
|
|
function renderActionButton(
|
|
action: ModalAction,
|
|
extraClasses?: string,
|
|
): React.ReactNode {
|
|
const variant = action.variant ?? 'primary';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={action.onClick}
|
|
data-autofocus={action.autoFocus ? true : undefined}
|
|
className={clsx(baseButtonClasses, buttonVariantClasses[variant], 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,
|
|
}: ModalProps) {
|
|
const toneStyle = toneStyles[tone];
|
|
const panelSizeClasses = sizeClasses[size];
|
|
const hasActions = !!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-end justify-center p-4 text-center sm:items-center sm:p-0">
|
|
<DialogPanel
|
|
transition
|
|
className={clsx(
|
|
'relative flex max-h-[90vh] w-full 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 + Body + Sidebar */}
|
|
<div className="flex-1 overflow-y-auto bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4 dark:bg-gray-800">
|
|
{/* Header (Icon + Titel + optionale Beschreibung) */}
|
|
<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>
|
|
)}
|
|
|
|
{/* Beschreibung nur anzeigen, wenn keine eigenen Children übergeben wurden */}
|
|
{!children && description && (
|
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
|
{description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body + Sidebar */}
|
|
{(bodyContent || sidebar) && (
|
|
<div
|
|
className={clsx(
|
|
'mt-6',
|
|
sidebar && 'sm:mt-8 sm:flex sm:items-start sm:gap-6',
|
|
)}
|
|
>
|
|
{bodyContent && (
|
|
<div
|
|
className={clsx(
|
|
'flex-1 text-left',
|
|
!sidebar && 'mx-auto max-w-2xl',
|
|
)}
|
|
>
|
|
{bodyContent}
|
|
</div>
|
|
)}
|
|
|
|
{sidebar && (
|
|
<aside className="mt-6 border-t border-gray-200 pt-6 text-left text-sm sm:mt-0 sm:w-80 sm:shrink-0 sm:border-l sm:border-t-0 sm:pl-6 dark:border-white/10">
|
|
{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',
|
|
isAlert
|
|
? 'sm:flex sm:flex-row-reverse sm:gap-3'
|
|
: secondaryAction
|
|
? 'sm:mt-2 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3'
|
|
: 'sm:mt-2',
|
|
)}
|
|
>
|
|
{primaryAction &&
|
|
renderActionButton(
|
|
primaryAction,
|
|
isAlert
|
|
? 'sm:w-auto'
|
|
: secondaryAction
|
|
? 'sm:col-start-2'
|
|
: '',
|
|
)}
|
|
|
|
{secondaryAction &&
|
|
renderActionButton(
|
|
{ ...secondaryAction, variant: secondaryAction.variant ?? 'secondary' },
|
|
clsx(
|
|
'mt-3 sm:mt-0',
|
|
isAlert && 'sm:w-auto sm:mr-3',
|
|
!useGrayFooter && 'bg-white',
|
|
),
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</DialogPanel>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export default Modal;
|