205 lines
5.5 KiB
TypeScript
205 lines
5.5 KiB
TypeScript
/**
|
||
* Modal 组件
|
||
*
|
||
* 统一的弹窗组件,遵循三层架构设计
|
||
* 支持多种尺寸(small、medium、large、xlarge、fullscreen)
|
||
* 支持ESC键关闭、点击遮罩关闭、键盘导航
|
||
* 完整的ARIA标签支持,确保可访问性
|
||
*
|
||
* @created 2025-11-22
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useRef, ReactNode, CSSProperties, useState } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { useThemeStyles, cn } from '@/lib/ui-styles'
|
||
|
||
export type ModalSize = 'xs' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' | 'fullscreen'
|
||
|
||
const MODAL_BORDER_RADIUS = 32
|
||
|
||
const sizeStyleMap: Record<ModalSize, CSSProperties> = {
|
||
xs: { width: 'min(90vw, 20rem)', maxWidth: '20rem' },
|
||
small: { width: 'min(92vw, 24rem)', maxWidth: '24rem' },
|
||
medium: { width: 'min(94vw, 32rem)', maxWidth: '32rem' },
|
||
large: { width: 'min(95vw, 40rem)', maxWidth: '40rem' },
|
||
xlarge: { width: 'min(96vw, 60rem)', maxWidth: '60rem' },
|
||
xxlarge: { width: 'min(98vw, 80rem)', maxWidth: '80rem' },
|
||
fullscreen: {
|
||
width: '100vw',
|
||
height: '100vh',
|
||
maxWidth: '100vw',
|
||
maxHeight: '100vh',
|
||
},
|
||
}
|
||
|
||
interface ModalProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
title?: string
|
||
children: ReactNode
|
||
size?: ModalSize
|
||
showCloseButton?: boolean
|
||
closeOnOverlayClick?: boolean
|
||
closeOnEscape?: boolean
|
||
footer?: ReactNode
|
||
className?: string
|
||
containerClassName?: string
|
||
containerStyle?: CSSProperties
|
||
contentClassName?: string
|
||
bodyClassName?: string
|
||
bodyStyle?: CSSProperties
|
||
}
|
||
|
||
export default function Modal({
|
||
isOpen,
|
||
onClose,
|
||
title,
|
||
children,
|
||
size = 'medium',
|
||
showCloseButton = true,
|
||
closeOnOverlayClick = true,
|
||
closeOnEscape = true,
|
||
footer,
|
||
className = '',
|
||
containerClassName = '',
|
||
containerStyle,
|
||
contentClassName = '',
|
||
bodyClassName = '',
|
||
bodyStyle,
|
||
}: ModalProps) {
|
||
const [isMounted, setIsMounted] = useState(false)
|
||
const styles = useThemeStyles()
|
||
const modalRef = useRef<HTMLDivElement>(null)
|
||
const shouldCloseRef = useRef(false)
|
||
|
||
// ESC键关闭
|
||
useEffect(() => {
|
||
if (!isOpen || !closeOnEscape) return
|
||
|
||
const handleEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
onClose()
|
||
}
|
||
}
|
||
|
||
document.addEventListener('keydown', handleEscape)
|
||
return () => document.removeEventListener('keydown', handleEscape)
|
||
}, [isOpen, closeOnEscape, onClose])
|
||
|
||
// 阻止body滚动
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
document.body.style.overflow = 'hidden'
|
||
} else {
|
||
document.body.style.overflow = ''
|
||
}
|
||
return () => {
|
||
document.body.style.overflow = ''
|
||
}
|
||
}, [isOpen])
|
||
|
||
useEffect(() => {
|
||
setIsMounted(true)
|
||
}, [])
|
||
|
||
if (!isOpen) return null
|
||
|
||
const handleOverlayMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
shouldCloseRef.current = closeOnOverlayClick && e.target === e.currentTarget
|
||
}
|
||
|
||
const handleOverlayMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (shouldCloseRef.current && e.target === e.currentTarget) {
|
||
onClose()
|
||
}
|
||
shouldCloseRef.current = false
|
||
}
|
||
|
||
const sizeClass = styles.modalStyles.sizes[size]
|
||
const sizeInlineStyle = sizeStyleMap[size] || sizeStyleMap.medium
|
||
const isCustomContent = !title && !showCloseButton && !footer
|
||
const containerInlineStyle: CSSProperties = {
|
||
...sizeInlineStyle,
|
||
borderRadius: MODAL_BORDER_RADIUS,
|
||
...(isCustomContent && { maxHeight: '92vh' }),
|
||
...(containerStyle || {}),
|
||
}
|
||
|
||
const contentInlineStyle: CSSProperties = {
|
||
borderRadius: MODAL_BORDER_RADIUS,
|
||
}
|
||
|
||
const modalMarkup = (
|
||
<div
|
||
className={`${styles.modalStyles.overlay} ${className}`}
|
||
onMouseDown={handleOverlayMouseDown}
|
||
onMouseUp={handleOverlayMouseUp}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={title ? 'modal-title' : undefined}
|
||
>
|
||
<div
|
||
ref={modalRef}
|
||
className={cn(styles.modalStyles.container, sizeClass, containerClassName)}
|
||
style={containerInlineStyle}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className={cn(styles.modalStyles.content, contentClassName)} style={contentInlineStyle}>
|
||
{(title || showCloseButton) && (
|
||
<div className={styles.modalStyles.header}>
|
||
{title && (
|
||
<h3 id="modal-title" className={styles.modalStyles.title}>
|
||
{title}
|
||
</h3>
|
||
)}
|
||
{showCloseButton && (
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className={styles.modalStyles.closeButton}
|
||
aria-label="关闭"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M6 18L18 6M6 6l12 12"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{title || showCloseButton || footer ? (
|
||
<>
|
||
<div className={cn(styles.modalStyles.body, bodyClassName)} style={bodyStyle}>
|
||
{children}
|
||
</div>
|
||
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
|
||
</>
|
||
) : (
|
||
children
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
if (!isMounted) {
|
||
return null
|
||
}
|
||
|
||
return createPortal(modalMarkup, document.body)
|
||
}
|
||
|