CommonSectionPopup.vue 5.2 KB

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