206 lines
5.8 KiB
TypeScript
206 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useId, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { X } from "lucide-react";
|
|
|
|
let openModalCount = 0;
|
|
const openModalStack: string[] = [];
|
|
const subscribeToBrowserReady = () => () => {};
|
|
const getBrowserSnapshot = () => true;
|
|
const getServerSnapshot = () => false;
|
|
|
|
export interface ModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
title?: string;
|
|
children: ReactNode;
|
|
size?: "sm" | "md" | "lg" | "xl" | "full";
|
|
showCloseButton?: boolean;
|
|
closeOnClickOutside?: boolean;
|
|
closeOnEsc?: boolean;
|
|
footer?: ReactNode;
|
|
className?: string;
|
|
/** Remove default content padding while keeping the standard modal frame */
|
|
noPadding?: boolean;
|
|
/** Hide the built-in header (caller manages its own header inside children) */
|
|
noHeader?: boolean;
|
|
/** Visual level for a modal opened above another modal */
|
|
stackLevel?: number;
|
|
}
|
|
|
|
export function Modal({
|
|
isOpen,
|
|
onClose,
|
|
title,
|
|
children,
|
|
size = "md",
|
|
showCloseButton = true,
|
|
closeOnClickOutside = false,
|
|
closeOnEsc = true,
|
|
footer,
|
|
className = "",
|
|
noPadding = false,
|
|
noHeader = false,
|
|
stackLevel = 0,
|
|
}: ModalProps) {
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const titleId = useId();
|
|
const browserReady = useSyncExternalStore(
|
|
subscribeToBrowserReady,
|
|
getBrowserSnapshot,
|
|
getServerSnapshot,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
|
|
openModalCount += 1;
|
|
openModalStack.push(titleId);
|
|
document.body.style.overflow = "hidden";
|
|
|
|
return () => {
|
|
openModalCount = Math.max(0, openModalCount - 1);
|
|
const stackIndex = openModalStack.lastIndexOf(titleId);
|
|
if (stackIndex >= 0) openModalStack.splice(stackIndex, 1);
|
|
if (openModalCount === 0) {
|
|
document.body.style.overflow = "";
|
|
}
|
|
};
|
|
}, [isOpen, titleId]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !closeOnEsc) return;
|
|
|
|
const handleEsc = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && openModalStack.at(-1) === titleId) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleEsc);
|
|
return () => document.removeEventListener("keydown", handleEsc);
|
|
}, [isOpen, closeOnEsc, onClose, titleId]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || !browserReady || !contentRef.current) return;
|
|
|
|
const focusableElements = contentRef.current.querySelectorAll<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
|
|
if (focusableElements.length === 0) return;
|
|
|
|
const firstElement = focusableElements[0];
|
|
const lastElement = focusableElements[focusableElements.length - 1];
|
|
|
|
firstElement?.focus();
|
|
|
|
const handleTab = (e: KeyboardEvent) => {
|
|
if (e.key !== "Tab" || openModalStack.at(-1) !== titleId) return;
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
lastElement?.focus();
|
|
e.preventDefault();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
firstElement?.focus();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleTab);
|
|
return () => document.removeEventListener("keydown", handleTab);
|
|
}, [isOpen, titleId, browserReady]);
|
|
|
|
if (!isOpen || !browserReady) return null;
|
|
|
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
|
if (
|
|
closeOnClickOutside &&
|
|
e.target === modalRef.current &&
|
|
openModalStack.at(-1) === titleId
|
|
) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const sizeClasses = {
|
|
sm: "max-w-[95vw] sm:max-w-md",
|
|
md: "max-w-[95vw] sm:max-w-2xl",
|
|
lg: "max-w-[95vw] sm:max-w-4xl",
|
|
xl: "max-w-[95vw] sm:max-w-6xl",
|
|
full: "max-w-[98vw] sm:max-w-[95vw]",
|
|
};
|
|
|
|
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
|
|
|
|
const showHeader = !noHeader && (title || showCloseButton);
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={modalRef}
|
|
onClick={handleBackdropClick}
|
|
data-overlay-root="true"
|
|
className="app-overlay fixed inset-0 z-[100] flex items-center justify-center p-2 transition-opacity duration-200 opacity-100 sm:p-4"
|
|
style={{ zIndex: 100 + stackLevel }}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={title ? titleId : undefined}
|
|
>
|
|
<div
|
|
ref={contentRef}
|
|
className={`app-surface-elevated flex scale-100 flex-col overflow-hidden rounded-xl opacity-100 transition-all duration-200 sm:rounded-2xl ${modalSizeClass} ${className}`}
|
|
>
|
|
{/* Header */}
|
|
{showHeader && (
|
|
<div className="mb-3 flex shrink-0 items-center gap-2 px-4 pt-4 sm:mb-4 sm:gap-3 sm:px-6 sm:pt-6">
|
|
{title && (
|
|
<h3
|
|
id={titleId}
|
|
className="min-w-0 flex-1 truncate text-sm font-semibold text-[var(--foreground)] sm:text-base lg:text-lg"
|
|
title={title}
|
|
>
|
|
{title}
|
|
</h3>
|
|
)}
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{showCloseButton && (
|
|
<button
|
|
onClick={onClose}
|
|
className="app-icon-button rounded-full p-1"
|
|
title="Закрыть"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div
|
|
className={noPadding
|
|
? "min-h-0 flex flex-1 flex-col"
|
|
: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-6"}
|
|
>
|
|
{children}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
{footer && (
|
|
<div className="shrink-0 px-4 pt-3 sm:px-6 sm:pt-4">{footer}</div>
|
|
)}
|
|
|
|
{/* Bottom padding */}
|
|
{!noPadding && <div className="pb-4 sm:pb-6" />}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|