Initial commit: Kalendartool (Next.js, Prisma, Docker)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
133
components/ui/ModalShell.tsx
Normal file
133
components/ui/ModalShell.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Gemeinsamer Modal-Rahmen fuer alle Dialoge der App.
|
||||
*
|
||||
* Schliessen moeglich via:
|
||||
* - X-Button (immer, auch waehrend einer laufenden Aktion)
|
||||
* - ESC-Taste (ausser waehrend isPending, um Abbrueche zu vermeiden)
|
||||
* - Klick auf den dunklen Hintergrund (ausser waehrend isPending)
|
||||
*
|
||||
* Alle Modals der App MUESSEN diesen Rahmen verwenden, damit das
|
||||
* Schliess-Verhalten einheitlich ist (DRY).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
/** Verfuegbare Modal-Groessen. */
|
||||
export type ModalSize = 'md' | 'lg' | 'xl';
|
||||
|
||||
const SIZE_CLASSES: Record<ModalSize, string> = {
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-5xl',
|
||||
};
|
||||
|
||||
export function ModalShell({
|
||||
title,
|
||||
ariaLabel,
|
||||
isPending = false,
|
||||
size = 'md',
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
/** Accessibility-Beschriftung (faellt auf title zurueck). */
|
||||
ariaLabel?: string;
|
||||
/** Blockiert ESC/Backdrop-Schliessen waehrend einer laufenden Aktion. */
|
||||
isPending?: boolean;
|
||||
size?: ModalSize;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// ESC schliesst das Modal. Der Listener wird bei jedem Render von
|
||||
// onClose/isPending neu registriert (guenstig und immer aktuell).
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !isPending) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isPending, onClose]);
|
||||
|
||||
// Merkt sich, ob der mousedown auf dem Backdrop (ausserhalb des
|
||||
// Modal-Inhalts) begann. Nur dann ist ein click ein echtes
|
||||
// "Klick ausserhalb". Grund: Bei einer Textauswahl, die IM Modal
|
||||
// beginnt und ausserhalb endet, feuert der Browser das click-Event
|
||||
// auf dem gemeinsamen Vorfahren beider Ziele - dem Backdrop. Ohne
|
||||
// diese Pruefung wuerde das Modal beim Markieren von Text
|
||||
// fälschlich geschlossen.
|
||||
const mouseDownOnBackdropRef = useRef(false);
|
||||
|
||||
// Portal: Das Modal wird auf document.body gerendert, nicht innerhalb
|
||||
// der aufrufenden Komponente. Grund: Wird ein Modal z. B. in der
|
||||
// Sidebar (sticky, eigener Stacking-Kontext) gerendert, wuerde sein
|
||||
// z-50 nur innerhalb der Sidebar gelten - Hauptinhalte daneben
|
||||
// wuerden es überlappen. Auf body-Ebene gilt z-50 global.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel ?? title}
|
||||
onMouseDown={(event) => {
|
||||
mouseDownOnBackdropRef.current =
|
||||
event.target === event.currentTarget;
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
mouseDownOnBackdropRef.current &&
|
||||
!isPending
|
||||
) {
|
||||
mouseDownOnBackdropRef.current = false;
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${SIZE_CLASSES[size]} max-h-[90vh] overflow-y-auto rounded-lg border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-700 dark:bg-slate-900`}
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schliessen"
|
||||
className="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/** X-Icon (2D, Outline-Stil) zum Schliessen des Modals. */
|
||||
function XIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={18}
|
||||
height={18}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user