preline
Version:
Preline UI is an open-source set of prebuilt UI components based on the utility-first Tailwind CSS framework.
399 lines (323 loc) • 10.7 kB
text/typescript
/*
* HSTooltip
* @version: 3.1.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,
offset,
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 onToggleMouseEnterListener: () => void;
private onToggleMouseLeaveListener: () => void;
private onToggleHandleListener: () => void;
constructor(el: HTMLElement, options?: {}, events?: {}) {
super(el, options, events);
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");
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 init() {
this.createCollection(window.$hsTooltipCollection, this);
if (this.eventMode === "click") {
this.onToggleClickListener = () => this.toggleClick();
this.toggle.addEventListener("click", this.onToggleClickListener);
} else if (this.eventMode === "focus") {
this.onToggleFocusListener = () => this.toggleFocus();
this.toggle.addEventListener("click", this.onToggleFocusListener);
} 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,
);
}
if (this.preventFloatingUI === "false") this.buildFloatingUI();
}
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();
const handle = () => {
this.hide();
this.toggle.removeEventListener("blur", handle, true);
};
this.toggle.addEventListener("blur", handle, true);
}
private buildFloatingUI() {
if (this.scope === "window") document.body.appendChild(this.content);
const checkSpaceAndAdjust = (x: number, y: number, placement: string) => {
const contentRect = this.content.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollbarWidth = window.innerWidth -
document.documentElement.clientWidth;
const availableWidth = viewportWidth - scrollbarWidth;
const toggleRect = this.toggle.getBoundingClientRect();
let adjustedX = x;
let adjustedY = y;
let adjustedPlacement = placement;
if (
placement.includes("right") && x + contentRect.width > availableWidth
) {
adjustedPlacement = placement.replace("right", "left");
adjustedX = toggleRect.left - contentRect.width - 5;
} else if (placement.includes("left") && x < 0) {
adjustedPlacement = placement.replace("left", "right");
adjustedX = toggleRect.right + 5;
}
if (placement.includes("top") && y - contentRect.height < 0) {
adjustedPlacement = placement.replace("top", "bottom");
adjustedY = toggleRect.bottom + 5;
} else if (
placement.includes("bottom") && y + contentRect.height > viewportHeight
) {
adjustedPlacement = placement.replace("bottom", "top");
adjustedY = toggleRect.top - contentRect.height - 5;
}
return { x: adjustedX, y: adjustedY, placement: adjustedPlacement };
};
computePosition(this.toggle, this.content, {
placement: POSITIONS[this.placement] || "top",
strategy: this.strategy || "fixed",
middleware: [offset(5)],
}).then(({ x, y, placement }) => {
const adjusted = checkSpaceAndAdjust(x, y, placement);
Object.assign(this.content.style, {
position: this.strategy || "fixed",
left: `${adjusted.x}px`,
top: `${adjusted.y}px`,
});
this.content.setAttribute("data-placement", adjusted.placement);
});
this.cleanupAutoUpdate = autoUpdate(this.toggle, this.content, () => {
computePosition(this.toggle, this.content, {
placement: POSITIONS[this.placement] || "top",
strategy: this.strategy || "fixed",
middleware: [offset(5)],
}).then(({ x, y, placement }) => {
const adjusted = checkSpaceAndAdjust(x, y, placement);
Object.assign(this.content.style, {
left: `${adjusted.x}px`,
top: `${adjusted.y}px`,
});
this.content.setAttribute("data-placement", adjusted.placement);
});
});
}
private _show() {
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() {
switch (this.eventMode) {
case "click":
this.click();
break;
case "focus":
this.focus();
break;
default:
this.enter();
break;
}
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
if (this.eventMode === "click") {
this.toggle.removeEventListener("click", this.onToggleClickListener);
} else if (this.eventMode === "focus") {
this.toggle.removeEventListener("click", this.onToggleFocusListener);
} 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;
}
}
declare global {
interface Window {
HSTooltip: Function;
$hsTooltipCollection: ICollectionItem<HSTooltip>[];
}
}
window.addEventListener("load", () => {
HSTooltip.autoInit();
// Uncomment for debug
// console.log('Tooltip collection:', window.$hsTooltipCollection);
});
if (typeof window !== "undefined") {
window.HSTooltip = HSTooltip;
}
export default HSTooltip;