tooltip.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. type Modifiers = Record<
  5. 'modifiers',
  6. {
  7. truncateOnly?: boolean
  8. }
  9. >
  10. interface TooltipTargetRecord {
  11. element: HTMLDivElement
  12. modifiers: Modifiers['modifiers']
  13. }
  14. let isListeningToEvents = false
  15. let isTooltipInDom = false
  16. let hasHoverOnNode = false
  17. let currentEvent: MouseEvent | TouchEvent | null = null
  18. let tooltipTimeout: NodeJS.Timeout | null = null
  19. let tooltipTargetRecords: {
  20. element: HTMLDivElement
  21. modifiers: Modifiers['modifiers']
  22. }[] = []
  23. const removeTooltips = () => {
  24. document
  25. .querySelectorAll('[role="tooltip"]')
  26. .forEach((node) => node?.remove())
  27. isTooltipInDom = false
  28. }
  29. const addModifierRecord = (
  30. element: HTMLDivElement,
  31. modifiers: Modifiers['modifiers'],
  32. ) => {
  33. tooltipTargetRecords.push({
  34. element,
  35. modifiers,
  36. })
  37. }
  38. const getModifierRecord = ($el: HTMLDivElement): TooltipTargetRecord => {
  39. return (
  40. tooltipTargetRecords.find(({ element }) => element === $el) || {
  41. modifiers: { truncateOnly: undefined },
  42. element: $el,
  43. }
  44. )
  45. }
  46. const createTooltip = (
  47. { top, left }: { top: string; left: string },
  48. message: string,
  49. ) => {
  50. const tooltipNode = document.createElement('div')
  51. tooltipNode.classList.add('tooltip')
  52. tooltipNode.style.top = top
  53. tooltipNode.style.left = left
  54. tooltipNode.setAttribute('aria-hidden', 'true')
  55. tooltipNode.setAttribute('role', 'tooltip')
  56. // Set the max-width to half of the available width
  57. const availableWidth = window.innerWidth / 2
  58. tooltipNode.style.maxWidth = `${availableWidth}px`
  59. const tooltipMessageNode = document.createElement('p')
  60. tooltipMessageNode.textContent = message
  61. tooltipNode.insertAdjacentElement('afterbegin', tooltipMessageNode)
  62. return tooltipNode
  63. }
  64. const getLeftBasedOnLanguage = (clientX: number, tooltipRectangle: DOMRect) => {
  65. const isRTL = useLocaleStore().localeData?.dir === 'rtl'
  66. let left = ''
  67. if (isRTL) {
  68. // For RTL languages, place the tooltip to the left of the mouse
  69. const leftValue = clientX - tooltipRectangle.width
  70. left = `${leftValue}px`
  71. // Check if the tooltip would overflow the window's width
  72. if (leftValue < 0) {
  73. // If it would, adjust the left property to ensure it fits within the window
  74. left = '0px'
  75. }
  76. } else {
  77. // For LTR languages, place the tooltip to the right of the mouse
  78. left = `${clientX}px`
  79. // Check if the tooltip would overflow the window's width
  80. if (clientX + tooltipRectangle.width > window.innerWidth) {
  81. // Move tooltip to the left if it overflows to avoid squeezing the tooltip content
  82. left = `${window.innerWidth - tooltipRectangle.width}px`
  83. }
  84. }
  85. return left
  86. }
  87. const addTooltip = (
  88. targetNode: HTMLDivElement,
  89. message: string,
  90. {
  91. event,
  92. }: {
  93. event: MouseEvent | TouchEvent
  94. },
  95. ) => {
  96. const tooltipNode = createTooltip({ top: '0px', left: '0px' }, message)
  97. document.body.appendChild(tooltipNode) // Temporarily add to DOM to calculate dimensions
  98. const tooltipRectangle = tooltipNode.getBoundingClientRect()
  99. let top: string
  100. let left: string
  101. if ('touches' in event) {
  102. const { clientX, clientY } = event.touches[0]
  103. top = `${clientY}px`
  104. left = getLeftBasedOnLanguage(clientX, tooltipRectangle)
  105. } else {
  106. const { clientX, clientY } = event
  107. const verticalThreshold = 10 // native tooltip has an extra threshold of ~ 10px
  108. top = `${clientY + verticalThreshold}px`
  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 = (
  125. element: HTMLDivElement,
  126. options?: TooltipTargetRecord['modifiers'],
  127. ) => {
  128. const modifications = {
  129. isTruncated: false,
  130. }
  131. if (options?.truncateOnly) {
  132. modifications.isTruncated = isContentTruncated(element)
  133. }
  134. return modifications
  135. }
  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 as HTMLDivElement)?.hasAttribute('data-tooltip')) return
  139. hasHoverOnNode = true // Set it to capture mousemove event
  140. const tooltipTargetNode = event.target as HTMLDivElement
  141. const tooltipRecord = getModifierRecord(tooltipTargetNode)
  142. const { isTruncated } = evaluateModifiers(
  143. tooltipTargetNode,
  144. tooltipRecord.modifiers,
  145. )
  146. // If the content gets truncated and the modifier is set to only show the tooltip on truncation
  147. if (!isTruncated && tooltipRecord?.modifiers.truncateOnly) return
  148. if (tooltipTimeout) clearTimeout(tooltipTimeout)
  149. const message = tooltipTargetNode.getAttribute('aria-label')
  150. tooltipTimeout = setTimeout(() => {
  151. addTooltip(tooltipTargetNode, message as string, {
  152. event: currentEvent as MouseEvent,
  153. })
  154. isTooltipInDom = true
  155. }, 500) // Sets a 500ms delay before showing tooltip as native
  156. }
  157. const handleEvent = (event: MouseEvent | TouchEvent) => {
  158. if (hasHoverOnNode) currentEvent = event
  159. }
  160. const handleTooltipRemoveEvent = () => {
  161. if (tooltipTimeout) clearTimeout(tooltipTimeout)
  162. if (isTooltipInDom) removeTooltips()
  163. }
  164. const addEventListeners = () => {
  165. window.addEventListener('scroll', handleTooltipRemoveEvent, {
  166. passive: true,
  167. capture: true,
  168. }) // important to catch scroll event in capturing phase
  169. window.addEventListener('touchstart', handleTooltipAddEvent, {
  170. passive: true,
  171. })
  172. window.addEventListener('touchmove', handleEvent, {
  173. passive: true,
  174. })
  175. window.addEventListener('touchcancel', handleTooltipRemoveEvent, {
  176. passive: true,
  177. })
  178. window.addEventListener('mouseover', handleTooltipAddEvent, {
  179. passive: true,
  180. })
  181. window.addEventListener('mousemove', handleEvent, {
  182. passive: true,
  183. })
  184. window.addEventListener('mouseout', handleTooltipRemoveEvent, {
  185. passive: true,
  186. })
  187. }
  188. const cleanupEventHandlers = () => {
  189. window.removeEventListener('touchstart', handleTooltipAddEvent)
  190. window.removeEventListener('touchmove', handleEvent)
  191. window.removeEventListener('touchcancel', handleTooltipRemoveEvent)
  192. window.removeEventListener('mouseover', handleTooltipAddEvent)
  193. window.removeEventListener('mousemove', handleEvent)
  194. window.removeEventListener('mouseout', handleTooltipRemoveEvent)
  195. window.removeEventListener('scroll', handleTooltipRemoveEvent)
  196. }
  197. const cleanupAndAddEventListeners = () => {
  198. cleanupEventHandlers()
  199. addEventListeners()
  200. }
  201. export default {
  202. name: 'tooltip',
  203. directive: {
  204. mounted: (element: HTMLDivElement, { value: message, modifiers }) => {
  205. if (!message) return
  206. addModifierRecord(element, modifiers)
  207. element.setAttribute('aria-label', message)
  208. element.setAttribute('data-tooltip', 'true')
  209. if (!isListeningToEvents) {
  210. addEventListeners()
  211. isListeningToEvents = true
  212. // Resize we can not add it into cleanup function
  213. window.addEventListener('resize', cleanupAndAddEventListeners)
  214. }
  215. },
  216. updated(element: HTMLDivElement, { value: message }) {
  217. // In some cases we update the aria-label on an interval f.e table time cells
  218. // We don't want to write to the DOM on every update if nothing has changed
  219. if (element.getAttribute('aria-label') !== message)
  220. element.setAttribute('aria-label', message)
  221. },
  222. beforeUnmount(element) {
  223. // If we dynamically remove the element from the DOM, we need to remove it from the tooltipTargetRecords
  224. tooltipTargetRecords = tooltipTargetRecords.filter(
  225. (record) => record.element !== element,
  226. )
  227. // If there are no more elements with the tooltip directive, remove event listeners
  228. if (tooltipTargetRecords.length !== 1) return
  229. // Cleanup only on the last element
  230. if (isTooltipInDom) removeTooltips()
  231. if (isListeningToEvents) cleanupEventHandlers()
  232. isListeningToEvents = false
  233. tooltipTargetRecords = []
  234. window.removeEventListener('resize', cleanupAndAddEventListeners)
  235. },
  236. },
  237. } as {
  238. name: string
  239. directive: ObjectDirective
  240. }