Corp/frontend/src/hooks/useModal.ts

144 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useRef } 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;
}