/* * HSTooltip * @version: 4.2.0 * @author: Preline Labs Ltd. * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) * Copyright 2024 Preline Labs Ltd. */ import { autoUpdate, computePosition, flip, offset, type Placement, type Strategy, } from '@floating-ui/dom'; import { afterTransition, dispatch, getClassProperty } from '../../utils'; import { ITooltip } from './interfaces'; import { TTooltipOptionsScope } from './types'; import HSBasePlugin from '../base-plugin'; import { ICollectionItem } from '../../interfaces'; import { POSITIONS } from '../../constants'; class HSTooltip extends HSBasePlugin<{}> implements ITooltip { private readonly toggle: HTMLElement | null; public content: HTMLElement | null; readonly eventMode: string; private readonly preventFloatingUI: string; private readonly placement: string; private readonly strategy: Strategy; private readonly scope: TTooltipOptionsScope; cleanupAutoUpdate: (() => void) | null = null; private onToggleClickListener: () => void; private onToggleFocusListener: () => void; private onToggleBlurListener: () => void; private onToggleMouseEnterListener: () => void; private onToggleMouseLeaveListener: () => void; private onToggleHandleListener: () => void; constructor(el: HTMLElement, options?: {}, events?: {}) { super(el, options, events); if (!window.$hsTooltipCollection) window.$hsTooltipCollection = []; if (this.el) { this.toggle = this.el.querySelector('.hs-tooltip-toggle') || this.el; this.content = this.el.querySelector('.hs-tooltip-content'); this.eventMode = getClassProperty(this.el, '--trigger') || 'hover'; // TODO:: rename "Popper" to "FLoatingUI" this.preventFloatingUI = getClassProperty( this.el, '--prevent-popper', 'false', ); this.placement = getClassProperty(this.el, '--placement') || 'top'; this.strategy = getClassProperty(this.el, '--strategy') as Strategy; this.scope = (getClassProperty(this.el, '--scope') as TTooltipOptionsScope) || 'parent'; } if (this.el && this.toggle && this.content) this.init(); } private toggleClick() { this.click(); } private toggleFocus() { this.focus(); } private toggleMouseEnter() { this.enter(); } private toggleMouseLeave() { this.leave(); } private toggleHandle() { this.hide(); this.toggle.removeEventListener('click', this.onToggleHandleListener, true); this.toggle.removeEventListener('blur', this.onToggleHandleListener, true); } private hideOtherTooltips() { if (!window.$hsTooltipCollection) return; window.$hsTooltipCollection.forEach(({ element }) => { if (element.el === this.el) return false; if (!element.el.classList.contains('show')) return false; element.hide(); }); } private init() { this.createCollection(window.$hsTooltipCollection, this); this.onToggleFocusListener = () => this.enter(); this.onToggleBlurListener = () => this.hide(); this.toggle.addEventListener('focus', this.onToggleFocusListener); this.toggle.addEventListener('blur', this.onToggleBlurListener); if (this.eventMode === 'click') { this.onToggleClickListener = () => this.toggleClick(); this.toggle.addEventListener('click', this.onToggleClickListener); } else if (this.eventMode === 'hover') { this.onToggleMouseEnterListener = () => this.toggleMouseEnter(); this.onToggleMouseLeaveListener = () => this.toggleMouseLeave(); this.toggle.addEventListener( 'mouseenter', this.onToggleMouseEnterListener, ); this.toggle.addEventListener( 'mouseleave', this.onToggleMouseLeaveListener, ); } } private enter() { this._show(); } private leave() { this.hide(); } private click() { if (this.el.classList.contains('show')) return false; this._show(); this.onToggleHandleListener = () => { setTimeout(() => this.toggleHandle()); }; this.toggle.addEventListener('click', this.onToggleHandleListener, true); this.toggle.addEventListener('blur', this.onToggleHandleListener, true); } private focus() { this._show(); } private async positionTooltip( placement: string, ): Promise<{ x: number; y: number; placement: string }> { const actualPlacement = placement === 'auto' ? 'top' : placement; const fallbackPlacements = ( placement === 'auto' ? ['bottom', 'left', 'right'] : this.getFallbackPlacements(actualPlacement) ) as Placement[]; const middlewareArr = [offset(5), flip({ fallbackPlacements })]; const result = await computePosition(this.toggle, this.content, { placement: actualPlacement as any, strategy: this.strategy || 'fixed', middleware: middlewareArr, }); return result; } private getFallbackPlacements(placement: string): Placement[] { switch (placement) { case 'top': return ['bottom', 'left', 'right'] as Placement[]; case 'bottom': return ['top', 'left', 'right'] as Placement[]; case 'left': return ['right', 'top', 'bottom'] as Placement[]; case 'right': return ['left', 'top', 'bottom'] as Placement[]; case 'top-start': return ['bottom-start', 'top-end', 'bottom-end'] as Placement[]; case 'top-end': return ['bottom-end', 'top-start', 'bottom-start'] as Placement[]; case 'bottom-start': return ['top-start', 'bottom-end', 'top-end'] as Placement[]; case 'bottom-end': return ['top-end', 'bottom-start', 'top-start'] as Placement[]; case 'left-start': return ['right-start', 'left-end', 'right-end'] as Placement[]; case 'left-end': return ['right-end', 'left-start', 'right-start'] as Placement[]; case 'right-start': return ['left-start', 'right-end', 'left-end'] as Placement[]; case 'right-end': return ['left-end', 'right-start', 'left-start'] as Placement[]; default: return ['top', 'bottom', 'left', 'right'] as Placement[]; } } private applyTooltipPosition(x: number, y: number, placement: string) { Object.assign(this.content.style, { position: this.strategy || 'fixed', left: `${x}px`, top: `${y}px`, }); this.content.setAttribute('data-placement', placement); } private buildFloatingUI() { if (this.scope === 'window') document.body.appendChild(this.content); const isAutoPlacement = this.placement.startsWith('auto'); const originalPlacement = getClassProperty(this.el, '--placement'); const isDefaultPlacement = !originalPlacement || originalPlacement === ''; const targetPlacement = isAutoPlacement ? 'auto' : isDefaultPlacement ? 'auto' : POSITIONS[this.placement] || this.placement; this.positionTooltip(targetPlacement).then((result) => { this.applyTooltipPosition(result.x, result.y, result.placement); }); this.cleanupAutoUpdate = autoUpdate(this.toggle, this.content, () => { this.positionTooltip(targetPlacement).then((result) => { Object.assign(this.content.style, { position: this.strategy || 'fixed', left: `${result.x}px`, top: `${result.y}px`, }); this.content.setAttribute('data-placement', result.placement); }); }); } private _show() { if (this.el.classList.contains('show')) return false; this.hideOtherTooltips(); this.content.classList.remove('hidden'); if (this.scope === 'window') this.content.classList.add('show'); if (this.preventFloatingUI === 'false' && !this.cleanupAutoUpdate) { this.buildFloatingUI(); } setTimeout(() => { this.el.classList.add('show'); this.fireEvent('show', this.el); dispatch('show.hs.tooltip', this.el, this.el); }); } // Public methods public show() { if (this.eventMode === 'click') { this.click(); } else { this.enter(); } this.toggle.focus(); this.toggle.style.outline = 'none'; } public hide() { this.el.classList.remove('show'); if (this.scope === 'window') this.content.classList.remove('show'); if (this.preventFloatingUI === 'false' && this.cleanupAutoUpdate) { this.cleanupAutoUpdate(); this.cleanupAutoUpdate = null; } this.fireEvent('hide', this.el); dispatch('hide.hs.tooltip', this.el, this.el); afterTransition(this.content, () => { if (this.el.classList.contains('show')) return false; this.content.classList.add('hidden'); this.toggle.style.outline = ''; }); } public destroy() { // Remove classes this.el.classList.remove('show'); this.content.classList.add('hidden'); // Remove listeners this.toggle.removeEventListener('focus', this.onToggleFocusListener); this.toggle.removeEventListener('blur', this.onToggleBlurListener); // Remove eventMode-specific listeners if (this.eventMode === 'click') { this.toggle.removeEventListener('click', this.onToggleClickListener); } else if (this.eventMode === 'hover') { this.toggle.removeEventListener( 'mouseenter', this.onToggleMouseEnterListener, ); this.toggle.removeEventListener( 'mouseleave', this.onToggleMouseLeaveListener, ); } this.toggle.removeEventListener('click', this.onToggleHandleListener, true); this.toggle.removeEventListener('blur', this.onToggleHandleListener, true); if (this.cleanupAutoUpdate) { this.cleanupAutoUpdate(); this.cleanupAutoUpdate = null; } window.$hsTooltipCollection = window.$hsTooltipCollection.filter( ({ element }) => element.el !== this.el, ); } // Static methods private static findInCollection( target: HSTooltip | HTMLElement | string, ): ICollectionItem | null { return ( window.$hsTooltipCollection.find((el) => { if (target instanceof HSTooltip) return el.element.el === target.el; else if (typeof target === 'string') { return el.element.el === document.querySelector(target); } else return el.element.el === target; }) || null ); } static getInstance(target: HTMLElement | string, isInstance = false) { const elInCollection = window.$hsTooltipCollection.find( (el) => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target), ); return elInCollection ? isInstance ? elInCollection : elInCollection.element.el : null; } static autoInit() { if (!window.$hsTooltipCollection) window.$hsTooltipCollection = []; if (window.$hsTooltipCollection) { window.$hsTooltipCollection = window.$hsTooltipCollection.filter( ({ element }) => document.contains(element.el), ); } document .querySelectorAll('.hs-tooltip:not(.--prevent-on-load-init)') .forEach((el: HTMLElement) => { if ( !window.$hsTooltipCollection.find( (elC) => (elC?.element?.el as HTMLElement) === el, ) ) { new HSTooltip(el); } }); } static show(target: HSTooltip | HTMLElement | string) { const instance = HSTooltip.findInCollection(target); if (instance) instance.element.show(); } static hide(target: HSTooltip | HTMLElement | string) { const instance = HSTooltip.findInCollection(target); if (instance) instance.element.hide(); } // Backward compatibility static on( evt: string, target: HSTooltip | HTMLElement | string, cb: Function, ) { const instance = HSTooltip.findInCollection(target); if (instance) instance.element.events[evt] = cb; } } export default HSTooltip;