preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
405 lines (333 loc) • 11.2 kB
text/typescript
/*
* 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<HSTooltip> | 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;