useDialog.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  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 isDialogOpened = (name?: string) =>
  35. name ? dialogsOpened.value.has(name) : dialogsOpened.value.size > 0
  36. export const getDialogMeta = () => {
  37. return {
  38. dialogsOptions,
  39. dialogsOpened,
  40. }
  41. }
  42. const getDialogOptions = (name: string) => {
  43. const options = dialogsOptions.get(name)
  44. if (!options) {
  45. throw new Error(`Dialog '${name}' was not initialized with 'useDialog'`)
  46. }
  47. return options
  48. }
  49. export const openDialog = async (
  50. name: string,
  51. props: Record<string, unknown>,
  52. ) => {
  53. if (dialogsOpened.value.has(name)) return Promise.resolve()
  54. const options = getDialogOptions(name)
  55. dialogsOpened.value.add(name)
  56. if (options.beforeOpen) {
  57. await options.beforeOpen()
  58. }
  59. const component = defineAsyncComponent(
  60. options.component as AsyncComponentLoader,
  61. )
  62. if (options.refocus) {
  63. lastFocusedElements[name] = document.activeElement as HTMLElement
  64. }
  65. await pushComponent('dialog', name, component, props)
  66. return new Promise<void>((resolve) => {
  67. options.component().finally(() => {
  68. resolve()
  69. nextTick(() => {
  70. testFlags.set(`${name}.opened`)
  71. })
  72. })
  73. })
  74. }
  75. export const closeDialog = async (name: string) => {
  76. if (!dialogsOpened.value.has(name)) return
  77. const options = getDialogOptions(name)
  78. await destroyComponent('dialog', name)
  79. dialogsOpened.value.delete(name)
  80. if (options.afterClose) {
  81. await options.afterClose()
  82. }
  83. const lastFocusedElement = lastFocusedElements[name]
  84. if (lastFocusedElement && options.refocus && 'focus' in lastFocusedElement) {
  85. lastFocusedElement.focus({ preventScroll: true })
  86. delete lastFocusedElements[name]
  87. }
  88. nextTick(() => {
  89. testFlags.set(`${name}.closed`)
  90. })
  91. }
  92. export const useDialog = (options: DialogOptions) => {
  93. options.refocus ??= true
  94. dialogsOptions.set(options.name, options as DialogOptions)
  95. const isOpened = computed(() => dialogsOpened.value.has(options.name))
  96. const vm = getCurrentInstance()
  97. if (vm) {
  98. // unmounted happens after setup, if component was unmounted
  99. // so we need to add options again
  100. // this happens mainly in storybook stories
  101. onMounted(() => {
  102. dialogsOptions.set(options.name, options as DialogOptions)
  103. })
  104. onUnmounted(async () => {
  105. await closeDialog(options.name)
  106. dialogsOptions.delete(options.name)
  107. })
  108. }
  109. const open = (props: Record<string, unknown> = {}) => {
  110. return openDialog(options.name, props)
  111. }
  112. const close = () => {
  113. return closeDialog(options.name)
  114. }
  115. const toggle = (props: Record<string, unknown> = {}) => {
  116. if (isOpened.value) {
  117. return closeDialog(options.name)
  118. }
  119. return openDialog(options.name, props)
  120. }
  121. let pendingPrefetch: Promise<unknown>
  122. const prefetch = async () => {
  123. if (pendingPrefetch) return pendingPrefetch
  124. pendingPrefetch = options.component().catch(noop)
  125. return pendingPrefetch
  126. }
  127. if (options.prefetch) {
  128. prefetch()
  129. }
  130. return {
  131. isOpened,
  132. name: options.name,
  133. open,
  134. close,
  135. toggle,
  136. prefetch,
  137. }
  138. }