tooltip.ts 9.3 KB

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