flnxi-Wu/components/ui/Modal.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

205 lines
5.5 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.

/**
* 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)
}