123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- import { last, noop } from 'lodash-es'
- import {
- computed,
- defineAsyncComponent,
- ref,
- onUnmounted,
- getCurrentInstance,
- onMounted,
- nextTick,
- type AsyncComponentLoader,
- type Component,
- type Ref,
- } from 'vue'
- import { useRoute } from 'vue-router'
- import {
- destroyComponent,
- pushComponent,
- } from '#shared/components/DynamicInitializer/manage.ts'
- import testFlags from '#shared/utils/testFlags.ts'
- export interface OverlayContainerOptions {
- name: string
- component: () => Promise<Component>
- prefetch?: boolean
- /**
- * If true, dialog will focus the element that opened it.
- * If dialog is opened without a user interaction, you should set it to false.
- * @default true
- */
- refocus?: boolean
- beforeOpen?: (uniqueId?: string) => Awaited<unknown>
- afterClose?: (uniqueId?: string) => Awaited<unknown>
- fullscreen?: boolean
- /**
- * If true, no page context will be added to the name, e.g. for confirmation dialogs.
- * @default false
- */
- global?: boolean
- }
- export type OverlayContainerType = 'dialog' | 'flyout'
- export interface OverlayContainerMeta {
- mounted: Set<string>
- options: Map<string, OverlayContainerOptions>
- opened: Ref<Set<string>>
- lastFocusedElements: Record<string, HTMLElement>
- }
- const overlayContainerMeta: Record<OverlayContainerType, OverlayContainerMeta> =
- {
- dialog: {
- mounted: new Set<string>(),
- options: new Map<string, OverlayContainerOptions>(),
- opened: ref(new Set<string>()),
- lastFocusedElements: {},
- },
- flyout: {
- mounted: new Set<string>(),
- options: new Map<string, OverlayContainerOptions>(),
- opened: ref(new Set<string>()),
- lastFocusedElements: {},
- },
- }
- export const getOpenedOverlayContainers = (type: OverlayContainerType) =>
- overlayContainerMeta[type].opened.value
- export const isOverlayContainerOpened = (
- type: OverlayContainerType,
- name?: string,
- ) =>
- name
- ? overlayContainerMeta[type].opened.value.has(name)
- : overlayContainerMeta[type].opened.value.size > 0
- export const currentOverlayContainersOpen = computed(() => {
- const openContainers: Partial<
- Record<OverlayContainerType, string | undefined>
- > = {}
- Object.keys(overlayContainerMeta).forEach((type) => {
- openContainers[type as OverlayContainerType] = last(
- Array.from(
- overlayContainerMeta[type as OverlayContainerType].opened.value,
- ),
- )
- })
- return openContainers
- })
- export const getOverlayContainerMeta = (type: OverlayContainerType) => {
- return {
- options: overlayContainerMeta[type].options,
- opened: overlayContainerMeta[type].opened,
- }
- }
- const getOverlayContainerOptions = (
- type: OverlayContainerType,
- name: string,
- ) => {
- const options = overlayContainerMeta[type].options.get(name)
- if (!options) {
- throw new Error(
- `Overlay container '${name}' from type '${type}' was not initialized with 'useOverlayContainer'.`,
- )
- }
- return options
- }
- export const closeOverlayContainer = async (
- type: OverlayContainerType,
- name: string,
- ) => {
- const [realName, uniqueId] = name.split(':')
- if (!overlayContainerMeta[type].opened.value.has(name)) return
- const options = getOverlayContainerOptions(type, realName)
- await destroyComponent(type, name)
- overlayContainerMeta[type].opened.value.delete(name)
- if (options.afterClose) {
- await options.afterClose(uniqueId)
- }
- const controllerElement =
- (document.querySelector(
- `[aria-haspopup="${type}"][aria-controls="${type}-${name}"]`,
- ) as HTMLElement | null) ||
- overlayContainerMeta[type].lastFocusedElements[name]
- if (controllerElement && 'focus' in controllerElement)
- controllerElement.focus({ preventScroll: true })
- nextTick(() => {
- testFlags.set(`${name}.closed`)
- })
- }
- export const openOverlayContainer = async (
- type: OverlayContainerType,
- name: string,
- props: Record<string, unknown>,
- ) => {
- const options = getOverlayContainerOptions(type, name)
- let uniqueName = name
- if (props.uniqueId) {
- uniqueName = `${name}:${props.uniqueId}`
- }
- if (options.refocus) {
- overlayContainerMeta[type].lastFocusedElements[uniqueName] =
- document.activeElement as HTMLElement
- }
- overlayContainerMeta[type].opened.value.add(uniqueName)
- if (options.beforeOpen) {
- await options.beforeOpen(props.uniqueId as string | undefined)
- }
- const component = defineAsyncComponent(
- options.component as AsyncComponentLoader,
- )
- await pushComponent(type, uniqueName, component, props)
- return new Promise<void>((resolve) => {
- options.component().finally(() => {
- resolve()
- nextTick(() => {
- testFlags.set(`${uniqueName}.opened`)
- })
- })
- })
- }
- export const useOverlayContainer = (
- type: OverlayContainerType,
- options: OverlayContainerOptions,
- ) => {
- const { name } = options
- const vm = getCurrentInstance()
- if (!vm) {
- throw new Error(
- `Overlay container '${name}' from type '${type}' was not initialized inside setup context.`,
- )
- }
- options.refocus ??= true
- const route = useRoute()
- const currentName = options.global ? name : `${name}_${route.path}`
- overlayContainerMeta[type].options.set(currentName, options)
- const isOpened = computed(() =>
- overlayContainerMeta[type].opened.value.has(currentName),
- )
- // Unmounted happens after setup, if component was unmounted so we need to add options again.
- // This happens mainly in storybook stories.
- onMounted(() => {
- overlayContainerMeta[type].mounted.add(currentName)
- overlayContainerMeta[type].options.set(currentName, options)
- })
- onUnmounted(async () => {
- overlayContainerMeta[type].mounted.delete(currentName)
- await closeOverlayContainer(type, currentName)
- // Was mounted during hmr.
- if (!overlayContainerMeta[type].mounted.has(currentName)) {
- overlayContainerMeta[type].options.delete(currentName)
- }
- })
- const open = (props: Record<string, unknown> = {}) => {
- return openOverlayContainer(type, currentName, props)
- }
- const close = () => {
- return closeOverlayContainer(type, currentName)
- }
- const toggle = (props: Record<string, unknown> = {}) => {
- if (isOpened.value) {
- return closeOverlayContainer(type, currentName)
- }
- return openOverlayContainer(type, currentName, props)
- }
- let pendingPrefetch: Promise<unknown>
- const prefetch = async () => {
- if (pendingPrefetch) return pendingPrefetch
- pendingPrefetch = options.component().catch(noop)
- return pendingPrefetch
- }
- if (options.prefetch) {
- prefetch()
- }
- return {
- overlayContainerMeta,
- isOpened,
- name: currentName,
- open,
- close,
- toggle,
- prefetch,
- }
- }
|