CommonDialog.vue 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { onKeyUp, usePointerSwipe } from '@vueuse/core'
  4. import { nextTick, onMounted, ref, type Events } from 'vue'
  5. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  6. import type { EventHandlers } from '#shared/types/utils.ts'
  7. import stopEvent from '#shared/utils/events.ts'
  8. import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
  9. import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
  10. import { closeDialog } from '#mobile/composables/useDialog.ts'
  11. const props = defineProps<{
  12. name: string
  13. label?: string
  14. content?: string
  15. // don't focus the first element inside a Dialog after being mounted
  16. // if nothing is focusable, will focus "Done" button
  17. noAutofocus?: boolean
  18. listeners?: {
  19. done?: EventHandlers<Events>
  20. }
  21. }>()
  22. const emit = defineEmits<{
  23. close: []
  24. }>()
  25. const PX_SWIPE_CLOSE = -150
  26. const top = ref('0')
  27. const dialogElement = ref<HTMLElement>()
  28. const contentElement = ref<HTMLElement>()
  29. const close = async () => {
  30. emit('close')
  31. await closeDialog(props.name)
  32. }
  33. const canCloseDialog = () => {
  34. const currentDialog = dialogElement.value
  35. if (!currentDialog) {
  36. return false
  37. }
  38. // close dialog only if this is the last one opened
  39. const dialogs = document.querySelectorAll('[data-common-dialog]')
  40. return dialogs[dialogs.length - 1] === currentDialog
  41. }
  42. onKeyUp('Escape', (e) => {
  43. if (canCloseDialog()) {
  44. stopEvent(e)
  45. close()
  46. }
  47. })
  48. const { distanceY, isSwiping } = usePointerSwipe(dialogElement, {
  49. onSwipe() {
  50. if (distanceY.value < 0) {
  51. const distance = Math.abs(distanceY.value)
  52. top.value = `${distance}px`
  53. } else {
  54. top.value = '0'
  55. }
  56. },
  57. onSwipeEnd() {
  58. if (distanceY.value <= PX_SWIPE_CLOSE) {
  59. close()
  60. } else {
  61. top.value = '0'
  62. }
  63. },
  64. pointerTypes: ['touch', 'pen'],
  65. })
  66. useTrapTab(dialogElement)
  67. onMounted(() => {
  68. if (props.noAutofocus) return
  69. // will try to find focusable element inside dialog
  70. // if it won't find it, will try to find inside the header
  71. // most likely will find "Done" button
  72. const firstFocusable =
  73. getFirstFocusableElement(contentElement.value) ||
  74. getFirstFocusableElement(dialogElement.value)
  75. nextTick(() => {
  76. firstFocusable?.focus()
  77. firstFocusable?.scrollIntoView({ block: 'nearest' })
  78. })
  79. })
  80. </script>
  81. <script lang="ts">
  82. export default {
  83. inheritAttrs: false,
  84. }
  85. </script>
  86. <template>
  87. <div
  88. :id="`dialog-${name}`"
  89. class="fixed inset-0 z-10 flex overflow-y-auto"
  90. :aria-label="$t(label || name)"
  91. role="dialog"
  92. >
  93. <div
  94. ref="dialogElement"
  95. data-common-dialog
  96. class="flex h-full grow flex-col overflow-x-hidden bg-black"
  97. :class="{ 'transition-all duration-200 ease-linear': !isSwiping }"
  98. :style="{ transform: `translateY(${top})` }"
  99. >
  100. <div class="bg-gray-150/40 mx-4 h-2.5 shrink-0 rounded-t-xl" />
  101. <div
  102. class="relative flex h-16 shrink-0 select-none items-center justify-center rounded-t-xl bg-gray-600/80"
  103. >
  104. <div
  105. class="absolute bottom-0 top-0 flex items-center ltr:left-0 ltr:pl-4 rtl:right-0 rtl:pr-4"
  106. >
  107. <slot name="before-label" />
  108. </div>
  109. <div
  110. class="line-clamp-2 max-w-[65%] text-center text-base font-semibold leading-[19px] text-white"
  111. >
  112. <slot name="label">
  113. {{ $t(label) }}
  114. </slot>
  115. </div>
  116. <div
  117. class="absolute bottom-0 top-0 flex items-center ltr:right-0 ltr:pr-4 rtl:left-0 rtl:pl-4"
  118. >
  119. <slot name="after-label">
  120. <CommonButton
  121. class="grow"
  122. variant="primary"
  123. transparent-background
  124. v-bind="listeners?.done"
  125. @pointerdown.stop
  126. @click="close()"
  127. @keypress.space.prevent="close()"
  128. >
  129. {{ $t('Done') }}
  130. </CommonButton>
  131. </slot>
  132. </div>
  133. </div>
  134. <div
  135. ref="contentElement"
  136. v-bind="$attrs"
  137. class="flex grow flex-col items-start overflow-y-auto bg-black text-white"
  138. >
  139. <slot>{{ content }}</slot>
  140. </div>
  141. </div>
  142. </div>
  143. </template>