159 lines
4.1 KiB
TypeScript
159 lines
4.1 KiB
TypeScript
// app/components/ui/Modal.tsx
|
|
import React, { useEffect, useRef } from 'react';
|
|
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;
|
|
|
|
return (
|
|
<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>
|
|
);
|
|
} |