CommonPopover.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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. type UnwrapRef,
  17. useTemplateRef,
  18. } from 'vue'
  19. import { useTransitionConfig } from '#shared/composables/useTransitionConfig.ts'
  20. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  21. import { EnumTextDirection } from '#shared/graphql/types.ts'
  22. import { getPopoverClasses } from '#shared/initializer/initializePopover.ts'
  23. import { useLocaleStore } from '#shared/stores/locale.ts'
  24. import stopEvent from '#shared/utils/events.ts'
  25. import testFlags from '#shared/utils/testFlags.ts'
  26. import { usePopoverInstances } from './usePopoverInstances.ts'
  27. import type {
  28. Placement,
  29. CommonPopoverInternalInstance,
  30. Orientation,
  31. } from './types.ts'
  32. export interface Props {
  33. owner: HTMLElement | ComponentPublicInstance | undefined
  34. orientation?: Orientation
  35. placement?: Placement
  36. hideArrow?: boolean
  37. id?: string
  38. noAutoFocus?: boolean
  39. persistent?: boolean
  40. }
  41. const props = withDefaults(defineProps<Props>(), {
  42. placement: 'start',
  43. orientation: 'autoVertical',
  44. })
  45. const emit = defineEmits<{
  46. open: []
  47. close: []
  48. }>()
  49. const popoverElement = useTemplateRef('popover')
  50. const showPopover = ref(false)
  51. const targetElementBounds = ref<UnwrapRef<UseElementBoundingReturn>>()
  52. const windowSize = useWindowSize()
  53. const hasDirectionUp = computed(() => {
  54. if (!targetElementBounds.value || !windowSize.height) return false
  55. return targetElementBounds.value.y > windowSize.height.value / 2
  56. })
  57. const hasDirectionRight = computed(() => {
  58. if (!targetElementBounds.value || !windowSize.width) return false
  59. return targetElementBounds.value.x < windowSize.width.value / 2
  60. })
  61. const locale = useLocaleStore()
  62. const autoOrientation = computed(() => {
  63. if (props.orientation === 'autoVertical') {
  64. return hasDirectionUp.value ? 'top' : 'bottom'
  65. }
  66. if (props.orientation === 'autoHorizontal') {
  67. return hasDirectionRight.value ? 'right' : 'left'
  68. }
  69. if (locale.localeData?.dir === EnumTextDirection.Rtl) {
  70. if (props.orientation === 'left') return 'right'
  71. if (props.orientation === 'right') return 'left'
  72. }
  73. return props.orientation
  74. })
  75. const verticalOrientation = computed(() => {
  76. return autoOrientation.value === 'top' || autoOrientation.value === 'bottom'
  77. })
  78. // eslint-disable-next-line sonarjs/cognitive-complexity
  79. const currentPlacement = computed(() => {
  80. if (props.placement === 'arrowStart' || props.placement === 'arrowEnd') {
  81. if (locale.localeData?.dir === EnumTextDirection.Rtl) {
  82. if (props.placement === 'arrowStart') return 'arrowEnd'
  83. return 'arrowStart'
  84. }
  85. return props.placement
  86. }
  87. if (verticalOrientation.value) {
  88. if (locale.localeData?.dir === EnumTextDirection.Rtl) {
  89. if (props.placement === 'start') return 'end'
  90. if (props.placement === 'end') return 'start'
  91. return props.hideArrow ? 'start' : 'arrowStart'
  92. }
  93. return props.placement
  94. }
  95. if (hasDirectionUp.value) return props.hideArrow ? 'end' : 'arrowEnd'
  96. return props.hideArrow ? 'start' : 'arrowStart'
  97. })
  98. const BORDER_OFFSET = 2
  99. const PLACEMENT_OFFSET_WO_ARROW = 16
  100. const PLACEMENT_OFFSET_WITH_ARROW = 30
  101. const ORIENTATION_OFFSET_WO_ARROW = 6
  102. const ORIENTATION_OFFSET_WITH_ARROW = 16
  103. // eslint-disable-next-line sonarjs/cognitive-complexity
  104. const popoverStyle = computed(() => {
  105. if (!targetElementBounds.value) return { top: 0, left: 0, maxHeight: 0 }
  106. const maxHeight = hasDirectionUp.value
  107. ? targetElementBounds.value.top
  108. : windowSize.height.value - targetElementBounds.value.bottom
  109. const style: Record<string, string> = {
  110. maxHeight: `${verticalOrientation.value ? maxHeight - 24 : maxHeight + 34}px`,
  111. }
  112. const arrowOffset = props.hideArrow
  113. ? PLACEMENT_OFFSET_WO_ARROW
  114. : PLACEMENT_OFFSET_WITH_ARROW
  115. const placementOffset = targetElementBounds.value.width / 2 - arrowOffset
  116. if (verticalOrientation.value) {
  117. if (currentPlacement.value === 'start') {
  118. style.left = `${targetElementBounds.value.left - BORDER_OFFSET}px`
  119. } else if (currentPlacement.value === 'arrowStart') {
  120. style.left = `${targetElementBounds.value.left + placementOffset + BORDER_OFFSET}px`
  121. } else if (currentPlacement.value === 'arrowEnd') {
  122. style.right = `${windowSize.width.value - targetElementBounds.value.left + placementOffset - targetElementBounds.value.width - BORDER_OFFSET}px`
  123. } else if (currentPlacement.value === 'end') {
  124. style.right = `${windowSize.width.value - targetElementBounds.value.left - targetElementBounds.value.width - BORDER_OFFSET}px`
  125. }
  126. } else if (!verticalOrientation.value) {
  127. if (currentPlacement.value === 'start') {
  128. style.top = `${targetElementBounds.value.top - BORDER_OFFSET}px`
  129. } else if (currentPlacement.value === 'arrowStart') {
  130. style.top = `${targetElementBounds.value.bottom - targetElementBounds.value.height / 2 - arrowOffset + BORDER_OFFSET}px`
  131. } else if (currentPlacement.value === 'arrowEnd') {
  132. style.bottom = `${windowSize.height.value - targetElementBounds.value.bottom + targetElementBounds.value.height / 2 - arrowOffset - BORDER_OFFSET}px`
  133. } else if (currentPlacement.value === 'end') {
  134. style.bottom = `${windowSize.height.value - targetElementBounds.value.bottom - BORDER_OFFSET}px`
  135. }
  136. }
  137. const orientationOffset = props.hideArrow
  138. ? ORIENTATION_OFFSET_WO_ARROW
  139. : ORIENTATION_OFFSET_WITH_ARROW
  140. switch (autoOrientation.value) {
  141. case 'top':
  142. style.bottom = `${windowSize.height.value - targetElementBounds.value.top + orientationOffset}px`
  143. break
  144. case 'bottom':
  145. style.top = `${
  146. targetElementBounds.value.top +
  147. targetElementBounds.value.height +
  148. orientationOffset
  149. }px`
  150. break
  151. case 'left':
  152. style.right = `${windowSize.width.value - targetElementBounds.value.left + orientationOffset}px`
  153. break
  154. case 'right':
  155. style.left = `${targetElementBounds.value.right + orientationOffset}px`
  156. break
  157. default:
  158. }
  159. return style
  160. })
  161. const arrowPlacementClasses = computed(() => {
  162. const classes: Record<string, boolean> = {
  163. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  164. '-translate-x-1/2': verticalOrientation.value,
  165. '-translate-y-1/2': !verticalOrientation.value,
  166. }
  167. switch (autoOrientation.value) {
  168. case 'bottom':
  169. Object.assign(classes, {
  170. '-top-[11px]': true,
  171. 'border-l-0 border-b-0': true,
  172. })
  173. break
  174. case 'top':
  175. Object.assign(classes, {
  176. '-bottom-[11px]': true,
  177. 'border-r-0 border-t-0': true,
  178. })
  179. break
  180. case 'left':
  181. Object.assign(classes, {
  182. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  183. '-right-[11px]': true,
  184. 'border-t-0 border-l-0': true,
  185. })
  186. break
  187. case 'right':
  188. Object.assign(classes, {
  189. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  190. '-left-[11px]': true,
  191. 'border-b-0 border-r-0': true,
  192. })
  193. break
  194. default:
  195. }
  196. if (verticalOrientation.value) {
  197. if (
  198. currentPlacement.value === 'start' ||
  199. currentPlacement.value === 'arrowStart'
  200. ) {
  201. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  202. classes['left-7'] = true
  203. } else if (
  204. currentPlacement.value === 'end' ||
  205. currentPlacement.value === 'arrowEnd'
  206. ) {
  207. // eslint-disable-next-line zammad/zammad-tailwind-ltr
  208. classes['right-2'] = true
  209. }
  210. } else if (!verticalOrientation.value) {
  211. if (
  212. currentPlacement.value === 'start' ||
  213. currentPlacement.value === 'arrowStart'
  214. ) {
  215. classes['top-7'] = true
  216. } else if (
  217. currentPlacement.value === 'end' ||
  218. currentPlacement.value === 'arrowEnd'
  219. ) {
  220. classes['bottom-2'] = true
  221. }
  222. }
  223. return classes
  224. })
  225. const { moveNextFocusToTrap } = useTrapTab(popoverElement)
  226. const { instances } = usePopoverInstances()
  227. const updateOwnerAriaExpandedState = () => {
  228. const element = props.owner
  229. if (!element) return
  230. if ('ariaExpanded' in element) {
  231. element.ariaExpanded = showPopover.value ? 'true' : 'false'
  232. }
  233. }
  234. let removeOnKeyUpEscapeHandler: () => void
  235. const closePopover = (isInteractive = false) => {
  236. if (!showPopover.value) return
  237. showPopover.value = false
  238. emit('close')
  239. removeOnKeyUpEscapeHandler?.()
  240. nextTick(() => {
  241. if (!isInteractive && props.owner) {
  242. // eslint-disable-next-line @typescript-eslint/no-unused-expressions
  243. '$el' in props.owner ? props.owner.$el?.focus?.() : props.owner?.focus?.()
  244. }
  245. updateOwnerAriaExpandedState()
  246. testFlags.set('common-popover.closed')
  247. })
  248. }
  249. const openPopover = () => {
  250. if (showPopover.value) return
  251. targetElementBounds.value = useElementBounding(
  252. props.owner,
  253. ) as unknown as UnwrapRef<UseElementBoundingReturn>
  254. instances.value.forEach((instance) => {
  255. if (instance.isOpen.value) instance.closePopover()
  256. })
  257. showPopover.value = true
  258. emit('open')
  259. removeOnKeyUpEscapeHandler = onKeyUp('Escape', (e) => {
  260. if (!showPopover.value) return
  261. stopEvent(e)
  262. closePopover()
  263. })
  264. onClickOutside(popoverElement, () => closePopover(true), {
  265. ignore: [props.owner],
  266. })
  267. requestAnimationFrame(() => {
  268. nextTick(() => {
  269. if (!props.noAutoFocus) moveNextFocusToTrap()
  270. updateOwnerAriaExpandedState()
  271. testFlags.set('common-popover.opened')
  272. })
  273. })
  274. }
  275. const togglePopover = (isInteractive = false) => {
  276. if (showPopover.value) {
  277. closePopover(isInteractive)
  278. } else {
  279. openPopover()
  280. }
  281. }
  282. const exposedInstance: CommonPopoverInternalInstance = {
  283. isOpen: computed(() => showPopover.value),
  284. openPopover,
  285. closePopover,
  286. togglePopover,
  287. }
  288. instances.value.add(exposedInstance)
  289. onUnmounted(() => {
  290. instances.value.delete(exposedInstance)
  291. removeOnKeyUpEscapeHandler?.()
  292. })
  293. defineExpose(exposedInstance)
  294. defineOptions({
  295. inheritAttrs: false,
  296. })
  297. const { durations } = useTransitionConfig()
  298. const classes = getPopoverClasses()
  299. </script>
  300. <template>
  301. <Teleport to="body">
  302. <Transition name="fade" :duration="durations.normal">
  303. <div
  304. v-if="persistent"
  305. v-show="showPopover"
  306. :id="id"
  307. ref="popover"
  308. role="region"
  309. class="popover fixed z-50 flex"
  310. :class="[classes.base]"
  311. :style="popoverStyle"
  312. :aria-labelledby="owner && '$el' in owner ? owner.$el?.id : owner?.id"
  313. v-bind="$attrs"
  314. >
  315. <div class="w-full overflow-y-auto">
  316. <slot />
  317. </div>
  318. <div
  319. v-if="!hideArrow"
  320. class="absolute -z-10 -rotate-45 transform"
  321. :class="[arrowPlacementClasses, classes.arrow]"
  322. />
  323. </div>
  324. <div
  325. v-else-if="showPopover"
  326. :id="id"
  327. ref="popover"
  328. role="region"
  329. class="popover fixed z-50 flex"
  330. :class="[classes.base]"
  331. :style="popoverStyle"
  332. :aria-labelledby="owner && '$el' in owner ? owner.$el?.id : owner?.id"
  333. v-bind="$attrs"
  334. >
  335. <div class="w-full overflow-y-auto">
  336. <slot />
  337. </div>
  338. <div
  339. v-if="!hideArrow"
  340. class="absolute -z-10 -rotate-45 transform"
  341. :class="[arrowPlacementClasses, classes.arrow]"
  342. />
  343. </div>
  344. </Transition>
  345. </Teleport>
  346. </template>