CommonPopover.vue 11 KB

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