您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. // src/components/ui/modal.tsx
  2. import * as DialogPrimitive from '@radix-ui/react-dialog';
  3. import { Loader, X } from 'lucide-react';
  4. import { FC, ReactNode, useCallback, useEffect, useMemo } from 'react';
  5. import { useTranslation } from 'react-i18next';
  6. interface ModalProps {
  7. open: boolean;
  8. onOpenChange?: (open: boolean) => void;
  9. title?: ReactNode;
  10. children: ReactNode;
  11. footer?: ReactNode;
  12. className?: string;
  13. size?: 'small' | 'default' | 'large';
  14. closable?: boolean;
  15. closeIcon?: ReactNode;
  16. maskClosable?: boolean;
  17. destroyOnClose?: boolean;
  18. full?: boolean;
  19. confirmLoading?: boolean;
  20. cancelText?: ReactNode | string;
  21. okText?: ReactNode | string;
  22. onOk?: () => void;
  23. onCancel?: () => void;
  24. }
  25. export const Modal: FC<ModalProps> = ({
  26. open,
  27. onOpenChange,
  28. title,
  29. children,
  30. footer,
  31. className = '',
  32. size = 'default',
  33. closable = true,
  34. closeIcon = <X className="w-4 h-4" />,
  35. maskClosable = true,
  36. destroyOnClose = false,
  37. full = false,
  38. onOk,
  39. onCancel,
  40. confirmLoading,
  41. cancelText,
  42. okText,
  43. }) => {
  44. const sizeClasses = {
  45. small: 'max-w-md',
  46. default: 'max-w-2xl',
  47. large: 'max-w-4xl',
  48. };
  49. const { t } = useTranslation();
  50. // Handle ESC key close
  51. useEffect(() => {
  52. const handleKeyDown = (e: KeyboardEvent) => {
  53. if (e.key === 'Escape' && maskClosable) {
  54. onOpenChange?.(false);
  55. }
  56. };
  57. window.addEventListener('keydown', handleKeyDown);
  58. return () => window.removeEventListener('keydown', handleKeyDown);
  59. }, [maskClosable, onOpenChange]);
  60. const handleCancel = useCallback(() => {
  61. onOpenChange?.(false);
  62. onCancel?.();
  63. }, [onOpenChange, onCancel]);
  64. const handleOk = useCallback(() => {
  65. onOpenChange?.(true);
  66. onOk?.();
  67. }, [onOpenChange, onOk]);
  68. const handleChange = (open: boolean) => {
  69. onOpenChange?.(open);
  70. if (open) {
  71. handleOk();
  72. }
  73. if (!open) {
  74. handleCancel();
  75. }
  76. };
  77. const footEl = useMemo(() => {
  78. let footerTemp;
  79. if (footer) {
  80. footerTemp = footer;
  81. } else {
  82. footerTemp = (
  83. <div className="flex justify-end gap-2">
  84. <button
  85. type="button"
  86. onClick={() => handleCancel()}
  87. className="px-2 py-1 border border-input rounded-md hover:bg-muted"
  88. >
  89. {cancelText ?? t('modal.cancelText')}
  90. </button>
  91. <button
  92. type="button"
  93. disabled={confirmLoading}
  94. onClick={() => handleOk()}
  95. className="px-2 py-1 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
  96. >
  97. {confirmLoading && (
  98. <Loader className="inline-block mr-2 h-4 w-4 animate-spin" />
  99. )}
  100. {okText ?? t('modal.okText')}
  101. </button>
  102. </div>
  103. );
  104. }
  105. return (
  106. <div className="flex items-center justify-end border-t border-border px-6 py-4">
  107. {footerTemp}
  108. </div>
  109. );
  110. }, [footer, cancelText, t, confirmLoading, okText, handleCancel, handleOk]);
  111. return (
  112. <DialogPrimitive.Root open={open} onOpenChange={handleChange}>
  113. <DialogPrimitive.Portal>
  114. <DialogPrimitive.Overlay
  115. className="fixed inset-0 z-50 bg-colors-background-neutral-weak/50 backdrop-blur-sm flex items-center justify-center p-4"
  116. onClick={() => maskClosable && onOpenChange?.(false)}
  117. >
  118. <DialogPrimitive.Content
  119. className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-colors-background-neutral-standard rounded-lg shadow-lg transition-all`}
  120. onClick={(e) => e.stopPropagation()}
  121. >
  122. {/* title */}
  123. {title && (
  124. <div className="flex items-center justify-between border-b border-border px-6 py-4">
  125. <DialogPrimitive.Title className="text-lg font-medium text-foreground">
  126. {title}
  127. </DialogPrimitive.Title>
  128. {closable && (
  129. <DialogPrimitive.Close asChild>
  130. <button
  131. type="button"
  132. className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted"
  133. >
  134. {closeIcon}
  135. </button>
  136. </DialogPrimitive.Close>
  137. )}
  138. </div>
  139. )}
  140. {/* content */}
  141. <div className="p-6 overflow-y-auto max-h-[80vh]">
  142. {destroyOnClose && !open ? null : children}
  143. </div>
  144. {/* footer */}
  145. {footEl}
  146. </DialogPrimitive.Content>
  147. </DialogPrimitive.Overlay>
  148. </DialogPrimitive.Portal>
  149. </DialogPrimitive.Root>
  150. );
  151. };
  152. // example usage
  153. /*
  154. import { Modal } from '@/components/ui/modal';
  155. function Demo() {
  156. const [open, setOpen] = useState(false);
  157. return (
  158. <div>
  159. <button onClick={() => setOpen(true)}>open modal</button>
  160. <Modal
  161. open={open}
  162. onOpenChange={setOpen}
  163. title="title"
  164. footer={
  165. <div className="flex gap-2">
  166. <button onClick={() => setOpen(false)} className="px-4 py-2 border rounded-md">
  167. cancel
  168. </button>
  169. <button onClick={() => setOpen(false)} className="px-4 py-2 bg-primary text-white rounded-md">
  170. ok
  171. </button>
  172. </div>
  173. }
  174. >
  175. <div className="py-4">弹窗内容区域</div>
  176. </Modal>
  177. <Modal
  178. title={'modal-title'}
  179. onOk={handleOk}
  180. confirmLoading={loading}
  181. destroyOnClose
  182. >
  183. <div className="py-4">弹窗内容区域</div>
  184. </Modal>
  185. </div>
  186. );
  187. }
  188. */