useOverlayContainer.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { last, 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, Ref } from 'vue'
  18. export interface OverlayContainerOptions {
  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. export type OverlayContainerType = 'dialog' | 'flyout'
  32. export interface OverlayContainerMeta {
  33. mounted: Set<string>
  34. options: Map<string, OverlayContainerOptions>
  35. opened: Ref<Set<string>>
  36. lastFocusedElements: Record<string, HTMLElement>
  37. }
  38. const overlayContainerMeta: Record<OverlayContainerType, OverlayContainerMeta> =
  39. {
  40. dialog: {
  41. mounted: new Set<string>(),
  42. options: new Map<string, OverlayContainerOptions>(),
  43. opened: ref(new Set<string>()),
  44. lastFocusedElements: {},
  45. },
  46. flyout: {
  47. mounted: new Set<string>(),
  48. options: new Map<string, OverlayContainerOptions>(),
  49. opened: ref(new Set<string>()),
  50. lastFocusedElements: {},
  51. },
  52. }
  53. export const getOpenedOverlayContainers = (type: OverlayContainerType) =>
  54. overlayContainerMeta[type].opened.value
  55. export const isOverlayContainerOpened = (
  56. type: OverlayContainerType,
  57. name?: string,
  58. ) =>
  59. name
  60. ? overlayContainerMeta[type].opened.value.has(name)
  61. : overlayContainerMeta[type].opened.value.size > 0
  62. export const currentOverlayContainersOpen = computed(() => {
  63. const openContainers: Partial<
  64. Record<OverlayContainerType, string | undefined>
  65. > = {}
  66. Object.keys(overlayContainerMeta).forEach((type) => {
  67. openContainers[type as OverlayContainerType] = last(
  68. Array.from(
  69. overlayContainerMeta[type as OverlayContainerType].opened.value,
  70. ),
  71. )
  72. })
  73. return openContainers
  74. })
  75. export const getOverlayContainerMeta = (type: OverlayContainerType) => {
  76. return {
  77. options: overlayContainerMeta[type].options,
  78. opened: overlayContainerMeta[type].opened,
  79. }
  80. }
  81. const getOverlayContainerOptions = (
  82. type: OverlayContainerType,
  83. name: string,
  84. ) => {
  85. const options = overlayContainerMeta[type].options.get(name)
  86. if (!options) {
  87. throw new Error(
  88. `Overlay container '${name}' from type '${type}' was not initialized with 'useOverlayContainer'.`,
  89. )
  90. }
  91. return options
  92. }
  93. export const closeOverlayContainer = async (
  94. type: OverlayContainerType,
  95. name: string,
  96. ) => {
  97. if (!overlayContainerMeta[type].opened.value.has(name)) return
  98. const options = getOverlayContainerOptions(type, name)
  99. await destroyComponent(type, name)
  100. overlayContainerMeta[type].opened.value.delete(name)
  101. if (options.afterClose) {
  102. await options.afterClose()
  103. }
  104. const controllerElement =
  105. (document.querySelector(
  106. `[aria-haspopup="${type}"][aria-controls="${type}-${name}"]`,
  107. ) as HTMLElement | null) ||
  108. overlayContainerMeta[type].lastFocusedElements[name]
  109. if (controllerElement && 'focus' in controllerElement)
  110. controllerElement.focus({ preventScroll: true })
  111. nextTick(() => {
  112. testFlags.set(`${name}.closed`)
  113. })
  114. }
  115. export const openOverlayContainer = async (
  116. type: OverlayContainerType,
  117. name: string,
  118. props: Record<string, unknown>,
  119. ) => {
  120. // Close other open container from same type, before opening new one.
  121. const alreadyOpenedContainer = currentOverlayContainersOpen.value[type]
  122. if (alreadyOpenedContainer && alreadyOpenedContainer !== name) {
  123. await closeOverlayContainer(type, alreadyOpenedContainer)
  124. }
  125. if (overlayContainerMeta[type].opened.value.has(name))
  126. return Promise.resolve()
  127. const options = getOverlayContainerOptions(type, name)
  128. if (options.refocus) {
  129. overlayContainerMeta[type].lastFocusedElements[name] =
  130. document.activeElement as HTMLElement
  131. }
  132. overlayContainerMeta[type].opened.value.add(name)
  133. if (options.beforeOpen) {
  134. await options.beforeOpen()
  135. }
  136. const component = defineAsyncComponent(
  137. options.component as AsyncComponentLoader,
  138. )
  139. await pushComponent(type, name, component, props)
  140. return new Promise<void>((resolve) => {
  141. options.component().finally(() => {
  142. resolve()
  143. nextTick(() => {
  144. testFlags.set(`${name}.opened`)
  145. })
  146. })
  147. })
  148. }
  149. export const useOverlayContainer = (
  150. type: OverlayContainerType,
  151. options: OverlayContainerOptions,
  152. ) => {
  153. options.refocus ??= true
  154. overlayContainerMeta[type].options.set(
  155. options.name,
  156. options as OverlayContainerOptions,
  157. )
  158. const isOpened = computed(() =>
  159. overlayContainerMeta[type].opened.value.has(options.name),
  160. )
  161. const vm = getCurrentInstance()
  162. if (vm) {
  163. // Unmounted happens after setup, if component was unmounted so we need to add options again.
  164. // This happens mainly in storybook stories.
  165. onMounted(() => {
  166. overlayContainerMeta[type].mounted.add(options.name)
  167. overlayContainerMeta[type].options.set(
  168. options.name,
  169. options as OverlayContainerOptions,
  170. )
  171. })
  172. onUnmounted(async () => {
  173. overlayContainerMeta[type].mounted.delete(options.name)
  174. await closeOverlayContainer(type, options.name)
  175. // Was mounted during hmr.
  176. if (!overlayContainerMeta[type].mounted.has(options.name)) {
  177. overlayContainerMeta[type].options.delete(options.name)
  178. }
  179. })
  180. }
  181. const open = (props: Record<string, unknown> = {}) => {
  182. return openOverlayContainer(type, options.name, props)
  183. }
  184. const close = () => {
  185. return closeOverlayContainer(type, options.name)
  186. }
  187. const toggle = (props: Record<string, unknown> = {}) => {
  188. if (isOpened.value) {
  189. return closeOverlayContainer(type, options.name)
  190. }
  191. return openOverlayContainer(type, options.name, props)
  192. }
  193. let pendingPrefetch: Promise<unknown>
  194. const prefetch = async () => {
  195. if (pendingPrefetch) return pendingPrefetch
  196. pendingPrefetch = options.component().catch(noop)
  197. return pendingPrefetch
  198. }
  199. if (options.prefetch) {
  200. prefetch()
  201. }
  202. return {
  203. isOpened,
  204. name: options.name,
  205. open,
  206. close,
  207. toggle,
  208. prefetch,
  209. }
  210. }