feat: Improve modal functionality with advanced UX features
New features: - Create reusable Modal component with animations - Add useModal hook for common modal behaviors - Add useClickOutside hook for backdrop clicks - Implement ESC key handling for all modals - Add focus trap to prevent focus escaping modals - Add body scroll locking when modals are open - Add fullscreen toggle option for modals - Add fade-in/scale animations for smooth transitions Modal improvements: - Upload modal: converted to use new Modal component - Document details modal: converted to use new Modal component - Create folder modal: converted to use new Modal component - Acknowledgements report: enhanced with modal hooks Technical details: - Modal sizes: sm, md, lg, xl, full - Configurable close behavior (ESC, click outside, close button) - Automatic scrollbar width compensation - Focus restoration after modal close - Accessible with ARIA attributes and keyboard navigation
This commit is contained in:
parent
6def6d2d43
commit
a92a7dcdcd
@ -28,6 +28,7 @@ import { FolderTree, type FolderNode } from "@/components/documents/folders";
|
||||
import { AdvancedSearch } from "@/components/documents/search";
|
||||
import { BulkActionsToolbar, useDocumentSelection } from "@/components/documents/batch";
|
||||
import { DocumentsDashboard } from "@/components/documents/dashboard";
|
||||
import { Modal } from "@/components/ui";
|
||||
|
||||
// Динамический импорт компонентов с PDF обработкой (избегаем SSR ошибок с DOMMatrix)
|
||||
const DocumentUploadForm = dynamic(
|
||||
@ -629,125 +630,110 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Загрузить документ</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadForm(false)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUploadForm
|
||||
currentFolderId={selectedFolderId}
|
||||
onSuccess={() => {
|
||||
setShowUploadForm(false);
|
||||
loadDocuments();
|
||||
}}
|
||||
onCancel={() => setShowUploadForm(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
isOpen={showUploadForm}
|
||||
onClose={() => setShowUploadForm(false)}
|
||||
title="Загрузить документ"
|
||||
size="lg"
|
||||
showFullscreenToggle
|
||||
>
|
||||
<DocumentUploadForm
|
||||
currentFolderId={selectedFolderId}
|
||||
onSuccess={() => {
|
||||
setShowUploadForm(false);
|
||||
loadDocuments();
|
||||
}}
|
||||
onCancel={() => setShowUploadForm(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Document Details Modal */}
|
||||
{selectedDocument && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{selectedDocument.title}
|
||||
</h2>
|
||||
<Modal
|
||||
isOpen={!!selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
title={selectedDocument?.title}
|
||||
size="lg"
|
||||
showFullscreenToggle
|
||||
>
|
||||
{selectedDocument && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<DocumentStatusBadge
|
||||
status={selectedDocument.status}
|
||||
statusCode={selectedDocument.status_code}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-700">Описание</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedDocument.description || "Описание отсутствует"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedDocument.file_url && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPreviewFile({
|
||||
url: selectedDocument.file_url!,
|
||||
name: selectedDocument.file_name || selectedDocument.title,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Открыть файл
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Actions */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-700">Действия</h3>
|
||||
<DocumentWorkflowButtons
|
||||
documentId={selectedDocument.id}
|
||||
currentStatus={selectedDocument.status_code}
|
||||
onStatusChange={() => {
|
||||
loadDocuments();
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Acknowledgement */}
|
||||
{selectedDocument.acknowledgement_required && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
Подтверждение прочтения
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedDocument(null)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
onClick={() => {
|
||||
setShowAcknowledgementsReport({
|
||||
documentId: selectedDocument.id,
|
||||
documentTitle: selectedDocument.title,
|
||||
});
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 ring-1 ring-blue-200 hover:bg-blue-100"
|
||||
>
|
||||
<X size={20} />
|
||||
Посмотреть ведомость
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<DocumentStatusBadge
|
||||
status={selectedDocument.status}
|
||||
statusCode={selectedDocument.status_code}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-sm font-medium text-gray-700">Описание</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{selectedDocument.description || "Описание отсутствует"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedDocument.file_url && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPreviewFile({
|
||||
url: selectedDocument.file_url!,
|
||||
name: selectedDocument.file_name || selectedDocument.title,
|
||||
})
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Открыть файл
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Actions */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-700">Действия</h3>
|
||||
<DocumentWorkflowButtons
|
||||
documentId={selectedDocument.id}
|
||||
currentStatus={selectedDocument.status_code}
|
||||
onStatusChange={() => {
|
||||
loadDocuments();
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Acknowledgement */}
|
||||
{selectedDocument.acknowledgement_required && (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
Подтверждение прочтения
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAcknowledgementsReport({
|
||||
documentId: selectedDocument.id,
|
||||
documentTitle: selectedDocument.title,
|
||||
});
|
||||
setSelectedDocument(null);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 ring-1 ring-blue-200 hover:bg-blue-100"
|
||||
>
|
||||
Посмотреть ведомость
|
||||
</button>
|
||||
</div>
|
||||
<DocumentAcknowledgement
|
||||
document={selectedDocument}
|
||||
onAcknowledge={() => {
|
||||
loadDocuments();
|
||||
// Refresh selected document
|
||||
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DocumentAcknowledgement
|
||||
document={selectedDocument}
|
||||
onAcknowledge={() => {
|
||||
loadDocuments();
|
||||
// Refresh selected document
|
||||
apiClient.getDocument(selectedDocument.id).then(setSelectedDocument);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* File Preview Modal */}
|
||||
{previewFile && (
|
||||
@ -768,87 +754,78 @@ export default function DocumentsPage() {
|
||||
)}
|
||||
|
||||
{/* Create Folder Modal */}
|
||||
{showCreateFolder && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Создать папку</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={showCreateFolder}
|
||||
onClose={() => setShowCreateFolder(false)}
|
||||
title="Создать папку"
|
||||
size="sm"
|
||||
>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const name = formData.get('name') as string;
|
||||
if (!name.trim()) {
|
||||
alert('Введите название папки');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name.trim()) {
|
||||
alert('Введите название папки');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiClient.createFolder({
|
||||
name: name.trim(),
|
||||
parent: selectedFolderId,
|
||||
});
|
||||
setShowCreateFolder(false);
|
||||
loadFolders();
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания папки:', err);
|
||||
alert('Не удалось создать папку');
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="folderName" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Название папки
|
||||
</label>
|
||||
<input
|
||||
id="folderName"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
placeholder="Введите название..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFolderId && (
|
||||
<div className="rounded-lg bg-sky-50 p-3">
|
||||
<p className="text-xs text-sky-700">
|
||||
<FolderOpen className="mr-1 inline" size={14} />
|
||||
Будет создана в выбранной папке
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
try {
|
||||
await apiClient.createFolder({
|
||||
name: name.trim(),
|
||||
parent: selectedFolderId,
|
||||
});
|
||||
setShowCreateFolder(false);
|
||||
loadFolders();
|
||||
} catch (err) {
|
||||
console.error('Ошибка создания папки:', err);
|
||||
alert('Не удалось создать папку');
|
||||
}
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="folderName" className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Название папки
|
||||
</label>
|
||||
<input
|
||||
id="folderName"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
placeholder="Введите название..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFolderId && (
|
||||
<div className="rounded-lg bg-sky-50 p-3">
|
||||
<p className="text-xs text-sky-700">
|
||||
<FolderOpen className="mr-1 inline" size={14} />
|
||||
Будет создана в выбранной папке
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-sky-700"
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Acknowledgements Report Modal */}
|
||||
{showAcknowledgementsReport && (
|
||||
|
||||
@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { useModal, useClickOutside } from "@/hooks/useModal";
|
||||
|
||||
interface Employee {
|
||||
id: number;
|
||||
@ -45,6 +46,17 @@ export function DocumentAcknowledgementsReport({
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
|
||||
|
||||
// Используем модальные хуки для улучшений UX
|
||||
const contentRef = useModal({
|
||||
isOpen: true,
|
||||
onClose,
|
||||
closeOnEsc: true,
|
||||
trapFocus: true,
|
||||
lockBodyScroll: true,
|
||||
});
|
||||
|
||||
const backdropRef = useClickOutside(onClose, true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [documentId, search]);
|
||||
@ -117,8 +129,14 @@ export function DocumentAcknowledgementsReport({
|
||||
const displayList = getDisplayList();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex h-[90vh] w-full max-w-4xl flex-col rounded-2xl bg-white shadow-xl">
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 transition-opacity duration-200"
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="flex h-[90vh] w-full max-w-4xl flex-col rounded-2xl bg-white shadow-xl transition-all duration-200"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-gray-200 p-6">
|
||||
<div>
|
||||
|
||||
185
frontend/src/components/ui/Modal.tsx
Normal file
185
frontend/src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"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;
|
||||
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-50 flex items-center justify-center bg-black/50 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-2xl 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 border-b border-gray-200 px-6 py-4">
|
||||
{title && (
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{showFullscreenToggle && (
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="rounded-lg p-2 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-lg p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
title="Закрыть"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="shrink-0 border-t border-gray-200 px-6 py-4">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
frontend/src/components/ui/index.ts
Normal file
2
frontend/src/components/ui/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Modal } from "./Modal";
|
||||
export type { ModalProps } from "./Modal";
|
||||
143
frontend/src/hooks/useModal.ts
Normal file
143
frontend/src/hooks/useModal.ts
Normal file
@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, RefObject } from "react";
|
||||
|
||||
export interface UseModalOptions {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
closeOnEsc?: boolean;
|
||||
trapFocus?: boolean;
|
||||
lockBodyScroll?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для универсального управления модальными окнами
|
||||
* - Закрытие по ESC
|
||||
* - Trap focus внутри модала
|
||||
* - Блокировка прокрутки body
|
||||
*/
|
||||
export function useModal<T extends HTMLElement = HTMLDivElement>({
|
||||
isOpen,
|
||||
onClose,
|
||||
closeOnEsc = true,
|
||||
trapFocus = true,
|
||||
lockBodyScroll = true,
|
||||
}: UseModalOptions) {
|
||||
const modalRef = useRef<T>(null);
|
||||
|
||||
// Блокировка прокрутки body
|
||||
useEffect(() => {
|
||||
if (!lockBodyScroll) return;
|
||||
|
||||
if (isOpen) {
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
document.body.style.paddingRight = "";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
document.body.style.paddingRight = "";
|
||||
};
|
||||
}, [isOpen, lockBodyScroll]);
|
||||
|
||||
// Закрытие по ESC
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEsc || !onClose) 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 || !trapFocus || !modalRef.current) return;
|
||||
|
||||
const modalElement = modalRef.current;
|
||||
const focusableElements = modalElement.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
if (focusableElements.length === 0) return;
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
// Сохраняем ранее сфокусированный элемент
|
||||
const previouslyFocused = document.activeElement as HTMLElement;
|
||||
|
||||
// Фокус на первый элемент при открытии
|
||||
setTimeout(() => firstElement?.focus(), 0);
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Tab") return;
|
||||
|
||||
// Проверяем, что фокус внутри модала
|
||||
if (!modalElement.contains(document.activeElement)) {
|
||||
e.preventDefault();
|
||||
firstElement?.focus();
|
||||
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);
|
||||
// Возвращаем фокус на предыдущий элемент
|
||||
if (previouslyFocused && document.body.contains(previouslyFocused)) {
|
||||
previouslyFocused.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, trapFocus]);
|
||||
|
||||
return modalRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Хук для закрытия модала по клику вне его области
|
||||
*/
|
||||
export function useClickOutside<T extends HTMLElement = HTMLDivElement>(
|
||||
callback: () => void,
|
||||
enabled = true
|
||||
) {
|
||||
const ref = useRef<T>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && e.target === ref.current) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [callback, enabled]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user