UNPKG

@studiocms/ui

Version:

The UI library for StudioCMS. Includes the layouts & components we use to build StudioCMS.

331 lines (330 loc) 10.7 kB
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);