flnxi-Wu/components/ui/Modal.tsx
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

185 lines
4.9 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 } 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
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'medium',
showCloseButton = true,
closeOnOverlayClick = true,
closeOnEscape = true,
footer,
className = '',
}: ModalProps) {
const [isMounted, setIsMounted] = useState(false)
const styles = useThemeStyles()
const modalRef = useRef<HTMLDivElement>(null)
// 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 handleOverlayClick = (e: React.MouseEvent) => {
if (closeOnOverlayClick && e.target === e.currentTarget) {
onClose()
}
}
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' }),
}
const contentInlineStyle: CSSProperties = {
borderRadius: MODAL_BORDER_RADIUS,
}
const modalMarkup = (
<div
className={`${styles.modalStyles.overlay} ${className}`}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'modal-title' : undefined}
>
<div
ref={modalRef}
className={`${styles.modalStyles.container} ${sizeClass}`}
style={containerInlineStyle}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.modalStyles.content} 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={styles.modalStyles.body}>{children}</div>
{footer && <div className={styles.modalStyles.footer}>{footer}</div>}
</>
) : (
children
)}
</div>
</div>
</div>
)
if (!isMounted) {
return null
}
return createPortal(modalMarkup, document.body)
}