modal.vue 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. <script lang="ts" setup>
  2. import type { ExtendedModalApi, ModalProps } from './modal';
  3. import { computed, nextTick, provide, ref, useId, watch } from 'vue';
  4. import {
  5. useIsMobile,
  6. usePriorityValues,
  7. useSimpleLocale,
  8. } from '@vben-core/composables';
  9. import { Expand, Shrink } from '@vben-core/icons';
  10. import {
  11. Dialog,
  12. DialogContent,
  13. DialogDescription,
  14. DialogFooter,
  15. DialogHeader,
  16. DialogTitle,
  17. VbenButton,
  18. VbenHelpTooltip,
  19. VbenIconButton,
  20. VbenLoading,
  21. VisuallyHidden,
  22. } from '@vben-core/shadcn-ui';
  23. import { globalShareState } from '@vben-core/shared/global-state';
  24. import { cn } from '@vben-core/shared/utils';
  25. import { useModalDraggable } from './use-modal-draggable';
  26. interface Props extends ModalProps {
  27. modalApi?: ExtendedModalApi;
  28. }
  29. const props = withDefaults(defineProps<Props>(), {
  30. modalApi: undefined,
  31. });
  32. const components = globalShareState.getComponents();
  33. const contentRef = ref();
  34. const wrapperRef = ref<HTMLElement>();
  35. const dialogRef = ref();
  36. const headerRef = ref();
  37. const footerRef = ref();
  38. const id = useId();
  39. provide('DISMISSABLE_MODAL_ID', id);
  40. const { $t } = useSimpleLocale();
  41. const { isMobile } = useIsMobile();
  42. const state = props.modalApi?.useStore?.();
  43. const {
  44. bordered,
  45. cancelText,
  46. centered,
  47. class: modalClass,
  48. closable,
  49. closeOnClickModal,
  50. closeOnPressEscape,
  51. confirmLoading,
  52. confirmText,
  53. contentClass,
  54. description,
  55. draggable,
  56. footer: showFooter,
  57. footerClass,
  58. fullscreen,
  59. fullscreenButton,
  60. header,
  61. headerClass,
  62. loading: showLoading,
  63. modal,
  64. openAutoFocus,
  65. showCancelButton,
  66. showConfirmButton,
  67. title,
  68. titleTooltip,
  69. } = usePriorityValues(props, state);
  70. const shouldFullscreen = computed(
  71. () => (fullscreen.value && header.value) || isMobile.value,
  72. );
  73. const shouldDraggable = computed(
  74. () => draggable.value && !shouldFullscreen.value && header.value,
  75. );
  76. const { dragging, transform } = useModalDraggable(
  77. dialogRef,
  78. headerRef,
  79. shouldDraggable,
  80. );
  81. watch(
  82. () => state?.value?.isOpen,
  83. async (v) => {
  84. if (v) {
  85. await nextTick();
  86. if (!contentRef.value) return;
  87. const innerContentRef = contentRef.value.getContentRef();
  88. dialogRef.value = innerContentRef.$el;
  89. // reopen modal reassign value
  90. const { offsetX, offsetY } = transform;
  91. dialogRef.value.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
  92. }
  93. },
  94. );
  95. watch(
  96. () => showLoading.value,
  97. (v) => {
  98. if (v && wrapperRef.value) {
  99. wrapperRef.value.scrollTo({
  100. // behavior: 'smooth',
  101. top: 0,
  102. });
  103. }
  104. },
  105. );
  106. function handleFullscreen() {
  107. props.modalApi?.setState((prev) => {
  108. // if (prev.fullscreen) {
  109. // resetPosition();
  110. // }
  111. return { ...prev, fullscreen: !fullscreen.value };
  112. });
  113. }
  114. function interactOutside(e: Event) {
  115. if (!closeOnClickModal.value) {
  116. e.preventDefault();
  117. e.stopPropagation();
  118. }
  119. }
  120. function escapeKeyDown(e: KeyboardEvent) {
  121. if (!closeOnPressEscape.value) {
  122. e.preventDefault();
  123. }
  124. }
  125. function handerOpenAutoFocus(e: Event) {
  126. if (!openAutoFocus.value) {
  127. e?.preventDefault();
  128. }
  129. }
  130. // pointer-down-outside
  131. function pointerDownOutside(e: Event) {
  132. const target = e.target as HTMLElement;
  133. const isDismissableModal = target?.dataset.dismissableModal;
  134. if (!closeOnClickModal.value || isDismissableModal !== id) {
  135. e.preventDefault();
  136. e.stopPropagation();
  137. }
  138. }
  139. function handleFocusOutside(e: Event) {
  140. e.preventDefault();
  141. e.stopPropagation();
  142. }
  143. </script>
  144. <template>
  145. <Dialog
  146. :modal="false"
  147. :open="state?.isOpen"
  148. @update:open="() => modalApi?.close()"
  149. >
  150. <DialogContent
  151. ref="contentRef"
  152. :class="
  153. cn(
  154. 'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0 sm:rounded-2xl',
  155. modalClass,
  156. {
  157. 'border-border border': bordered,
  158. 'shadow-3xl': !bordered,
  159. 'left-0 top-0 size-full max-h-full !translate-x-0 !translate-y-0':
  160. shouldFullscreen,
  161. 'top-1/2 !-translate-y-1/2': centered && !shouldFullscreen,
  162. 'duration-300': !dragging,
  163. },
  164. )
  165. "
  166. :modal="modal"
  167. :open="state?.isOpen"
  168. :show-close="closable"
  169. close-class="top-3"
  170. @close-auto-focus="handleFocusOutside"
  171. @escape-key-down="escapeKeyDown"
  172. @focus-outside="handleFocusOutside"
  173. @interact-outside="interactOutside"
  174. @open-auto-focus="handerOpenAutoFocus"
  175. @pointer-down-outside="pointerDownOutside"
  176. >
  177. <DialogHeader
  178. ref="headerRef"
  179. :class="
  180. cn(
  181. 'px-5 py-4',
  182. {
  183. 'border-b': bordered,
  184. hidden: !header,
  185. 'cursor-move select-none': shouldDraggable,
  186. },
  187. headerClass,
  188. )
  189. "
  190. >
  191. <DialogTitle v-if="title" class="text-left">
  192. <slot name="title">
  193. {{ title }}
  194. <slot v-if="titleTooltip" name="titleTooltip">
  195. <VbenHelpTooltip trigger-class="pb-1">
  196. {{ titleTooltip }}
  197. </VbenHelpTooltip>
  198. </slot>
  199. </slot>
  200. </DialogTitle>
  201. <DialogDescription v-if="description">
  202. <slot name="description">
  203. {{ description }}
  204. </slot>
  205. </DialogDescription>
  206. <VisuallyHidden v-if="!title || !description">
  207. <DialogTitle v-if="!title" />
  208. <DialogDescription v-if="!description" />
  209. </VisuallyHidden>
  210. </DialogHeader>
  211. <div
  212. ref="wrapperRef"
  213. :class="
  214. cn('relative min-h-40 flex-1 overflow-y-auto p-3', contentClass, {
  215. 'overflow-hidden': showLoading,
  216. })
  217. "
  218. >
  219. <VbenLoading
  220. v-if="showLoading"
  221. class="size-full h-auto min-h-full"
  222. spinning
  223. />
  224. <slot></slot>
  225. </div>
  226. <VbenIconButton
  227. v-if="fullscreenButton"
  228. class="hover:bg-accent hover:text-accent-foreground text-foreground/80 flex-center absolute right-10 top-3 hidden size-6 rounded-full px-1 text-lg opacity-70 transition-opacity hover:opacity-100 focus:outline-none disabled:pointer-events-none sm:block"
  229. @click="handleFullscreen"
  230. >
  231. <Shrink v-if="fullscreen" class="size-3.5" />
  232. <Expand v-else class="size-3.5" />
  233. </VbenIconButton>
  234. <DialogFooter
  235. v-if="showFooter"
  236. ref="footerRef"
  237. :class="
  238. cn(
  239. 'flex-row items-center justify-end p-2',
  240. {
  241. 'border-t': bordered,
  242. },
  243. footerClass,
  244. )
  245. "
  246. >
  247. <slot name="prepend-footer"></slot>
  248. <slot name="footer">
  249. <component
  250. :is="components.DefaultButton || VbenButton"
  251. v-if="showCancelButton"
  252. variant="ghost"
  253. @click="() => modalApi?.onCancel()"
  254. >
  255. <slot name="cancelText">
  256. {{ cancelText || $t('cancel') }}
  257. </slot>
  258. </component>
  259. <component
  260. :is="components.PrimaryButton || VbenButton"
  261. v-if="showConfirmButton"
  262. :loading="confirmLoading"
  263. @click="() => modalApi?.onConfirm()"
  264. >
  265. <slot name="confirmText">
  266. {{ confirmText || $t('confirm') }}
  267. </slot>
  268. </component>
  269. </slot>
  270. <slot name="append-footer"></slot>
  271. </DialogFooter>
  272. </DialogContent>
  273. </Dialog>
  274. </template>