133 lines
4.1 KiB
TypeScript
133 lines
4.1 KiB
TypeScript
'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>
|
|
);
|
|
} |