CommonPopover.vue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. onClickOutside,
  5. onKeyUp,
  6. useElementBounding,
  7. useWindowSize,
  8. type UseElementBoundingReturn,
  9. } from '@vueuse/core'
  10. import {
  11. type ComponentPublicInstance,
  12. computed,
  13. nextTick,
  14. onUnmounted,
  15. ref,
  16. } from 'vue'
  17. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  18. import { EnumTextDirection } from '#shared/graphql/types.ts'
  19. import { useLocaleStore } from '#shared/stores/locale.ts'
  20. import stopEvent from '#shared/utils/events.ts'
  21. import testFlags from '#shared/utils/testFlags.ts'
  22. import { useTransitionConfig } from '#desktop/composables/useTransitionConfig.ts'
  23. import { usePopoverInstances } from './usePopoverInstances.ts'
  24. import type {
  25. Placement,
  26. CommonPopoverInternalInstance,
  27. Orientation,
  28. } from './types'
  29. export interface Props {
  30. owner: HTMLElement | ComponentPublicInstance | undefined
  31. orientation?: Orientation
  32. placement?: Placement
  33. hideArrow?: boolean
  34. id?: string
  35. }
  36. const props = withDefaults(defineProps<Props>(), {
  37. placement: 'start',
  38. orientation: 'autoVertical',
  39. })
  40. const emit = defineEmits<{
  41. open: []
  42. close: []
  43. }>()
  44. const popoverElement = ref<HTMLElement>()
  45. const showPopover = ref(false)
  46. let targetElementBounds: UseElementBoundingReturn
  47. const windowSize = useWindowSize()
  48. const hasDirectionUp = computed(() => {
  49. if (!targetElementBounds || !windowSize.height) return false
  50. return targetElementBounds.y.value > windowSize.height.value / 2
  51. })
  52. const hasDirectionRight = computed(() => {
  53. if (!targetElementBounds || !windowSize.width) return false
  54. return targetElementBounds.x.value < windowSize.width.value / 2
  55. })
  56. const locale = useLocaleStore()
  57. const autoOrientation = computed(() => {
  58. if (props.orientation === 'autoVertical') {
  59. return hasDirectionUp.value ? 'top' : 'bottom'
  60. }
  61. if (props.orientation === 'autoHorizontal') {
  62. return hasDirectionRight.value ? 'right' : 'left'
  63. }
  64. if (locale.localeData?.dir === EnumTextDirection.Rtl) {
  65. if (props.orientation === 'left') return 'right'
  66. if (props.orientation === 'right') return 'left'
  67. }
  68. return props.orientation
  69. })
  70. const verticalOrientation = computed(() => {
  71. return autoOrientation.value === 'top' || autoOrientation.value === 'bottom'
  72. })
  73. const currentPlacement = computed(() => {
  74. if (verticalOrientation.value) {
  75. if (locale.localeData?.dir === EnumTextDirection.Rtl) {
  76. if (props.placement === 'start') return 'end'
  77. return 'start'
  78. }
  79. return props.placement
  80. }
  81. if (hasDirectionUp.value) return 'end'
  82. return 'start'
  83. })
  84. const BORDER_OFFSET = 2
  85. const PLACEMENT_OFFSET_WO_ARROW = 16
  86. const PLACEMENT_OFFSET_WITH_ARROW = 30
  87. const ORIENTATION_OFFSET_WO_ARROW = 6
  88. const ORIENTATION_OFFSET_WITH_ARROW = 16
  89. const popoverStyle = computed(() => {
  90. if (!targetElementBounds) return { top: 0, left: 0, maxHeight: 0 }
  91. const maxHeight = hasDirectionUp.value
  92. ? targetElementBounds.top.value
  93. : windowSize.height.value - targetElementBounds.bottom.value
  94. const style: Record<string, string> = {
  95. maxHeight: `${verticalOrientation.value ? maxHeight - 24 : maxHeight + 34}px`,
  96. }
  97. const arrowOffset = props.hideArrow
  98. ? PLACEMENT_OFFSET_WO_ARROW
  99. : PLACEMENT_OFFSET_WITH_ARROW
  100. const placementOffset = targetElementBounds.width.value / 2 - arrowOffset
  101. if (verticalOrientation.value && currentPlacement.value === 'end') {
  102. style.right = `${windowSize.width.value - targetElementBounds.right.value + placementOffset - BORDER_OFFSET}px`
  103. } else if (verticalOrientation.value && currentPlacement.value === 'start') {
  104. style.left = `${targetElementBounds.left.value + placementOffset + BORDER_OFFSET}px`
  105. } else if (!verticalOrientation.value && currentPlacement.value === 'start') {
  106. style.top = `${targetElementBounds.top.value + placementOffset + BORDER_OFFSET}px`
  107. } else if (!verticalOrientation.value && currentPlacement.value === 'end') {
  108. style.bottom = `${windowSize.height.value - targetElementBounds.bottom.value + placementOffset - BORDER_OFFSET}px`
  109. }
  110. const orientationOffset = props.hideArrow
  111. ? ORIENTATION_OFFSET_WO_ARROW
  112. : ORIENTATION_OFFSET_WITH_ARROW
  113. switch (autoOrientation.value) {
  114. case 'top':
  115. style.bottom = `${windowSize.height.value - targetElementBounds.top.value + orientationOffset}px`
  116. break
  117. case 'bottom':
  118. style.top = `${
  119. targetElementBounds.top.value +
  120. targetElementBounds.height.value +
  121. orientationOffset
  122. }px`
  123. break
  124. case 'left':
  125. style.right = `${windowSize.width.value - targetElementBounds.left.value + orientationOffset}px`
  126. break
  127. case 'right':
  128. style.left = `${targetElementBounds.right.value + orientationOffset}px`
  129. break
  130. default:
  131. }
  132. return style
  133. })
  134. const arrowPlacementClasses = computed(() => {
  135. const classes: Record<string, boolean> = {
  136. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  137. '-translate-x-1/2': verticalOrientation.value,
  138. '-translate-y-1/2': !verticalOrientation.value,
  139. }
  140. switch (autoOrientation.value) {
  141. case 'bottom':
  142. Object.assign(classes, {
  143. '-top-[11px]': true,
  144. 'border-l-0 border-b-0': true,
  145. })
  146. break
  147. case 'top':
  148. Object.assign(classes, {
  149. '-bottom-[11px]': true,
  150. 'border-r-0 border-t-0': true,
  151. })
  152. break
  153. case 'left':
  154. Object.assign(classes, {
  155. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  156. '-right-[11px]': true,
  157. 'border-t-0 border-l-0': true,
  158. })
  159. break
  160. case 'right':
  161. Object.assign(classes, {
  162. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  163. '-left-[11px]': true,
  164. 'border-b-0 border-r-0': true,
  165. })
  166. break
  167. default:
  168. }
  169. if (verticalOrientation.value && currentPlacement.value === 'end') {
  170. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  171. classes['right-2'] = true
  172. } else if (verticalOrientation.value && currentPlacement.value === 'start') {
  173. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  174. classes['left-7'] = true
  175. } else if (!verticalOrientation.value && currentPlacement.value === 'start') {
  176. classes['top-7'] = true
  177. } else if (!verticalOrientation.value && currentPlacement.value === 'end') {
  178. classes['bottom-2'] = true
  179. }
  180. return classes
  181. })
  182. const { moveNextFocusToTrap } = useTrapTab(popoverElement)
  183. const { instances } = usePopoverInstances()
  184. const updateOwnerAriaExpandedState = () => {
  185. const element = props.owner
  186. if (!element) return
  187. if ('ariaExpanded' in element) {
  188. element.ariaExpanded = showPopover.value ? 'true' : 'false'
  189. }
  190. }
  191. let removeOnKeyUpEscapeHandler: () => void
  192. const closePopover = (isInteractive = false) => {
  193. if (!showPopover.value) return
  194. showPopover.value = false
  195. emit('close')
  196. removeOnKeyUpEscapeHandler?.()
  197. nextTick(() => {
  198. if (!isInteractive && props.owner) {
  199. // eslint-disable-next-line no-unused-expressions
  200. '$el' in props.owner ? props.owner.$el?.focus?.() : props.owner?.focus?.()
  201. }
  202. updateOwnerAriaExpandedState()
  203. testFlags.set('common-select.closed')
  204. })
  205. }
  206. const openPopover = () => {
  207. if (showPopover.value) return
  208. targetElementBounds = useElementBounding(props.owner)
  209. instances.value.forEach((instance) => {
  210. if (instance.isOpen.value) instance.closePopover()
  211. })
  212. showPopover.value = true
  213. emit('open')
  214. removeOnKeyUpEscapeHandler = onKeyUp('Escape', (e) => {
  215. if (!showPopover.value) return
  216. stopEvent(e)
  217. closePopover()
  218. })
  219. onClickOutside(popoverElement, () => closePopover(true), {
  220. ignore: [props.owner],
  221. })
  222. requestAnimationFrame(() => {
  223. nextTick(() => {
  224. moveNextFocusToTrap()
  225. updateOwnerAriaExpandedState()
  226. testFlags.set('common-popover.opened')
  227. })
  228. })
  229. }
  230. const togglePopover = (isInteractive = false) => {
  231. if (showPopover.value) {
  232. closePopover(isInteractive)
  233. } else {
  234. openPopover()
  235. }
  236. }
  237. const exposedInstance: CommonPopoverInternalInstance = {
  238. isOpen: computed(() => showPopover.value),
  239. openPopover,
  240. closePopover,
  241. togglePopover,
  242. }
  243. instances.value.add(exposedInstance)
  244. onUnmounted(() => {
  245. instances.value.delete(exposedInstance)
  246. removeOnKeyUpEscapeHandler?.()
  247. })
  248. defineExpose(exposedInstance)
  249. const { durations } = useTransitionConfig()
  250. </script>
  251. <template>
  252. <Teleport to="body">
  253. <Transition name="fade" :duration="durations.normal">
  254. <div
  255. v-if="showPopover"
  256. :id="id"
  257. ref="popoverElement"
  258. role="region"
  259. class="popover fixed z-50 flex min-h-9 rounded-xl border border-neutral-100 bg-white antialiased dark:border-gray-900 dark:bg-gray-500"
  260. :style="popoverStyle"
  261. :aria-labelledby="owner && '$el' in owner ? owner.$el?.id : owner?.id"
  262. >
  263. <div class="overflow-y-auto">
  264. <slot />
  265. </div>
  266. <div
  267. v-if="!hideArrow"
  268. class="absolute -z-10 h-[22px] w-[22px] -rotate-45 transform border border-neutral-100 bg-white dark:border-gray-900 dark:bg-gray-500"
  269. :class="arrowPlacementClasses"
  270. />
  271. </div>
  272. </Transition>
  273. </Teleport>
  274. </template>