geraete/components/ui/Modal.tsx
2025-11-26 08:02:48 +01:00

336 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
disabled?: 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}
disabled={action.disabled}
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-[950px] 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 sm:w-60 lg:w-80 sm:shrink-0">
{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;