useDialog.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. import {
  3. destroyComponent,
  4. pushComponent,
  5. } from '@shared/components/DynamicInitializer/manage'
  6. import { noop } from 'lodash-es'
  7. import {
  8. computed,
  9. defineAsyncComponent,
  10. ref,
  11. onUnmounted,
  12. getCurrentInstance,
  13. onMounted,
  14. nextTick,
  15. } from 'vue'
  16. import type { AsyncComponentLoader, Component } from 'vue'
  17. import testFlags from '@shared/utils/testFlags'
  18. interface DialogOptions {
  19. name: string
  20. component: () => Promise<Component>
  21. prefetch?: boolean
  22. /**
  23. * If true, dialog will focus the element that opened it.
  24. * If dialog is opened without a user interaction, you should set it to false.
  25. * @default true
  26. */
  27. refocus?: boolean
  28. beforeOpen?: () => Awaited<unknown>
  29. afterClose?: () => Awaited<unknown>
  30. }
  31. const dialogsOptions = new Map<string, DialogOptions>()
  32. const dialogsOpened = ref(new Set<string>())
  33. const lastFocusedElements: Record<string, HTMLElement> = {}
  34. export const getDialogMeta = () => {
  35. return {
  36. dialogsOptions,
  37. dialogsOpened,
  38. }
  39. }
  40. const getDialogOptions = (name: string) => {
  41. const options = dialogsOptions.get(name)
  42. if (!options) {
  43. throw new Error(`Dialog '${name}' was not initialized with 'useDialog'`)
  44. }
  45. return options
  46. }
  47. export const openDialog = async (
  48. name: string,
  49. props: Record<string, unknown>,
  50. ) => {
  51. if (dialogsOpened.value.has(name)) return Promise.resolve()
  52. const options = getDialogOptions(name)
  53. dialogsOpened.value.add(name)
  54. if (options.beforeOpen) {
  55. await options.beforeOpen()
  56. }
  57. const component = defineAsyncComponent(
  58. options.component as AsyncComponentLoader,
  59. )
  60. if (options.refocus) {
  61. lastFocusedElements[name] = document.activeElement as HTMLElement
  62. }
  63. await pushComponent('dialog', name, component, props)
  64. return new Promise<void>((resolve) => {
  65. options.component().finally(() => {
  66. resolve()
  67. nextTick(() => {
  68. testFlags.set(`${name}.opened`)
  69. })
  70. })
  71. })
  72. }
  73. export const closeDialog = async (name: string) => {
  74. if (!dialogsOpened.value.has(name)) return
  75. const options = getDialogOptions(name)
  76. await destroyComponent('dialog', name)
  77. dialogsOpened.value.delete(name)
  78. if (options.afterClose) {
  79. await options.afterClose()
  80. }
  81. const lastFocusedElement = lastFocusedElements[name]
  82. if (lastFocusedElement && options.refocus && 'focus' in lastFocusedElement) {
  83. lastFocusedElement.focus({ preventScroll: true })
  84. delete lastFocusedElements[name]
  85. }
  86. nextTick(() => {
  87. testFlags.set(`${name}.closed`)
  88. })
  89. }
  90. export const useDialog = (options: DialogOptions) => {
  91. options.refocus ??= true
  92. dialogsOptions.set(options.name, options as DialogOptions)
  93. const isOpened = computed(() => dialogsOpened.value.has(options.name))
  94. const vm = getCurrentInstance()
  95. if (vm) {
  96. // unmounted happens after setup, if component was unmounted
  97. // so we need to add options again
  98. // this happens mainly in storybook stories
  99. onMounted(() => {
  100. dialogsOptions.set(options.name, options as DialogOptions)
  101. })
  102. onUnmounted(async () => {
  103. await closeDialog(options.name)
  104. dialogsOptions.delete(options.name)
  105. })
  106. }
  107. const open = (props: Record<string, unknown> = {}) => {
  108. return openDialog(options.name, props)
  109. }
  110. const close = () => {
  111. return closeDialog(options.name)
  112. }
  113. const toggle = (props: Record<string, unknown> = {}) => {
  114. if (isOpened.value) {
  115. return closeDialog(options.name)
  116. }
  117. return openDialog(options.name, props)
  118. }
  119. let pendingPrefetch: Promise<unknown>
  120. const prefetch = async () => {
  121. if (pendingPrefetch) return pendingPrefetch
  122. pendingPrefetch = options.component().catch(noop)
  123. return pendingPrefetch
  124. }
  125. if (options.prefetch) {
  126. prefetch()
  127. }
  128. return {
  129. isOpened,
  130. name: options.name,
  131. open,
  132. close,
  133. toggle,
  134. prefetch,
  135. }
  136. }