123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- import { type ObjectDirective } from 'vue'
- import { useAppName } from '#shared/composables/useAppName.ts'
- import { useLocaleStore } from '#shared/stores/locale.ts'
- interface Modifiers {
- truncate?: boolean
- }
- let isListeningToEvents = false
- let isTooltipInDom = false
- let hasHoverOnNode = false
- let currentEvent: MouseEvent | TouchEvent | null = null
- let tooltipTimeout: NodeJS.Timeout | null = null
- let tooltipRecordsCount = 0
- let tooltipTargetRecords: WeakMap<HTMLElement, { modifiers: Modifiers }> =
- new WeakMap()
- const removeTooltips = () => {
- document
- .querySelectorAll('[role="tooltip"]')
- .forEach((node) => node?.remove())
- isTooltipInDom = false
- }
- const addModifierRecord = (element: HTMLDivElement, modifiers: Modifiers) => {
- if (tooltipTargetRecords.has(element)) return
- tooltipRecordsCount += 1
- tooltipTargetRecords.set(element, {
- modifiers,
- })
- }
- const removeModifierRecord = (element: HTMLDivElement) => {
- if (!tooltipTargetRecords.has(element)) return
- tooltipRecordsCount -= 1
- tooltipTargetRecords.delete(element)
- }
- const getModifierRecord = ($el: HTMLDivElement) => {
- return tooltipTargetRecords.get($el) || null
- }
- const createTooltip = (
- { top, left }: { top: string; left: string },
- message: string,
- ) => {
- const tooltipNode = document.createElement('div')
- tooltipNode.classList.add('tooltip')
- tooltipNode.style.top = top
- tooltipNode.style.left = left
- tooltipNode.setAttribute('aria-hidden', 'true')
- tooltipNode.setAttribute('role', 'tooltip')
- // Set the max-width to half of the available width
- const availableWidth = window.innerWidth / 2
- tooltipNode.style.maxWidth = `${availableWidth}px`
- const tooltipMessageNode = document.createElement('p')
- tooltipMessageNode.textContent = message
- tooltipNode.insertAdjacentElement('afterbegin', tooltipMessageNode)
- return tooltipNode
- }
- const getLeftBasedOnLanguage = (clientX: number, tooltipRectangle: DOMRect) => {
- const isRTL = useLocaleStore().localeData?.dir === 'rtl'
- let left = ''
- if (isRTL) {
- // For RTL languages, place the tooltip to the left of the mouse
- const leftValue = clientX - tooltipRectangle.width
- left = `${leftValue}px`
- // Check if the tooltip would overflow the window's width
- if (leftValue < 0) {
- // If it would, adjust the left property to ensure it fits within the window
- left = '0px'
- }
- } else {
- // For LTR languages, place the tooltip to the right of the mouse
- left = `${clientX}px`
- // Check if the tooltip would overflow the window's width
- if (clientX + tooltipRectangle.width > window.innerWidth) {
- // Move tooltip to the left if it overflows to avoid squeezing the tooltip content
- left = `${window.innerWidth - tooltipRectangle.width}px`
- }
- }
- return left
- }
- const addTooltip = (
- targetNode: HTMLDivElement,
- message: string,
- {
- event,
- }: {
- event: MouseEvent | TouchEvent
- },
- ) => {
- if (!event) return
- const tooltipNode = createTooltip({ top: '0px', left: '0px' }, message)
- document.body.appendChild(tooltipNode) // Temporarily add to DOM to calculate dimensions
- const tooltipRectangle = tooltipNode.getBoundingClientRect()
- let top: string
- let left: string
- if (!event) return
- if ('touches' in event) {
- const { clientX, clientY } = event.touches[0]
- top = `${clientY}px`
- left = getLeftBasedOnLanguage(clientX, tooltipRectangle)
- } else {
- const { clientX, clientY } = event
- const verticalThreshold = 10 // native tooltip has an extra threshold of ~ 10px
- const thresholdToBottom = 30
- const availableSpaceBelow = window.innerHeight - clientY - thresholdToBottom
- // If the tooltip is to close to the bottom of the viewport, show it above the target
- if (availableSpaceBelow < tooltipRectangle.height) {
- top = `${clientY - verticalThreshold - tooltipRectangle.height}px`
- } else {
- top = `${clientY + verticalThreshold}px`
- }
- left = getLeftBasedOnLanguage(clientX, tooltipRectangle)
- }
- tooltipNode.style.top = top
- tooltipNode.style.left = left
- document.body.insertAdjacentElement('beforeend', tooltipNode)
- setTimeout(() => {
- tooltipNode.classList.add('tooltip-animate')
- }, 500) // Add animation after 500ms same as for delay
- }
- const isContentTruncated = (element: HTMLElement) => {
- const { parentElement } = element
- // top-level element
- if (!parentElement) return element.offsetWidth < element.scrollWidth
- return parentElement.offsetWidth < parentElement.scrollWidth
- }
- const evaluateModifiers = (element: HTMLElement, options?: Modifiers) => {
- const modifications = {
- isTruncated: false,
- top: false,
- }
- if (options?.truncate) {
- modifications.isTruncated = isContentTruncated(element)
- }
- return modifications
- }
- const findTooltipTarget = (
- element: HTMLDivElement | null,
- ): HTMLDivElement | null => element?.closest('[data-tooltip]') || null
- const handleTooltipAddEvent = (event: MouseEvent | TouchEvent) => {
- if (isTooltipInDom) removeTooltips() // Remove tooltips if there is already one set in the DOM
- if (!event.target) return
- // Do not show the tooltip if the target element is missing.
- const tooltipTargetNode = findTooltipTarget(event.target as HTMLDivElement)
- if (!tooltipTargetNode) return
- // Do not show the tooltip if the message is absent or empty.
- const message = tooltipTargetNode.getAttribute('aria-label')
- if (!message) return
- // Do not show the tooltip if it was temporarily suspended.
- // This is signaled by any ancestors having a special CSS class assigned.
- if (tooltipTargetNode.closest('.no-tooltip')) return
- hasHoverOnNode = true // Set it to capture mousemove event
- const tooltipRecord = getModifierRecord(tooltipTargetNode)
- const { isTruncated } = evaluateModifiers(
- tooltipTargetNode,
- tooltipRecord?.modifiers,
- )
- // If the content gets truncated and the modifier is set to only show the tooltip on truncation
- if (!isTruncated && tooltipRecord?.modifiers.truncate) return
- if (tooltipTimeout) clearTimeout(tooltipTimeout)
- tooltipTimeout = setTimeout(() => {
- addTooltip(tooltipTargetNode, message as string, {
- event: currentEvent as MouseEvent,
- })
- isTooltipInDom = true
- }, 300) // Sets a delay before showing tooltip as native
- }
- const handleEvent = (event: MouseEvent | TouchEvent) => {
- if (hasHoverOnNode) currentEvent = event
- }
- const handleTooltipRemoveEvent = () => {
- if (tooltipTimeout) clearTimeout(tooltipTimeout)
- if (isTooltipInDom) removeTooltips()
- }
- const addEventListeners = () => {
- window.addEventListener('scroll', handleTooltipRemoveEvent, {
- passive: true,
- capture: true,
- }) // important to catch scroll event in capturing phase
- window.addEventListener('touchstart', handleTooltipAddEvent, {
- passive: true,
- })
- window.addEventListener('touchmove', handleEvent, {
- passive: true,
- })
- window.addEventListener('touchcancel', handleTooltipRemoveEvent, {
- passive: true,
- })
- window.addEventListener('mouseover', handleTooltipAddEvent, {
- passive: true,
- })
- window.addEventListener('mousemove', handleEvent, {
- passive: true,
- })
- window.addEventListener('mouseout', handleTooltipRemoveEvent, {
- passive: true,
- })
- }
- const cleanupEventHandlers = () => {
- window.removeEventListener('touchstart', handleTooltipAddEvent)
- window.removeEventListener('touchmove', handleEvent)
- window.removeEventListener('touchcancel', handleTooltipRemoveEvent)
- window.removeEventListener('mouseover', handleTooltipAddEvent)
- window.removeEventListener('mousemove', handleEvent)
- window.removeEventListener('mouseout', handleTooltipRemoveEvent)
- window.removeEventListener('scroll', handleTooltipRemoveEvent)
- }
- const cleanupAndAddEventListeners = () => {
- cleanupEventHandlers()
- addEventListeners()
- }
- export default {
- name: 'tooltip',
- directive: {
- mounted: (element: HTMLDivElement, { value: message, modifiers }) => {
- if (!message) return
- element.setAttribute('aria-label', message)
- // Mobile does not have tooltips, hence we don't apply the rest of the logic
- if (useAppName() === 'mobile') return
- element.setAttribute('data-tooltip', 'true')
- addModifierRecord(element, modifiers)
- if (!isListeningToEvents) {
- addEventListeners()
- isListeningToEvents = true
- // Resize we cannot add it into the cleanup function
- window.addEventListener('resize', cleanupAndAddEventListeners)
- }
- },
- updated(element: HTMLDivElement, { value: message }) {
- if (!message) {
- if (element.getAttribute('aria-label'))
- element.removeAttribute('aria-label')
- return
- }
- // In some cases, we update the aria-label on an interval f.e table time cells
- // We don't want to write to the DOM on every update if nothing has changed
- if (element.getAttribute('aria-label') !== message)
- element.setAttribute('aria-label', message)
- },
- beforeUnmount(element) {
- // If we dynamically remove the element from the DOM, we need to remove it from the tooltipTargetRecords
- removeModifierRecord(element)
- // If there are no more elements with the tooltip directive, remove event listeners
- if (tooltipRecordsCount !== 1) return
- // Cleanup only on the last element
- if (isTooltipInDom) removeTooltips()
- if (isListeningToEvents) cleanupEventHandlers()
- isListeningToEvents = false
- tooltipTargetRecords = new WeakMap()
- tooltipRecordsCount = 0
- window.removeEventListener('resize', cleanupAndAddEventListeners)
- },
- },
- } as {
- name: string
- directive: ObjectDirective
- }
|