index.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { type ObjectDirective } from 'vue'
  3. import { useAppName } from '#shared/composables/useAppName.ts'
  4. import { useLocaleStore } from '#shared/stores/locale.ts'
  5. interface Modifiers {
  6. truncate?: boolean
  7. }
  8. let isListeningToEvents = false
  9. let isTooltipInDom = false
  10. let hasHoverOnNode = false
  11. let currentEvent: MouseEvent | TouchEvent | null = null
  12. let tooltipTimeout: NodeJS.Timeout | null = null
  13. let tooltipRecordsCount = 0
  14. let tooltipTargetRecords: WeakMap<HTMLElement, { modifiers: Modifiers }> =
  15. new WeakMap()
  16. const removeTooltips = () => {
  17. document
  18. .querySelectorAll('[role="tooltip"]')
  19. .forEach((node) => node?.remove())
  20. isTooltipInDom = false
  21. }
  22. const addModifierRecord = (element: HTMLDivElement, modifiers: Modifiers) => {
  23. if (tooltipTargetRecords.has(element)) return
  24. tooltipRecordsCount += 1
  25. tooltipTargetRecords.set(element, {
  26. modifiers,
  27. })
  28. }
  29. const removeModifierRecord = (element: HTMLDivElement) => {
  30. if (!tooltipTargetRecords.has(element)) return
  31. tooltipRecordsCount -= 1
  32. tooltipTargetRecords.delete(element)
  33. }
  34. const getModifierRecord = ($el: HTMLDivElement) => {
  35. return tooltipTargetRecords.get($el) || null
  36. }
  37. const createTooltip = (
  38. { top, left }: { top: string; left: string },
  39. message: string,
  40. ) => {
  41. const tooltipNode = document.createElement('div')
  42. tooltipNode.classList.add('tooltip')
  43. tooltipNode.style.top = top
  44. tooltipNode.style.left = left
  45. tooltipNode.setAttribute('aria-hidden', 'true')
  46. tooltipNode.setAttribute('role', 'tooltip')
  47. // Set the max-width to half of the available width
  48. const availableWidth = window.innerWidth / 2
  49. tooltipNode.style.maxWidth = `${availableWidth}px`
  50. const tooltipMessageNode = document.createElement('p')
  51. tooltipMessageNode.textContent = message
  52. tooltipNode.insertAdjacentElement('afterbegin', tooltipMessageNode)
  53. return tooltipNode
  54. }
  55. const getLeftBasedOnLanguage = (clientX: number, tooltipRectangle: DOMRect) => {
  56. const isRTL = useLocaleStore().localeData?.dir === 'rtl'
  57. let left = ''
  58. if (isRTL) {
  59. // For RTL languages, place the tooltip to the left of the mouse
  60. const leftValue = clientX - tooltipRectangle.width
  61. left = `${leftValue}px`
  62. // Check if the tooltip would overflow the window's width
  63. if (leftValue < 0) {
  64. // If it would, adjust the left property to ensure it fits within the window
  65. left = '0px'
  66. }
  67. } else {
  68. // For LTR languages, place the tooltip to the right of the mouse
  69. left = `${clientX}px`
  70. // Check if the tooltip would overflow the window's width
  71. if (clientX + tooltipRectangle.width > window.innerWidth) {
  72. // Move tooltip to the left if it overflows to avoid squeezing the tooltip content
  73. left = `${window.innerWidth - tooltipRectangle.width}px`
  74. }
  75. }
  76. return left
  77. }
  78. const addTooltip = (
  79. targetNode: HTMLDivElement,
  80. message: string,
  81. {
  82. event,
  83. }: {
  84. event: MouseEvent | TouchEvent
  85. },
  86. ) => {
  87. if (!event) return
  88. const tooltipNode = createTooltip({ top: '0px', left: '0px' }, message)
  89. document.body.appendChild(tooltipNode) // Temporarily add to DOM to calculate dimensions
  90. const tooltipRectangle = tooltipNode.getBoundingClientRect()
  91. let top: string
  92. let left: string
  93. if (!event) return
  94. if ('touches' in event) {
  95. const { clientX, clientY } = event.touches[0]
  96. top = `${clientY}px`
  97. left = getLeftBasedOnLanguage(clientX, tooltipRectangle)
  98. } else {
  99. const { clientX, clientY } = event
  100. const verticalThreshold = 10 // native tooltip has an extra threshold of ~ 10px
  101. const thresholdToBottom = 30
  102. const availableSpaceBelow = window.innerHeight - clientY - thresholdToBottom
  103. // If the tooltip is to close to the bottom of the viewport, show it above the target
  104. if (availableSpaceBelow < tooltipRectangle.height) {
  105. top = `${clientY - verticalThreshold - tooltipRectangle.height}px`
  106. } else {
  107. top = `${clientY + verticalThreshold}px`
  108. }
  109. left = getLeftBasedOnLanguage(clientX, tooltipRectangle)
  110. }
  111. tooltipNode.style.top = top
  112. tooltipNode.style.left = left
  113. document.body.insertAdjacentElement('beforeend', tooltipNode)
  114. setTimeout(() => {
  115. tooltipNode.classList.add('tooltip-animate')
  116. }, 500) // Add animation after 500ms same as for delay
  117. }
  118. const isContentTruncated = (element: HTMLElement) => {
  119. const { parentElement } = element
  120. // top-level element
  121. if (!parentElement) return element.offsetWidth < element.scrollWidth
  122. return parentElement.offsetWidth < parentElement.scrollWidth
  123. }
  124. const evaluateModifiers = (element: HTMLElement, options?: Modifiers) => {
  125. const modifications = {
  126. isTruncated: false,
  127. top: false,
  128. }
  129. if (options?.truncate) {
  130. modifications.isTruncated = isContentTruncated(element)
  131. }
  132. return modifications
  133. }
  134. const findTooltipTarget = (
  135. element: HTMLDivElement | null,
  136. ): HTMLDivElement | null => element?.closest('[data-tooltip]') || null
  137. const handleTooltipAddEvent = (event: MouseEvent | TouchEvent) => {
  138. if (isTooltipInDom) removeTooltips() // Remove tooltips if there is already one set in the DOM
  139. if (!event.target) return
  140. // Do not show the tooltip if the target element is missing.
  141. const tooltipTargetNode = findTooltipTarget(event.target as HTMLDivElement)
  142. if (!tooltipTargetNode) return
  143. // Do not show the tooltip if the message is absent or empty.
  144. const message = tooltipTargetNode.getAttribute('aria-label')
  145. if (!message) return
  146. // Do not show the tooltip if it was temporarily suspended.
  147. // This is signaled by any ancestors having a special CSS class assigned.
  148. if (tooltipTargetNode.closest('.no-tooltip')) return
  149. hasHoverOnNode = true // Set it to capture mousemove event
  150. const tooltipRecord = getModifierRecord(tooltipTargetNode)
  151. const { isTruncated } = evaluateModifiers(
  152. tooltipTargetNode,
  153. tooltipRecord?.modifiers,
  154. )
  155. // If the content gets truncated and the modifier is set to only show the tooltip on truncation
  156. if (!isTruncated && tooltipRecord?.modifiers.truncate) return
  157. if (tooltipTimeout) clearTimeout(tooltipTimeout)
  158. tooltipTimeout = setTimeout(() => {
  159. addTooltip(tooltipTargetNode, message as string, {
  160. event: currentEvent as MouseEvent,
  161. })
  162. isTooltipInDom = true
  163. }, 300) // Sets a delay before showing tooltip as native
  164. }
  165. const handleEvent = (event: MouseEvent | TouchEvent) => {
  166. if (hasHoverOnNode) currentEvent = event
  167. }
  168. const handleTooltipRemoveEvent = () => {
  169. if (tooltipTimeout) clearTimeout(tooltipTimeout)
  170. if (isTooltipInDom) removeTooltips()
  171. }
  172. const addEventListeners = () => {
  173. window.addEventListener('scroll', handleTooltipRemoveEvent, {
  174. passive: true,
  175. capture: true,
  176. }) // important to catch scroll event in capturing phase
  177. window.addEventListener('touchstart', handleTooltipAddEvent, {
  178. passive: true,
  179. })
  180. window.addEventListener('touchmove', handleEvent, {
  181. passive: true,
  182. })
  183. window.addEventListener('touchcancel', handleTooltipRemoveEvent, {
  184. passive: true,
  185. })
  186. window.addEventListener('mouseover', handleTooltipAddEvent, {
  187. passive: true,
  188. })
  189. window.addEventListener('mousemove', handleEvent, {
  190. passive: true,
  191. })
  192. window.addEventListener('mouseout', handleTooltipRemoveEvent, {
  193. passive: true,
  194. })
  195. }
  196. const cleanupEventHandlers = () => {
  197. window.removeEventListener('touchstart', handleTooltipAddEvent)
  198. window.removeEventListener('touchmove', handleEvent)
  199. window.removeEventListener('touchcancel', handleTooltipRemoveEvent)
  200. window.removeEventListener('mouseover', handleTooltipAddEvent)
  201. window.removeEventListener('mousemove', handleEvent)
  202. window.removeEventListener('mouseout', handleTooltipRemoveEvent)
  203. window.removeEventListener('scroll', handleTooltipRemoveEvent)
  204. }
  205. const cleanupAndAddEventListeners = () => {
  206. cleanupEventHandlers()
  207. addEventListeners()
  208. }
  209. export default {
  210. name: 'tooltip',
  211. directive: {
  212. mounted: (element: HTMLDivElement, { value: message, modifiers }) => {
  213. if (!message) return
  214. element.setAttribute('aria-label', message)
  215. // Mobile does not have tooltips, hence we don't apply the rest of the logic
  216. if (useAppName() === 'mobile') return
  217. element.setAttribute('data-tooltip', 'true')
  218. addModifierRecord(element, modifiers)
  219. if (!isListeningToEvents) {
  220. addEventListeners()
  221. isListeningToEvents = true
  222. // Resize we cannot add it into the cleanup function
  223. window.addEventListener('resize', cleanupAndAddEventListeners)
  224. }
  225. },
  226. updated(element: HTMLDivElement, { value: message }) {
  227. if (!message) {
  228. if (element.getAttribute('aria-label'))
  229. element.removeAttribute('aria-label')
  230. return
  231. }
  232. // In some cases, we update the aria-label on an interval f.e table time cells
  233. // We don't want to write to the DOM on every update if nothing has changed
  234. if (element.getAttribute('aria-label') !== message)
  235. element.setAttribute('aria-label', message)
  236. },
  237. beforeUnmount(element) {
  238. // If we dynamically remove the element from the DOM, we need to remove it from the tooltipTargetRecords
  239. removeModifierRecord(element)
  240. // If there are no more elements with the tooltip directive, remove event listeners
  241. if (tooltipRecordsCount !== 1) return
  242. // Cleanup only on the last element
  243. if (isTooltipInDom) removeTooltips()
  244. if (isListeningToEvents) cleanupEventHandlers()
  245. isListeningToEvents = false
  246. tooltipTargetRecords = new WeakMap()
  247. tooltipRecordsCount = 0
  248. window.removeEventListener('resize', cleanupAndAddEventListeners)
  249. },
  250. },
  251. } as {
  252. name: string
  253. directive: ObjectDirective
  254. }