UNPKG

@yashrajbharti/tooltip-component

Version:

A modern, lightweight tooltip component built with Web Components and Shadow DOM

322 lines (274 loc) 8.97 kB
class TooltipComponent extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); this.visible = false; this.coords = { top: 0, left: 0 }; this.showTimeoutId = null; this.hideTimeoutId = null; this.tooltipElement = null; this.render(); this.setupEventListeners(); } static get observedAttributes() { return ["tooltip", "placement", "delay", "arrow", "width", "open"]; } get title() { return this.getAttribute("tooltip") || ""; } get placement() { return this.getAttribute("placement") || "top"; } get delay() { return parseInt(this.getAttribute("delay")) || 150; } get arrow() { return this.hasAttribute("arrow"); } get width() { return this.getAttribute("width") || "fixed"; } get open() { return this.getAttribute("open") !== "false"; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { if (this.tooltipElement) { if (name === "tooltip") { this.tooltipElement.textContent = newValue; const autoWidth = this.getAutoWidth(); this.tooltipElement.className = `tooltip placement-${this.placement} width-${autoWidth}`; if (this.arrow) { const arrow = this.tooltipElement.querySelector(".tooltip-arrow"); if (arrow) this.tooltipElement.appendChild(arrow); } } else if (name === "placement") { const autoWidth = this.getAutoWidth(); this.tooltipElement.className = `tooltip placement-${this.placement} width-${autoWidth}`; const arrow = this.tooltipElement.querySelector(".tooltip-arrow"); if (arrow) { arrow.className = `tooltip-arrow placement-${this.placement}`; } } else if (name === "width") { const autoWidth = this.getAutoWidth(); this.tooltipElement.className = `tooltip placement-${this.placement} width-${autoWidth}`; } else if (name === "arrow") { const existingArrow = this.tooltipElement.querySelector(".tooltip-arrow"); if (this.arrow && !existingArrow) { const arrow = document.createElement("div"); arrow.className = `tooltip-arrow placement-${this.placement}`; this.tooltipElement.appendChild(arrow); } else if (!this.arrow && existingArrow) { existingArrow.remove(); } } } else { this.render(); } } } getAutoWidth() { const textLength = this.title.length; return textLength > 50 ? "fixed" : "fit"; } render() { const style = document.createElement("style"); style.textContent = ` :host { display: inline-block; } .tooltip { position: fixed; z-index: 9999; background: rgba(97, 97, 97, 0.9); color: white; padding-block: 4px; padding-inline: 8px; border-radius: 4px; font-family: system-ui, -apple-system, sans-serif; font-size: 11px; font-weight: 500; word-break: break-word; white-space: normal; backdrop-filter: blur(8px); transition: opacity 200ms ease, scale 200ms ease; pointer-events: none; opacity: 0; scale: 0.9; } .tooltip.visible { opacity: 1; scale: 1; } .tooltip.width-fixed { inline-size: 300px; } .tooltip.width-fit { inline-size: fit-content; } .tooltip.placement-top { transform-origin: block-end center; } .tooltip.placement-bottom { transform-origin: block-start center; } .tooltip.placement-left { transform-origin: inline-end center; } .tooltip.placement-right { transform-origin: inline-start center; } .tooltip-arrow { position: absolute; inline-size: 0; block-size: 0; } .tooltip-arrow.placement-top { inset-inline-start: 50%; inset-block-end: -4px; translate: -50% 0; border-inline-start: 4px solid transparent; border-inline-end: 4px solid transparent; border-block-start: 4px solid rgba(97, 97, 97, 0.9); } .tooltip-arrow.placement-bottom { inset-inline-start: 50%; inset-block-start: -4px; translate: -50% 0; border-inline-start: 4px solid transparent; border-inline-end: 4px solid transparent; border-block-end: 4px solid rgba(97, 97, 97, 0.9); } .tooltip-arrow.placement-left { inset-block-start: 50%; inset-inline-end: -4px; translate: 0 -50%; border-block-start: 4px solid transparent; border-block-end: 4px solid transparent; border-inline-start: 4px solid rgba(97, 97, 97, 0.9); } .tooltip-arrow.placement-right { inset-block-start: 50%; inset-inline-start: -4px; translate: 0 -50%; border-block-start: 4px solid transparent; border-block-end: 4px solid transparent; border-inline-end: 4px solid rgba(97, 97, 97, 0.9); } @media (prefers-reduced-motion: reduce) { .tooltip { transition: opacity 133ms ease, scale 133ms ease; } } `; const slot = document.createElement("slot"); this.tooltipElement = document.createElement("div"); const autoWidth = this.getAutoWidth(); this.tooltipElement.className = `tooltip placement-${this.placement} width-${autoWidth}`; this.tooltipElement.textContent = this.title; if (this.arrow) { const arrow = document.createElement("div"); arrow.className = `tooltip-arrow placement-${this.placement}`; this.tooltipElement.appendChild(arrow); } this.tooltipElement.addEventListener("mouseenter", () => { if (this.hideTimeoutId) { clearTimeout(this.hideTimeoutId); this.hideTimeoutId = null; } }); this.tooltipElement.addEventListener( "mouseleave", this.hideTooltip.bind(this) ); this.shadowRoot.innerHTML = ""; this.shadowRoot.appendChild(style); this.shadowRoot.appendChild(slot); this.shadowRoot.appendChild(this.tooltipElement); } setupEventListeners() { this.addEventListener("mouseenter", this.showTooltip.bind(this)); this.addEventListener("mouseleave", this.hideTooltip.bind(this)); } showTooltip() { if (!this.title || !this.open) return; if (this.hideTimeoutId) { clearTimeout(this.hideTimeoutId); this.hideTimeoutId = null; } if (this.showTimeoutId) { clearTimeout(this.showTimeoutId); this.showTimeoutId = null; } this.showTimeoutId = setTimeout(() => { this.updateTooltipPosition(); this.visible = true; this.tooltipElement.classList.add("visible"); }, this.delay); } hideTooltip() { if (this.showTimeoutId) { clearTimeout(this.showTimeoutId); this.showTimeoutId = null; } if (this.hideTimeoutId) { clearTimeout(this.hideTimeoutId); this.hideTimeoutId = null; } this.hideTimeoutId = setTimeout(() => { this.visible = false; if (this.tooltipElement) { this.tooltipElement.classList.remove("visible"); } }, 100); } updateTooltipPosition() { if (!this.tooltipElement) return; const rect = this.getBoundingClientRect(); const tooltipRect = this.tooltipElement.getBoundingClientRect(); let top = 0; let left = 0; switch (this.placement) { case "top": top = rect.top - tooltipRect.height - 8; left = rect.left + rect.width / 2 - tooltipRect.width / 2 - 10; break; case "bottom": top = rect.bottom + 8; left = rect.left + rect.width / 2 - tooltipRect.width / 2 - 10; break; case "left": top = rect.top + rect.height / 2 - tooltipRect.height / 2; left = rect.left - tooltipRect.width - 28; break; case "right": top = rect.top + rect.height / 2 - tooltipRect.height / 2; left = rect.right + 8; break; } const padding = 8; top = Math.max( padding, Math.min(top, window.innerHeight - tooltipRect.height - padding) ); left = Math.max( padding, Math.min(left, window.innerWidth - tooltipRect.width - padding) ); this.tooltipElement.style.insetBlockStart = `${top}px`; this.tooltipElement.style.insetInlineStart = `${left}px`; } disconnectedCallback() { if (this.showTimeoutId) { clearTimeout(this.showTimeoutId); } if (this.hideTimeoutId) { clearTimeout(this.hideTimeoutId); } if (this.tooltipElement && this.tooltipElement.parentNode) { this.tooltipElement.parentNode.removeChild(this.tooltipElement); } } } customElements.define("tooltip-component", TooltipComponent);