From a92a7dcdcdef6011ab99519098b1148442d2a355 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 13:22:42 +0300 Subject: [PATCH] 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 --- frontend/src/app/documents/page.tsx | 357 ++++++++---------- .../DocumentAcknowledgementsReport.tsx | 22 +- frontend/src/components/ui/Modal.tsx | 185 +++++++++ frontend/src/components/ui/index.ts | 2 + frontend/src/hooks/useModal.ts | 143 +++++++ 5 files changed, 517 insertions(+), 192 deletions(-) create mode 100644 frontend/src/components/ui/Modal.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/hooks/useModal.ts diff --git a/frontend/src/app/documents/page.tsx b/frontend/src/app/documents/page.tsx index 7e7194be..0dcea422 100644 --- a/frontend/src/app/documents/page.tsx +++ b/frontend/src/app/documents/page.tsx @@ -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() { {/* Upload Modal */} - {showUploadForm && ( -
-
-
-

Загрузить документ

- -
- { - setShowUploadForm(false); - loadDocuments(); - }} - onCancel={() => setShowUploadForm(false)} - /> -
-
- )} + setShowUploadForm(false)} + title="Загрузить документ" + size="lg" + showFullscreenToggle + > + { + setShowUploadForm(false); + loadDocuments(); + }} + onCancel={() => setShowUploadForm(false)} + /> + {/* Document Details Modal */} - {selectedDocument && ( -
-
-
-

- {selectedDocument.title} -

+ setSelectedDocument(null)} + title={selectedDocument?.title} + size="lg" + showFullscreenToggle + > + {selectedDocument && ( +
+
+ +
+ +
+

Описание

+

+ {selectedDocument.description || "Описание отсутствует"} +

+
+ + {selectedDocument.file_url && ( +
+ +
+ )} + + {/* Workflow Actions */} +
+

Действия

+ { + loadDocuments(); + setSelectedDocument(null); + }} + /> +
+ + {/* Acknowledgement */} + {selectedDocument.acknowledgement_required && ( +
+
+

+ Подтверждение прочтения +

- -
-
- -
- -
-

Описание

-

- {selectedDocument.description || "Описание отсутствует"} -

-
- - {selectedDocument.file_url && ( -
- -
- )} - - {/* Workflow Actions */} -
-

Действия

- { - loadDocuments(); - setSelectedDocument(null); - }} - /> -
- - {/* Acknowledgement */} - {selectedDocument.acknowledgement_required && ( -
-
-

- Подтверждение прочтения -

- -
- { - loadDocuments(); - // Refresh selected document - apiClient.getDocument(selectedDocument.id).then(setSelectedDocument); - }} - /> -
- )} -
+ { + loadDocuments(); + // Refresh selected document + apiClient.getDocument(selectedDocument.id).then(setSelectedDocument); + }} + />
-
- )} + )} +
+ )} + {/* File Preview Modal */} {previewFile && ( @@ -768,87 +754,78 @@ export default function DocumentsPage() { )} {/* Create Folder Modal */} - {showCreateFolder && ( -
-
-
-

Создать папку

- -
+ setShowCreateFolder(false)} + title="Создать папку" + size="sm" + > +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const name = formData.get('name') as string; - { - 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" - > -
- - -
- - {selectedFolderId && ( -
-

- - Будет создана в выбранной папке -

-
- )} - -
- - -
-
+ try { + await apiClient.createFolder({ + name: name.trim(), + parent: selectedFolderId, + }); + setShowCreateFolder(false); + loadFolders(); + } catch (err) { + console.error('Ошибка создания папки:', err); + alert('Не удалось создать папку'); + } + }} + className="space-y-4" + > +
+ +
-
- )} + + {selectedFolderId && ( +
+

+ + Будет создана в выбранной папке +

+
+ )} + +
+ + +
+ + {/* Acknowledgements Report Modal */} {showAcknowledgementsReport && ( diff --git a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx index 46b49b55..51dc1d7b 100644 --- a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx +++ b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx @@ -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 ( -
-
+
+
{/* Header */}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx new file mode 100644 index 00000000..9b3e5cc4 --- /dev/null +++ b/frontend/src/components/ui/Modal.tsx @@ -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(null); + const contentRef = useRef(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( + '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 ( +
+
+ {/* Header */} + {(title || showCloseButton || showFullscreenToggle) && ( +
+ {title && ( + + )} +
+ {showFullscreenToggle && ( + + )} + {showCloseButton && ( + + )} +
+
+ )} + + {/* Content */} +
{children}
+ + {/* Footer */} + {footer && ( +
{footer}
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..1ae2df91 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,2 @@ +export { Modal } from "./Modal"; +export type { ModalProps } from "./Modal"; diff --git a/frontend/src/hooks/useModal.ts b/frontend/src/hooks/useModal.ts new file mode 100644 index 00000000..619967bc --- /dev/null +++ b/frontend/src/hooks/useModal.ts @@ -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({ + isOpen, + onClose, + closeOnEsc = true, + trapFocus = true, + lockBodyScroll = true, +}: UseModalOptions) { + const modalRef = useRef(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( + '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( + callback: () => void, + enabled = true +) { + const ref = useRef(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; +}