useOverlayContainer.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  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. type AsyncComponentLoader,
  12. type Component,
  13. type Ref,
  14. } from 'vue'
  15. import { useRoute } from 'vue-router'
  16. import {
  17. destroyComponent,
  18. pushComponent,
  19. } from '#shared/components/DynamicInitializer/manage.ts'
  20. import testFlags from '#shared/utils/testFlags.ts'
  21. export interface OverlayContainerOptions {
  22. name: string
  23. component: () => Promise<Component>
  24. prefetch?: boolean
  25. /**
  26. * If true, dialog will focus the element that opened it.
  27. * If dialog is opened without a user interaction, you should set it to false.
  28. * @default true
  29. */
  30. refocus?: boolean
  31. beforeOpen?: (uniqueId?: string) => Awaited<unknown>
  32. afterClose?: (uniqueId?: string) => Awaited<unknown>
  33. fullscreen?: boolean
  34. /**
  35. * If true, no page context will be added to the name, e.g. for confirmation dialogs.
  36. * @default false
  37. */
  38. global?: boolean
  39. }
  40. export type OverlayContainerType = 'dialog' | 'flyout'
  41. export interface OverlayContainerMeta {
  42. mounted: Set<string>
  43. options: Map<string, OverlayContainerOptions>
  44. opened: Ref<Set<string>>
  45. lastFocusedElements: Record<string, HTMLElement>
  46. }
  47. const overlayContainerMeta: Record<OverlayContainerType, OverlayContainerMeta> =
  48. {
  49. dialog: {
  50. mounted: new Set<string>(),
  51. options: new Map<string, OverlayContainerOptions>(),
  52. opened: ref(new Set<string>()),
  53. lastFocusedElements: {},
  54. },
  55. flyout: {
  56. mounted: new Set<string>(),
  57. options: new Map<string, OverlayContainerOptions>(),
  58. opened: ref(new Set<string>()),
  59. lastFocusedElements: {},
  60. },
  61. }
  62. export const getOpenedOverlayContainers = (type: OverlayContainerType) =>
  63. overlayContainerMeta[type].opened.value
  64. export const isOverlayContainerOpened = (
  65. type: OverlayContainerType,
  66. name?: string,
  67. ) =>
  68. name
  69. ? overlayContainerMeta[type].opened.value.has(name)
  70. : overlayContainerMeta[type].opened.value.size > 0
  71. export const currentOverlayContainersOpen = computed(() => {
  72. const openContainers: Partial<
  73. Record<OverlayContainerType, string | undefined>
  74. > = {}
  75. Object.keys(overlayContainerMeta).forEach((type) => {
  76. openContainers[type as OverlayContainerType] = last(
  77. Array.from(
  78. overlayContainerMeta[type as OverlayContainerType].opened.value,
  79. ),
  80. )
  81. })
  82. return openContainers
  83. })
  84. export const getOverlayContainerMeta = (type: OverlayContainerType) => {
  85. return {
  86. options: overlayContainerMeta[type].options,
  87. opened: overlayContainerMeta[type].opened,
  88. }
  89. }
  90. const getOverlayContainerOptions = (
  91. type: OverlayContainerType,
  92. name: string,
  93. ) => {
  94. const options = overlayContainerMeta[type].options.get(name)
  95. if (!options) {
  96. throw new Error(
  97. `Overlay container '${name}' from type '${type}' was not initialized with 'useOverlayContainer'.`,
  98. )
  99. }
  100. return options
  101. }
  102. export const closeOverlayContainer = async (
  103. type: OverlayContainerType,
  104. name: string,
  105. ) => {
  106. const [realName, uniqueId] = name.split(':')
  107. if (!overlayContainerMeta[type].opened.value.has(name)) return
  108. const options = getOverlayContainerOptions(type, realName)
  109. await destroyComponent(type, name)
  110. overlayContainerMeta[type].opened.value.delete(name)
  111. if (options.afterClose) {
  112. await options.afterClose(uniqueId)
  113. }
  114. const controllerElement =
  115. (document.querySelector(
  116. `[aria-haspopup="${type}"][aria-controls="${type}-${name}"]`,
  117. ) as HTMLElement | null) ||
  118. overlayContainerMeta[type].lastFocusedElements[name]
  119. if (controllerElement && 'focus' in controllerElement)
  120. controllerElement.focus({ preventScroll: true })
  121. nextTick(() => {
  122. testFlags.set(`${name}.closed`)
  123. })
  124. }
  125. export const openOverlayContainer = async (
  126. type: OverlayContainerType,
  127. name: string,
  128. props: Record<string, unknown>,
  129. ) => {
  130. const options = getOverlayContainerOptions(type, name)
  131. let uniqueName = name
  132. if (props.uniqueId) {
  133. uniqueName = `${name}:${props.uniqueId}`
  134. }
  135. if (options.refocus) {
  136. overlayContainerMeta[type].lastFocusedElements[uniqueName] =
  137. document.activeElement as HTMLElement
  138. }
  139. overlayContainerMeta[type].opened.value.add(uniqueName)
  140. if (options.beforeOpen) {
  141. await options.beforeOpen(props.uniqueId as string | undefined)
  142. }
  143. const component = defineAsyncComponent(
  144. options.component as AsyncComponentLoader,
  145. )
  146. await pushComponent(type, uniqueName, component, props)
  147. return new Promise<void>((resolve) => {
  148. options.component().finally(() => {
  149. resolve()
  150. nextTick(() => {
  151. testFlags.set(`${uniqueName}.opened`)
  152. })
  153. })
  154. })
  155. }
  156. export const useOverlayContainer = (
  157. type: OverlayContainerType,
  158. options: OverlayContainerOptions,
  159. ) => {
  160. const { name } = options
  161. const vm = getCurrentInstance()
  162. if (!vm) {
  163. throw new Error(
  164. `Overlay container '${name}' from type '${type}' was not initialized inside setup context.`,
  165. )
  166. }
  167. options.refocus ??= true
  168. const route = useRoute()
  169. const currentName = options.global ? name : `${name}_${route.path}`
  170. overlayContainerMeta[type].options.set(currentName, options)
  171. const isOpened = computed(() =>
  172. overlayContainerMeta[type].opened.value.has(currentName),
  173. )
  174. // Unmounted happens after setup, if component was unmounted so we need to add options again.
  175. // This happens mainly in storybook stories.
  176. onMounted(() => {
  177. overlayContainerMeta[type].mounted.add(currentName)
  178. overlayContainerMeta[type].options.set(currentName, options)
  179. })
  180. onUnmounted(async () => {
  181. overlayContainerMeta[type].mounted.delete(currentName)
  182. await closeOverlayContainer(type, currentName)
  183. // Was mounted during hmr.
  184. if (!overlayContainerMeta[type].mounted.has(currentName)) {
  185. overlayContainerMeta[type].options.delete(currentName)
  186. }
  187. })
  188. const open = (props: Record<string, unknown> = {}) => {
  189. return openOverlayContainer(type, currentName, props)
  190. }
  191. const close = () => {
  192. return closeOverlayContainer(type, currentName)
  193. }
  194. const toggle = (props: Record<string, unknown> = {}) => {
  195. if (isOpened.value) {
  196. return closeOverlayContainer(type, currentName)
  197. }
  198. return openOverlayContainer(type, currentName, props)
  199. }
  200. let pendingPrefetch: Promise<unknown>
  201. const prefetch = async () => {
  202. if (pendingPrefetch) return pendingPrefetch
  203. pendingPrefetch = options.component().catch(noop)
  204. return pendingPrefetch
  205. }
  206. if (options.prefetch) {
  207. prefetch()
  208. }
  209. return {
  210. overlayContainerMeta,
  211. isOpened,
  212. name: currentName,
  213. open,
  214. close,
  215. toggle,
  216. prefetch,
  217. }
  218. }