useDialog.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { noop } from 'lodash-es'
  3. import {
  4. computed,
  5. defineAsyncComponent,
  6. ref,
  7. onUnmounted,
  8. getCurrentInstance,
  9. onMounted,
  10. nextTick,
  11. } from 'vue'
  12. import {
  13. destroyComponent,
  14. pushComponent,
  15. } from '#shared/components/DynamicInitializer/manage.ts'
  16. import testFlags from '#shared/utils/testFlags.ts'
  17. import type { AsyncComponentLoader, Component } from 'vue'
  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 mounted = new Set<string>()
  32. const dialogsOptions = new Map<string, DialogOptions>()
  33. const dialogsOpened = ref(new Set<string>())
  34. const lastFocusedElements: Record<string, HTMLElement> = {}
  35. export const getOpenedDialogs = () => dialogsOpened.value
  36. export const isDialogOpened = (name?: string) =>
  37. name ? dialogsOpened.value.has(name) : dialogsOpened.value.size > 0
  38. export const getDialogMeta = () => {
  39. return {
  40. dialogsOptions,
  41. dialogsOpened,
  42. }
  43. }
  44. const getDialogOptions = (name: string) => {
  45. const options = dialogsOptions.get(name)
  46. if (!options) {
  47. throw new Error(`Dialog '${name}' was not initialized with 'useDialog'`)
  48. }
  49. return options
  50. }
  51. export const openDialog = async (
  52. name: string,
  53. props: Record<string, unknown>,
  54. ) => {
  55. if (dialogsOpened.value.has(name)) return Promise.resolve()
  56. const options = getDialogOptions(name)
  57. if (options.refocus) {
  58. lastFocusedElements[name] = document.activeElement as HTMLElement
  59. }
  60. dialogsOpened.value.add(name)
  61. if (options.beforeOpen) {
  62. await options.beforeOpen()
  63. }
  64. const component = defineAsyncComponent(
  65. options.component as AsyncComponentLoader,
  66. )
  67. await pushComponent('dialog', name, component, props)
  68. return new Promise<void>((resolve) => {
  69. options.component().finally(() => {
  70. resolve()
  71. nextTick(() => {
  72. testFlags.set(`${name}.opened`)
  73. })
  74. })
  75. })
  76. }
  77. export const closeDialog = async (name: string) => {
  78. if (!dialogsOpened.value.has(name)) return
  79. const options = getDialogOptions(name)
  80. await destroyComponent('dialog', name)
  81. dialogsOpened.value.delete(name)
  82. if (options.afterClose) {
  83. await options.afterClose()
  84. }
  85. const controllerElement =
  86. (document.querySelector(
  87. `[aria-haspopup="dialog"][aria-controls="dialog-${name}"]`,
  88. ) as HTMLElement | null) || lastFocusedElements[name]
  89. if (controllerElement && 'focus' in controllerElement)
  90. controllerElement.focus({ preventScroll: true })
  91. nextTick(() => {
  92. testFlags.set(`${name}.closed`)
  93. })
  94. }
  95. export const useDialog = (options: DialogOptions) => {
  96. options.refocus ??= true
  97. dialogsOptions.set(options.name, options as DialogOptions)
  98. const isOpened = computed(() => dialogsOpened.value.has(options.name))
  99. const vm = getCurrentInstance()
  100. if (vm) {
  101. // unmounted happens after setup, if component was unmounted
  102. // so we need to add options again
  103. // this happens mainly in storybook stories
  104. onMounted(() => {
  105. mounted.add(options.name)
  106. dialogsOptions.set(options.name, options as DialogOptions)
  107. })
  108. onUnmounted(async () => {
  109. mounted.delete(options.name)
  110. await closeDialog(options.name)
  111. // was mounted during hmr
  112. if (!mounted.has(options.name)) {
  113. dialogsOptions.delete(options.name)
  114. }
  115. })
  116. }
  117. const open = (props: Record<string, unknown> = {}) => {
  118. return openDialog(options.name, props)
  119. }
  120. const close = () => {
  121. return closeDialog(options.name)
  122. }
  123. const toggle = (props: Record<string, unknown> = {}) => {
  124. if (isOpened.value) {
  125. return closeDialog(options.name)
  126. }
  127. return openDialog(options.name, props)
  128. }
  129. let pendingPrefetch: Promise<unknown>
  130. const prefetch = async () => {
  131. if (pendingPrefetch) return pendingPrefetch
  132. pendingPrefetch = options.component().catch(noop)
  133. return pendingPrefetch
  134. }
  135. if (options.prefetch) {
  136. prefetch()
  137. }
  138. return {
  139. isOpened,
  140. name: options.name,
  141. open,
  142. close,
  143. toggle,
  144. prefetch,
  145. }
  146. }