返回 AiToEarn
modal.tsx
根目录 / project / aitoearn-web / src / components / ui / modal.tsx
1 /**
2 * Modal - 通用模态框组件
3 * 基于 shadcn/ui Dialog 封装,提供与 antd Modal 类似的 API
4 */
5
6 'use client'
7
8 import * as React from 'react'
9 import { Button } from '@/components/ui/button'
10 import {
11 Dialog,
12 DialogContent,
13 DialogDescription,
14 DialogFooter,
15 DialogHeader,
16 DialogTitle,
17 } from '@/components/ui/dialog'
18 import { cn } from '@/utils/className'
19
20 export interface ModalProps {
21 /** 是否显示模态框 */
22 open?: boolean
23 /** 标题 */
24 title?: React.ReactNode
25 /** 子内容 */
26 children?: React.ReactNode
27 /** 底部按钮区域,传入 null 则不显示 */
28 footer?: React.ReactNode | null
29 /** 确认按钮文字 */
30 okText?: string
31 /** 取消按钮文字 */
32 cancelText?: string
33 /** 确认按钮 loading */
34 confirmLoading?: boolean
35 /** 宽度,支持数字(px)或字符串 */
36 width?: number | string
37 /** 是否居中显示 */
38 centered?: boolean
39 /** 关闭时是否销毁子元素 */
40 destroyOnClose?: boolean
41 /** 是否显示关闭按钮 */
42 closable?: boolean
43 /** 点击蒙层是否允许关闭 */
44 maskClosable?: boolean
45 /** 关闭时回调 */
46 onCancel?: () => void
47 /** 点击确定回调 */
48 onOk?: () => void
49 /** 自定义类名 */
50 className?: string
51 /** 内容弹层样式 */
52 contentStyle?: React.CSSProperties
53 /** 内容区域类名 */
54 bodyClassName?: string
55 /** 遮罩层类名 */
56 overlayClassName?: string
57 /** 遮罩层样式 */
58 overlayStyle?: React.CSSProperties
59 /** z-index */
60 zIndex?: number
61 /** 是否启用 Radix modal 模式 */
62 modal?: boolean
63 /** 禁用焦点自动捕获与外部交互事件 */
64 disableFocusTrap?: boolean
65 }
66
67 /**
68 * Modal 模态框组件
69 *
70 * @example
71 * ```tsx
72 * import { Modal } from '@/components/ui/modal'
73 *
74 * <Modal
75 * open={visible}
76 * title="标题"
77 * onCancel={() => setVisible(false)}
78 * onOk={handleSubmit}
79 * >
80 * 内容
81 * </Modal>
82 * ```
83 */
84 export const Modal: React.FC<ModalProps> = ({
85 open = false,
86 title,
87 children,
88 footer,
89 okText = '确定',
90 cancelText = '取消',
91 confirmLoading = false,
92 width = 520,
93 centered = true,
94 destroyOnClose = false,
95 closable = true,
96 maskClosable = true,
97 onCancel,
98 onOk,
99 className,
100 contentStyle,
101 bodyClassName,
102 overlayClassName,
103 overlayStyle,
104 zIndex,
105 modal = true,
106 disableFocusTrap,
107 }) => {
108 // 处理宽度 - 'auto' 或 undefined 时不设置内联宽度,让 CSS 控制
109 // 使用 CSS 变量来允许媒体查询覆盖
110 const shouldSetWidthStyle = width !== 'auto' && width !== undefined
111 const widthValue = typeof width === 'number' ? `${width}px` : width
112
113 // 如果 destroyOnClose 为 true 且 modal 关闭,不渲染 children
114 const shouldRenderContent = destroyOnClose ? open : true
115
116 // 默认 footer
117 const defaultFooter = (
118 <>
119 <Button variant="outline" onClick={onCancel}>
120 {cancelText}
121 </Button>
122 <Button onClick={onOk} disabled={confirmLoading} loading={confirmLoading}>
123 {okText}
124 </Button>
125 </>
126 )
127
128 const handleOpenChange = (isOpen: boolean) => {
129 if (!isOpen) {
130 onCancel?.()
131 }
132 }
133
134 const handleInteractOutside = (e: Event) => {
135 if (!maskClosable) {
136 e.preventDefault()
137 }
138 }
139
140 // 计算 overlay 的 z-index(比 content 小 1)
141 const overlayZIndex = zIndex ? zIndex - 1 : undefined
142 const mergedOverlayStyle = overlayStyle || overlayZIndex
143 ? {
144 ...overlayStyle,
145 ...(overlayZIndex ? { zIndex: overlayZIndex } : {}),
146 }
147 : undefined
148
149 return (
150 <Dialog open={open} onOpenChange={handleOpenChange} modal={modal}>
151 <DialogContent
152 className={cn(
153 'max-h-[90vh] overflow-hidden flex flex-col',
154 // 移动端响应式宽度
155 'w-[calc(100vw-24px)] sm:w-auto',
156 !closable && '[&>button]:hidden',
157 className,
158 )}
159 style={
160 {
161 // 使用 CSS 变量,允许媒体查询覆盖
162 '--modal-width': shouldSetWidthStyle ? widthValue : undefined,
163 'width': shouldSetWidthStyle ? 'min(var(--modal-width), calc(100vw - 24px))' : undefined,
164 'maxWidth': shouldSetWidthStyle
165 ? 'min(var(--modal-width), calc(100vw - 24px))'
166 : undefined,
167 ...(zIndex ? { zIndex } : {}),
168 ...contentStyle,
169 } as React.CSSProperties
170 }
171 overlayClassName={overlayClassName}
172 overlayStyle={mergedOverlayStyle}
173 disableFocusTrap={disableFocusTrap}
174 onInteractOutside={handleInteractOutside}
175 onEscapeKeyDown={!closable ? e => e.preventDefault() : undefined}
176 >
177 {title ? (
178 <DialogHeader>
179 <DialogTitle>{title}</DialogTitle>
180 {/* 隐藏的描述,用于满足无障碍要求 */}
181 <DialogDescription className="sr-only">
182 {typeof title === 'string' ? title : 'Modal dialog'}
183 </DialogDescription>
184 </DialogHeader>
185 ) : (
186 <>
187 {/* 无标题时也需要隐藏的标题和描述 */}
188 <DialogTitle className="sr-only">Dialog</DialogTitle>
189 <DialogDescription className="sr-only">Dialog content</DialogDescription>
190 </>
191 )}
192
193 {shouldRenderContent && (
194 <div className={cn(
195 // 负 margin 扩展滚动容器边界,让 focus ring 有空间显示
196 '-mx-4 sm:-mx-6',
197 // 正 padding 恢复内容的视觉边距
198 'px-4 sm:px-6',
199 // 原有样式(移除 w-full,依赖 flex 容器撑开)
200 'flex-1 py-2 flex flex-col overflow-y-auto',
201 bodyClassName,
202 )}
203 >
204 {children}
205 </div>
206 )}
207
208 {footer !== null && (
209 <DialogFooter>{footer === undefined ? defaultFooter : footer}</DialogFooter>
210 )}
211 </DialogContent>
212 </Dialog>
213 )
214 }
215
216 // 为了与 antd Modal 使用方式兼容,也导出一个 default
217 export default Modal
218
218 lines Plain Text