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:
igor_nadein 2026-02-28 13:22:42 +03:00
parent 6def6d2d43
commit a92a7dcdcd
5 changed files with 517 additions and 192 deletions

View File

@ -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 && (

View File

@ -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>

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Modal } from "./Modal";
export type { ModalProps } from "./Modal";

View 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;
}