123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
- import { type ObjectDirective } from 'vue'
- import { useLocaleStore } from '#shared/stores/locale.ts'
- type Modifiers = Record<
- 'modifiers',
- {
- truncateOnly?: boolean
- }
- >
- interface TooltipTargetRecord {
- element: HTMLDivElement
- modifiers: Modifiers['modifiers']
- }
- let isListeningToEvents = false
- let isTooltipInDom = false
- let hasHoverOnNode = false
- let currentEvent: MouseEvent | TouchEvent | null = null
- let tooltipTimeout: NodeJS.Timeout | null = null
- let tooltipTargetRecords: {
- element: HTMLDivElement
- modifiers: Modifiers['modifiers']
- }[] = []
- const removeTooltips = () => {
- document
- .querySelectorAll('[role="tooltip"]')
- .forEach((node) => node?.remove())
- isTooltipInDom = false
- }
- const addModifierRecord = (
- element: HTMLDivElement,
- modifiers: Modifiers['modifiers'],
- ) => {
- tooltipTargetRecords.push({
- element,
- modifiers,
- })
- }
- const getModifierRecord = ($el: HTMLDivElement): TooltipTargetRecord => {
- return (
- tooltipTargetRecords.find(({ element }) => element === $el) || {
- modifiers: { truncateOnly: undefined },
- element: $el,
- }
- )
- }
- 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
- },
- ) => {
- 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 ('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
- 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: HTMLDivElement,
- options?: TooltipTargetRecord['modifiers'],
- ) => {
- const modifications = {
- isTruncated: false,
- }
- if (options?.truncateOnly) {
- modifications.isTruncated = isContentTruncated(element)
- }
- return modifications
- }
- const handleTooltipAddEvent = (event: MouseEvent | TouchEvent) => {
- if (isTooltipInDom) removeTooltips() // Remove tooltips if there is already one set in the DOM
- if (!(event.target as HTMLDivElement)?.hasAttribute('data-tooltip')) return
- hasHoverOnNode = true // Set it to capture mousemove event
- const tooltipTargetNode = event.target as HTMLDivElement
- 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.truncateOnly) return
- if (tooltipTimeout) clearTimeout(tooltipTimeout)
- const message = tooltipTargetNode.getAttribute('aria-label')
- tooltipTimeout = setTimeout(() => {
- addTooltip(tooltipTargetNode, message as string, {
- event: currentEvent as MouseEvent,
- })
- isTooltipInDom = true
- }, 500) // Sets a 500ms 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
- addModifierRecord(element, modifiers)
- element.setAttribute('aria-label', message)
- element.setAttribute('data-tooltip', 'true')
- if (!isListeningToEvents) {
- addEventListeners()
- isListeningToEvents = true
- // Resize we can not add it into cleanup function
- window.addEventListener('resize', cleanupAndAddEventListeners)
- }
- },
- updated(element: HTMLDivElement, { value: message }) {
- // 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
- tooltipTargetRecords = tooltipTargetRecords.filter(
- (record) => record.element !== element,
- )
- // If there are no more elements with the tooltip directive, remove event listeners
- if (tooltipTargetRecords.length !== 1) return
- // Cleanup only on the last element
- if (isTooltipInDom) removeTooltips()
- if (isListeningToEvents) cleanupEventHandlers()
- isListeningToEvents = false
- tooltipTargetRecords = []
- window.removeEventListener('resize', cleanupAndAddEventListeners)
- },
- },
- } as {
- name: string
- directive: ObjectDirective
- }
|