Files
leaudit-platform-frontend/app/components/ui/Modal.tsx
T

162 lines
4.2 KiB
TypeScript

// app/components/ui/Modal.tsx
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import modalStyles from '~/styles/components/modal.css?url';
// 导出样式
export function links() {
return [{ rel: 'stylesheet', href: modalStyles }];
}
// 模态框尺寸
export type ModalSize = 'small' | 'medium' | 'large' | 'full';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
width?: number | string;
size?: ModalSize;
className?: string;
closeOnEsc?: boolean;
closeOnBackdropClick?: boolean;
}
export function Modal({
isOpen,
onClose,
title,
children,
footer,
width,
size = 'medium',
className = '',
closeOnEsc = true,
closeOnBackdropClick = true
}: ModalProps) {
// 引用模态框内容元素
const contentRef = useRef<HTMLDivElement>(null);
// 保存之前的活动元素,以便关闭时恢复焦点
const previousActiveElement = useRef<HTMLElement | null>(null);
// 自动聚焦第一个可聚焦元素并保存先前的焦点元素
useEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement;
if (contentRef.current) {
const focusableElements = contentRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length > 0) {
(focusableElements[0] as HTMLElement).focus();
} else {
contentRef.current.focus();
}
}
// 当模态框打开时禁止背景滚动
document.body.style.overflow = 'hidden';
} else if (previousActiveElement.current) {
// 当模态框关闭时,恢复之前的焦点
previousActiveElement.current.focus();
// 恢复背景滚动
document.body.style.overflow = '';
}
// 清理函数
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 处理背景点击事件
const handleBackdropClick = () => {
if (closeOnBackdropClick) {
onClose();
}
};
// 获取尺寸样式类
const sizeClass = width ? '' : `modal-${size}`;
// 计算宽度样式
const widthStyle = width ?
{ width: typeof width === 'number' ? `${width}px` : width, maxWidth: typeof width === 'number' ? `${width}px` : width } : {};
// 使用useEffect添加键盘事件监听器
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && closeOnEsc) {
onClose();
}
};
document.addEventListener('keydown', handleEscKey);
return () => {
document.removeEventListener('keydown', handleEscKey);
};
}, [closeOnEsc, onClose]);
if (!isOpen) return null;
const modalNode = (
<div
className="modal-backdrop"
aria-hidden="true"
>
<div
ref={contentRef}
className={`modal-content ${sizeClass} ${className}`}
style={widthStyle}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className="modal-header">
<h3 id="modal-title" className="modal-title">{title}</h3>
<button
className="modal-close"
onClick={onClose}
aria-label="关闭"
style={{ cursor: 'pointer' }}
>
<i className="ri-close-line"></i>
</button>
</div>
<div className="modal-body">
{children}
</div>
{footer && (
<div className="modal-footer">
{footer}
</div>
)}
</div>
{/* 背景点击处理 */}
<button
className="modal-backdrop-button"
onClick={handleBackdropClick}
aria-label="关闭模态框"
tabIndex={-1}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'transparent',
border: 'none',
cursor: 'default',
zIndex: 0
}}
/>
</div>
);
return ReactDOM.createPortal(modalNode, document.body);
}