geraete/components/ui/Modal.tsx
2026-01-29 11:47:28 +01:00

331 lines
9.9 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"
/>
{/* Zentrierter Container keine eigene Scrollbar mehr, die Höhe begrenzt das Panel */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 text-center sm:p-8">
<DialogPanel
transition
className={clsx(
'relative flex w-full max-h-[calc(100vh-3rem)] 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 ' +
'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) dieser Block bekommt flex-1 und min-h-0 */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-white px-4 pt-5 pb-4 dark:bg-gray-800 sm:p-6 sm:pb-4">
{/* Header (nicht scrollend) */}
<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 bekommt min-h-0 und nur der Body scrollt */}
{(bodyContent || sidebar) && (
<div
className={clsx(
'mt-6 flex min-h-0 flex-1 flex-col',
sidebar
? 'sm:mt-8 sm:flex-row sm:items-stretch sm:gap-6'
: undefined,
)}
>
{/* Linker Inhalt (scrollbar) */}
{bodyContent && (
<div
className={clsx(
'min-h-0 flex-1 overflow-y-auto text-left',
!sidebar && 'mx-auto max-w-2xl',
)}
>
{bodyContent}
</div>
)}
{/* Rechte Sidebar (nicht scrollbar, eigene interne Scroller möglich) */}
{sidebar && (
<aside className="sm:min-h-0 sm:w-60 sm:shrink-0 sm:overflow-hidden lg:w-80">
{sidebar}
</aside>
)}
</div>
)}
</div>
{/* Footer (immer sichtbar, außerhalb des scrollbaren Bereichs) */}
{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>
</Dialog>
);
}
export default Modal;