@studiocms/ui
Version:
The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.
331 lines (330 loc) • 10.7 kB
JavaScript
class Tooltip {
container;
anchor;
tooltip;
options;
resizeObserver = null;
throttleUpdate;
rafId = null;
offset;
isSticky = false;
edgePadding = 8;
constructor(container) {
if (!container) {
throw new Error("Tooltip: Container element is required.");
}
this.container = container;
this.anchor = container.querySelector("[data-sui-tooltip-anchor]");
this.tooltip = container.querySelector("[data-sui-tooltip-popup]");
this.options = this.processOptionsFromAttributes();
this.offset = this.options.gap ?? 8;
this.throttleUpdate = this.throttle(() => this.update(), 16);
this.#init();
}
processOptionsFromAttributes() {
const jsonOptions = this.container.getAttribute("data-sui-tooltip-options");
let options = {
position: "auto",
enterDelay: 0,
exitDelay: 0,
hoverOnly: false,
animate: true,
pointer: true
};
try {
const parsedOptions = jsonOptions ? JSON.parse(jsonOptions) : {};
options = { ...options, ...parsedOptions };
} catch {
}
return options;
}
#init() {
this.update();
this.bindEvents();
this.container.dataset.initialized = "true";
}
update() {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = requestAnimationFrame(() => this.updatePosition());
}
bindEvents() {
window.addEventListener("resize", this.throttleUpdate);
if (window.ResizeObserver) {
this.resizeObserver = new ResizeObserver(() => this.update());
this.resizeObserver.observe(this.tooltip);
this.resizeObserver.observe(this.anchor);
}
}
updatePosition() {
const anchorRect = this.anchor.getBoundingClientRect();
const tooltipWidth = this.tooltip.offsetWidth;
const tooltipHeight = this.tooltip.offsetHeight;
const position = this.determinePosition(anchorRect, tooltipWidth, tooltipHeight);
for (const p of ["top", "bottom", "left", "right", "overlap"]) {
this.tooltip.classList.toggle(p, p === position);
}
let x = null;
let y = null;
const anchorCenter = {
x: anchorRect.left + window.scrollX + anchorRect.width / 2,
y: anchorRect.top + window.scrollY + anchorRect.height / 2
};
switch (position) {
case "top": {
x = anchorCenter.x - tooltipWidth / 2;
y = anchorRect.top + window.scrollY - tooltipHeight - this.offset;
break;
}
case "bottom": {
x = anchorCenter.x - tooltipWidth / 2;
y = anchorRect.bottom + window.scrollY + this.offset;
break;
}
case "left": {
x = anchorRect.left + window.scrollX - tooltipWidth - 12;
y = anchorCenter.y - tooltipHeight / 2;
break;
}
case "right": {
x = anchorRect.right + window.scrollX + 12;
y = anchorCenter.y - tooltipHeight / 2;
break;
}
default: {
x = anchorCenter.x - tooltipWidth / 2;
y = anchorRect.top + window.scrollY - tooltipHeight - this.offset;
break;
}
}
let finalPosition;
const anchorIsVisible = this.isAnchorInViewport(anchorRect);
if (anchorIsVisible) {
finalPosition = this.applyViewportConstraints(x, y, tooltipWidth, tooltipHeight);
} else {
finalPosition = { x, y };
}
this.tooltip.style.left = `${finalPosition.x}px`;
this.tooltip.style.top = `${finalPosition.y}px`;
if (this.options.pointer) {
this.updatePointer(position, anchorCenter, finalPosition, tooltipWidth, tooltipHeight);
}
}
updatePointer(position, anchorCenter, tooltipPosition, tooltipWidth, tooltipHeight) {
const arrow = this.tooltip.querySelector(".sui-tooltip-pointer");
if (!arrow) return;
arrow.style.left = "";
arrow.style.top = "";
const arrowSize = 8;
if (position === "top" || position === "bottom") {
const arrowLeft = anchorCenter.x - tooltipPosition.x - arrowSize;
const min = arrowSize;
const max = tooltipWidth - arrowSize * 2;
arrow.style.left = `${Math.max(min, Math.min(arrowLeft, max))}px`;
} else if (position === "left" || position === "right") {
const arrowTop = anchorCenter.y - tooltipPosition.y - arrowSize;
const min = arrowSize;
const max = tooltipHeight - arrowSize * 2;
arrow.style.top = `${Math.max(min, Math.min(arrowTop, max))}px`;
}
}
determinePosition(anchorRect, tooltipWidth, tooltipHeight) {
const position = this.options.position;
const anchor = anchorRect;
const space = {
top: anchor.top - this.offset - this.edgePadding,
bottom: window.innerHeight - anchor.bottom - this.offset - this.edgePadding,
left: anchor.left - this.offset - this.edgePadding,
right: window.innerWidth - anchor.right - this.offset - this.edgePadding
};
const canFit = {
top: space.top >= tooltipHeight,
bottom: space.bottom >= tooltipHeight,
left: space.left >= tooltipWidth,
right: space.right >= tooltipWidth
};
const positionHierarchy = {
top: ["top", "bottom", "right", "left"],
bottom: ["bottom", "top", "right", "left"],
left: ["left", "right", "top", "bottom"],
right: ["right", "left", "top", "bottom"],
auto: ["top", "bottom", "right", "left"]
};
const priority = positionHierarchy[position] || positionHierarchy.auto;
for (const pos of priority) {
if (canFit[pos]) {
return pos;
}
}
return "overlap";
}
isAnchorInViewport(anchorRect) {
return anchorRect.top < window.innerHeight && anchorRect.bottom > 0 && anchorRect.left < window.innerWidth && anchorRect.right > 0;
}
applyViewportConstraints(x, y, width, height) {
const minX = this.edgePadding + window.scrollX;
const maxX = window.innerWidth - width - this.edgePadding + window.scrollX;
const finalX = Math.max(minX, Math.min(x, maxX));
const minY = this.edgePadding + window.scrollY;
const maxY = window.innerHeight - height - this.edgePadding + window.scrollY;
const finalY = Math.max(minY, Math.min(y, maxY));
return {
x: finalX,
y: finalY
};
}
throttle(fn, delay) {
let timeoutId = 0;
let lastExecTime = 0;
return (...args) => {
const currentTime = Date.now();
const timeSinceLastExec = currentTime - lastExecTime;
clearTimeout(timeoutId);
if (timeSinceLastExec >= delay) {
fn.apply(this, args);
lastExecTime = currentTime;
} else {
timeoutId = window.setTimeout(() => {
fn.apply(this, args);
lastExecTime = Date.now();
}, delay - timeSinceLastExec);
}
};
}
show() {
this.tooltip.setAttribute("data-visible", "true");
this.anchor.setAttribute("aria-describedby", this.tooltip.id);
this.update();
}
hide() {
this.tooltip.removeAttribute("data-visible");
this.anchor.removeAttribute("aria-describedby");
}
getAnchor() {
return this.anchor;
}
isHoverOnly() {
return this.options.hoverOnly ?? false;
}
setSticky(sticky) {
this.isSticky = sticky;
}
isDefaultOpen() {
return this.options.defaultOpen ?? false;
}
destroy() {
window.removeEventListener("resize", this.throttleUpdate);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.anchor.removeAttribute("aria-describedby");
}
}
function loadTooltips() {
const allTooltipElements = document.querySelectorAll(
".sui-tooltip-container[data-sui-tooltip]"
);
let activeTooltip = null;
let enterTimeout;
let exitTimeout;
const show = (tooltipInstance, immediate = false) => {
clearTimeout(exitTimeout);
const delay = immediate ? 0 : tooltipInstance.options.enterDelay;
enterTimeout = window.setTimeout(() => {
if (activeTooltip && activeTooltip !== tooltipInstance) {
activeTooltip.hide();
activeTooltip.setSticky(false);
}
tooltipInstance.show();
activeTooltip = tooltipInstance;
}, delay);
};
const hide = (tooltipInstance) => {
clearTimeout(enterTimeout);
exitTimeout = window.setTimeout(() => {
tooltipInstance.hide();
tooltipInstance.setSticky(false);
if (activeTooltip === tooltipInstance) {
activeTooltip = null;
}
}, tooltipInstance.options.exitDelay);
};
for (const element of allTooltipElements) {
if (element.dataset.initialized) continue;
const tooltipInstance = new Tooltip(element);
const anchor = tooltipInstance.getAnchor();
const popup = tooltipInstance.tooltip;
window.sui.tooltips.instances.set(tooltipInstance.container.id, tooltipInstance);
anchor.addEventListener("mouseenter", () => {
if (!tooltipInstance.isSticky) {
show(tooltipInstance);
}
});
anchor.addEventListener("focus", () => {
if (!tooltipInstance.isSticky) {
show(tooltipInstance);
}
});
anchor.addEventListener("mouseleave", () => {
if (!tooltipInstance.isSticky) {
hide(tooltipInstance);
}
});
anchor.addEventListener("blur", () => {
if (!tooltipInstance.isSticky) {
hide(tooltipInstance);
}
});
anchor.addEventListener("click", (e) => {
e.stopPropagation();
if (tooltipInstance.isSticky) {
hide(tooltipInstance);
} else {
show(tooltipInstance);
tooltipInstance.setSticky(true);
}
});
popup.addEventListener("mouseenter", () => clearTimeout(exitTimeout));
popup.addEventListener("mouseleave", () => {
if (!tooltipInstance.isSticky) {
hide(tooltipInstance);
}
});
if (tooltipInstance.isDefaultOpen()) {
show(tooltipInstance, true);
tooltipInstance.setSticky(true);
}
}
document.addEventListener("click", (e) => {
if (activeTooltip?.isSticky && !activeTooltip.getAnchor().contains(e.target) && !activeTooltip.tooltip.contains(e.target)) {
hide(activeTooltip);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && activeTooltip) {
hide(activeTooltip);
}
});
}
window.sui = window.sui ?? {};
window.sui.tooltips = {
instances: /* @__PURE__ */ new Map(),
get: function(id) {
return this.instances.get(id);
},
show: function(id) {
const instance = this.get(id);
if (instance) {
instance.show();
instance.setSticky(true);
}
},
hide: function(id) {
this.get(id)?.hide();
}
};
document.addEventListener("astro:page-load", loadTooltips);