返回 AiToEarn
dialog.tsx
根目录 / project / aitoearn-web / src / components / ui / dialog.tsx
1 'use client'
2
3 import * as DialogPrimitive from '@radix-ui/react-dialog'
4 import { X } from 'lucide-react'
5 import * as React from 'react'
6
7 import { cn } from '@/utils/className'
8
9 const Dialog = DialogPrimitive.Root
10
11 const DialogTrigger = DialogPrimitive.Trigger
12
13 const DialogPortal = DialogPrimitive.Portal
14
15 const DialogClose = DialogPrimitive.Close
16
17 const DialogOverlay = React.forwardRef<
18 React.ElementRef<typeof DialogPrimitive.Overlay>,
19 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
20 >(({ className, ...props }, ref) => (
21 <DialogPrimitive.Overlay
22 ref={ref}
23 className={cn(
24 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-500',
25 className,
26 )}
27 {...props}
28 />
29 ))
30 DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31
32 interface DialogContentProps extends React.ComponentPropsWithoutRef<
33 typeof DialogPrimitive.Content
34 > {
35 overlayStyle?: React.CSSProperties
36 overlayClassName?: string
37 hideCloseButton?: boolean
38 /** 禁用焦点陷阱,用于解决嵌套 Dialog 的焦点冲突问题 */
39 disableFocusTrap?: boolean
40 }
41
42 const DialogContent = React.forwardRef<
43 React.ElementRef<typeof DialogPrimitive.Content>,
44 DialogContentProps
45 >(
46 (
47 {
48 className,
49 children,
50 forceMount,
51 overlayStyle,
52 overlayClassName,
53 hideCloseButton,
54 disableFocusTrap,
55 ...props
56 },
57 ref,
58 ) => {
59 // 嵌套 Dialog 焦点修复:当 disableFocusTrap 为 true 时,阻止所有焦点相关事件
60 const preventEvent = (e: Event) => e.preventDefault()
61
62 return (
63 <DialogPortal forceMount={forceMount}>
64 <DialogOverlay className={overlayClassName} style={overlayStyle} />
65 <DialogPrimitive.Content
66 ref={ref}
67 forceMount={forceMount}
68 // 抑制 Radix UI 的 Description 警告,允许 Dialog 没有 Description
69 aria-describedby={props['aria-describedby'] ?? undefined}
70 // 允许 Radix 的默认自动聚焦行为,确保 Dialog 内的 Input 等元素可以正常获取焦点
71 tabIndex={-1}
72 onOpenAutoFocus={disableFocusTrap ? preventEvent : props.onOpenAutoFocus}
73 onPointerDownOutside={disableFocusTrap ? preventEvent : props.onPointerDownOutside}
74 onInteractOutside={disableFocusTrap ? preventEvent : props.onInteractOutside}
75 className={cn(
76 // 改造说明:
77 // - 保持移动端默认行为:使用 `w-[calc(100%-24px)]` 使弹窗左右留出 12px 边距
78 // - 对于更大的视口,使用 min(maxWidth, 95vw) 策略,避免被 `sm:w-full` 强制撑满或过窄
79 // - 这样可以兼顾移动端和桌面大屏的显示效果,且无需在各处强制覆盖
80 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-24px)] sm:w-[min(1100px,95vw)] min-h-[200px] sm:min-h-0 translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-4 sm:p-6 shadow-2xl duration-500 ease-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl',
81 className,
82 )}
83 {...props}
84 >
85 {children}
86 {!hideCloseButton && (
87 <DialogPrimitive.Close className="absolute right-3 top-3 sm:right-4 sm:top-4 rounded-md opacity-70 ring-offset-background transition-all hover:opacity-100 hover:bg-accent p-2 sm:p-1 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
88 <X className="h-4 w-4" />
89 <span className="sr-only">Close</span>
90 </DialogPrimitive.Close>
91 )}
92 </DialogPrimitive.Content>
93 </DialogPortal>
94 )
95 },
96 )
97 DialogContent.displayName = DialogPrimitive.Content.displayName
98
99 function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
100 return (
101 <div
102 className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
103 {...props}
104 />
105 )
106 }
107 DialogHeader.displayName = 'DialogHeader'
108
109 function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
110 return (
111 <div
112 className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2', className)}
113 {...props}
114 />
115 )
116 }
117 DialogFooter.displayName = 'DialogFooter'
118
119 /**
120 * Dialog 内容区域组件
121 * 用于解决 overflow-y-auto 裁剪 focus ring 的问题
122 * 原理:负 margin 扩展滚动容器边界,正 padding 恢复内容视觉边距
123 */
124 function DialogBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
125 return (
126 <div
127 className={cn(
128 // 负 margin 扩展滚动容器边界,让 focus ring 有空间显示
129 '-mx-4 sm:-mx-6',
130 // 正 padding 恢复内容的视觉边距
131 'px-4 sm:px-6',
132 // 滚动相关样式
133 'min-h-0 flex-1 overflow-y-auto',
134 className,
135 )}
136 {...props}
137 />
138 )
139 }
140 DialogBody.displayName = 'DialogBody'
141
142 const DialogTitle = React.forwardRef<
143 React.ElementRef<typeof DialogPrimitive.Title>,
144 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
145 >(({ className, ...props }, ref) => (
146 <DialogPrimitive.Title
147 ref={ref}
148 className={cn('text-lg font-semibold leading-none tracking-tight', className)}
149 {...props}
150 />
151 ))
152 DialogTitle.displayName = DialogPrimitive.Title.displayName
153
154 const DialogDescription = React.forwardRef<
155 React.ElementRef<typeof DialogPrimitive.Description>,
156 React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
157 >(({ className, ...props }, ref) => (
158 <DialogPrimitive.Description
159 ref={ref}
160 className={cn('text-sm text-muted-foreground', className)}
161 {...props}
162 />
163 ))
164 DialogDescription.displayName = DialogPrimitive.Description.displayName
165
166 export {
167 Dialog,
168 DialogBody,
169 DialogClose,
170 DialogContent,
171 DialogDescription,
172 DialogFooter,
173 DialogHeader,
174 DialogOverlay,
175 DialogPortal,
176 DialogTitle,
177 DialogTrigger,
178 }
179
179 lines Plain Text