/** * 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 = { 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(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) => { shouldCloseRef.current = closeOnOverlayClick && e.target === e.currentTarget } const handleOverlayMouseUp = (e: React.MouseEvent) => { 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 = (
e.stopPropagation()} >
{(title || showCloseButton) && (
{title && ( )} {showCloseButton && ( )}
)} {title || showCloseButton || footer ? ( <>
{children}
{footer &&
{footer}
} ) : ( children )}
) if (!isMounted) { return null } return createPortal(modalMarkup, document.body) }