@yashrajbharti/tooltip-component
Version:
A modern, lightweight tooltip component built with Web Components and Shadow DOM
322 lines (274 loc) • 8.97 kB
JavaScript
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);
}
(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);