geraete/components/ui/Modal.tsx
2025-11-14 20:16:24 +01:00

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;