| 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 |