CommonSectionPopup.vue 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { onClickOutside, onKeyUp, useVModel } from '@vueuse/core'
  4. import { nextTick, type Ref, shallowRef, watch } from 'vue'
  5. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  6. import stopEvent from '#shared/utils/events.ts'
  7. import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
  8. import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
  9. import type { PopupItemDescriptor } from './types.ts'
  10. export interface Props {
  11. messages?: PopupItemDescriptor[]
  12. state: boolean
  13. noRefocus?: boolean
  14. zIndex?: number
  15. heading?: string
  16. }
  17. defineOptions({
  18. inheritAttrs: false,
  19. })
  20. const props = defineProps<Props>()
  21. const emit = defineEmits<{
  22. close: [isCancel: boolean]
  23. 'update:state': [state: boolean]
  24. }>()
  25. const localState = useVModel(props, 'state', emit)
  26. let animating = false
  27. // separate method because eslint doesn't see that when it's reassigned in a template
  28. const setAnimating = (value: boolean) => {
  29. animating = value
  30. }
  31. const hidePopup = (cancel = true) => {
  32. emit('close', cancel)
  33. localState.value = false
  34. }
  35. const onItemClick = (item: PopupItemDescriptor) => {
  36. if (item.onAction) item.onAction()
  37. if (item.type !== 'text' && !item.noHideOnSelect) {
  38. hidePopup(false)
  39. }
  40. }
  41. const wrapper = shallowRef<HTMLElement>()
  42. // ignore clicks while it's rendering
  43. onClickOutside(wrapper, () => !animating && hidePopup(), {
  44. ignore: ['button > [data-ignore-click]'],
  45. })
  46. onKeyUp(
  47. 'Escape',
  48. (e) => {
  49. if (localState.value) {
  50. stopEvent(e)
  51. hidePopup()
  52. }
  53. },
  54. { target: wrapper as Ref<EventTarget> },
  55. )
  56. useTrapTab(wrapper)
  57. const focusFirstFocusableElementInside = async () => {
  58. await nextTick()
  59. const firstElement = getFirstFocusableElement(wrapper.value)
  60. firstElement?.focus()
  61. firstElement?.scrollIntoView({ block: 'nearest' })
  62. }
  63. let lastFocusableOutsideElement: HTMLElement | null = null
  64. watch(
  65. localState,
  66. async (shown) => {
  67. if (shown) {
  68. lastFocusableOutsideElement = document.activeElement as HTMLElement
  69. // when popup is opened, focus the first focusable element (includes "Cancel" button)
  70. focusFirstFocusableElementInside()
  71. return
  72. }
  73. if (!props.noRefocus) {
  74. nextTick(() => lastFocusableOutsideElement?.focus())
  75. }
  76. },
  77. { immediate: true },
  78. )
  79. // Do not animate transitions in the test mode.
  80. const transition = VITE_TEST_MODE
  81. ? undefined
  82. : {
  83. enterActiveClass: 'window-open',
  84. enterFromClass: 'window-close',
  85. leaveActiveClass: 'window-open',
  86. leaveToClass: 'window-close',
  87. }
  88. const getComponentNameByType = (type: PopupItemDescriptor['type']) => {
  89. if (type === 'link') return 'CommonLink'
  90. if (type === 'button') return CommonButton
  91. return 'div'
  92. }
  93. const getClassesByType = (type: PopupItemDescriptor['type']) => {
  94. if (type === 'text') return 'text-left pt-3 last:pb-3'
  95. return 'cursor-pointer h-14 items-center justify-center border-b border-gray-300 text-center last:border-0'
  96. }
  97. </script>
  98. <template>
  99. <Teleport to="body">
  100. <Transition
  101. v-bind="transition"
  102. @before-enter="setAnimating(true)"
  103. @after-enter="setAnimating(false)"
  104. >
  105. <!-- empty @click is needed for https://stackoverflow.com/a/39712411 -->
  106. <div
  107. v-if="localState"
  108. class="window pb-safe-4 fixed bottom-0 top-0 flex w-screen flex-col justify-end px-4 text-white ltr:left-0 rtl:right-0"
  109. :class="{ 'z-20': !zIndex }"
  110. :style="{ zIndex }"
  111. role="presentation"
  112. tabindex="-1"
  113. aria-hidden="true"
  114. data-test-id="popupWindow"
  115. @click="void 0"
  116. @keydown.esc="hidePopup()"
  117. >
  118. <div ref="wrapper" class="wrapper" role="alert" :aria-label="heading">
  119. <div v-bind="$attrs" class="flex w-full flex-col rounded-xl bg-black">
  120. <h1 v-if="heading" class="w-full pt-3 text-center text-lg">
  121. {{ heading }}
  122. </h1>
  123. <slot name="header" />
  124. <component
  125. :is="getComponentNameByType(item.type)"
  126. v-for="item in messages"
  127. :key="item.label"
  128. :link="item.link"
  129. class="flex w-full items-center px-4"
  130. :class="[getClassesByType(item.type), item.class]"
  131. :variant="!item.link && item.buttonVariant"
  132. :transparent-background="!item.link"
  133. v-bind="item.attributes"
  134. @click="onItemClick(item)"
  135. >
  136. {{ $t(item.label) }}
  137. </component>
  138. </div>
  139. <CommonButton
  140. class="mt-3 flex h-14 w-full items-center justify-center !bg-black"
  141. @click="hidePopup()"
  142. >
  143. {{ $t('Cancel') }}
  144. </CommonButton>
  145. </div>
  146. </div>
  147. </Transition>
  148. </Teleport>
  149. </template>
  150. <style scoped>
  151. .window-open {
  152. &.window {
  153. transition: opacity 0.2s ease-in;
  154. }
  155. .wrapper {
  156. transition: transform 0.2s ease-in;
  157. }
  158. }
  159. .window-close {
  160. &.window {
  161. opacity: 0;
  162. }
  163. .wrapper {
  164. transform: translateY(100%);
  165. }
  166. }
  167. .window {
  168. background: hsla(0, 0%, 20%, 0.8);
  169. }
  170. </style>