igor_nadein 8ab14d79a1 feat: Make thumbnail conditional on fullscreen mode
- Changed Modal component to support render prop pattern (children as function)
- Modal children can now receive isFullscreen state as parameter
- Changed modal size from 'xl' to 'lg' for compact default view
- Thumbnail now only shows when modal is in fullscreen mode
- In normal mode: single column with metadata and 'Open file' button
- In fullscreen mode: two columns with metadata (left) and thumbnail (right)
- Provides progressive disclosure: compact view first, expanded view on demand
2026-02-28 13:41:21 +03:00

191 lines
5.6 KiB
TypeScript

"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { X, Maximize2, Minimize2 } from "lucide-react";
import { useState } from "react";
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode | ((isFullscreen: boolean) => ReactNode);
size?: "sm" | "md" | "lg" | "xl" | "full";
showCloseButton?: boolean;
closeOnClickOutside?: boolean;
closeOnEsc?: boolean;
showFullscreenToggle?: boolean;
footer?: ReactNode;
className?: string;
}
export function Modal({
isOpen,
onClose,
title,
children,
size = "md",
showCloseButton = true,
closeOnClickOutside = true,
closeOnEsc = true,
showFullscreenToggle = false,
footer,
className = "",
}: ModalProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// Анимация появления
useEffect(() => {
if (isOpen) {
setIsAnimating(true);
// Блокируем прокрутку body
document.body.style.overflow = "hidden";
} else {
// Разблокируем прокрутку при закрытии
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// ESC для закрытия
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEsc);
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, closeOnEsc, onClose]);
// Focus trap
useEffect(() => {
if (!isOpen || !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") return;
if (e.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
// Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
}
}
};
document.addEventListener("keydown", handleTab);
return () => document.removeEventListener("keydown", handleTab);
}, [isOpen]);
if (!isOpen) return null;
const handleBackdropClick = (e: React.MouseEvent) => {
if (closeOnClickOutside && e.target === modalRef.current) {
onClose();
}
};
const sizeClasses = {
sm: "max-w-md",
md: "max-w-2xl",
lg: "max-w-4xl",
xl: "max-w-6xl",
full: "max-w-[95vw]",
};
const modalSizeClass = isFullscreen
? "w-screen h-screen max-w-none rounded-none"
: `w-full ${sizeClasses[size]} max-h-[90vh]`;
return (
<div
ref={modalRef}
onClick={handleBackdropClick}
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 transition-opacity duration-200 ${
isAnimating ? "opacity-100" : "opacity-0"
}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
>
<div
ref={contentRef}
className={`flex flex-col rounded-2xl bg-white shadow-xl overflow-y-auto transition-all duration-200 ${modalSizeClass} ${
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0"
} ${className}`}
>
{/* Header */}
{(title || showCloseButton || showFullscreenToggle) && (
<div className="flex shrink-0 items-center justify-between mb-4 px-6 pt-6">
{title && (
<h3 id="modal-title" className="text-lg font-semibold text-gray-900">
{title}
</h3>
)}
<div className="ml-auto flex items-center gap-2">
{showFullscreenToggle && (
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
title={isFullscreen ? "Выйти из полноэкранного режима" : "Полноэкранный режим"}
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
)}
{showCloseButton && (
<button
onClick={onClose}
className="rounded-full p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
title="Закрыть"
>
<X size={20} />
</button>
)}
</div>
</div>
)}
{/* Content */}
<div className="px-6">
{typeof children === "function" ? children(isFullscreen) : children}
</div>
{/* Footer */}
{footer && (
<div className="shrink-0 border-t border-gray-200 px-6 py-4 mt-4">{footer}</div>
)}
{/* Bottom padding */}
<div className="pb-6" />
</div>
</div>
);
}