// ============================================================================ // Stylescape | Tooltip // ============================================================================ // Accessible tooltip with positioning and various trigger modes. // Supports data-ss-tooltip attributes for declarative configuration. // ============================================================================ /** * Tooltip positioning options */ export type TooltipPosition = "top" | "bottom" | "left" | "right" | "auto"; /** * Tooltip trigger modes */ export type TooltipTrigger = "hover" | "focus" | "click" | "manual"; /** * Configuration options for Tooltip */ export interface TooltipOptions { /** Tooltip content (HTML supported) */ content?: string; /** Position relative to trigger */ position?: TooltipPosition; /** Trigger mode */ trigger?: TooltipTrigger | TooltipTrigger[]; /** Show delay in milliseconds */ showDelay?: number; /** Hide delay in milliseconds */ hideDelay?: number; /** Animation duration */ animationDuration?: number; /** Offset from trigger element */ offset?: number; /** CSS class for tooltip */ tooltipClass?: string; /** Max width for tooltip */ maxWidth?: number; /** Allow HTML content */ allowHTML?: boolean; /** Interactive tooltip (don't close on tooltip hover) */ interactive?: boolean; /** Arrow/pointer */ arrow?: boolean; /** Z-index for tooltip */ zIndex?: number; } /** * Accessible tooltip with smart positioning. * * @example JavaScript * ```typescript * const tooltip = new Tooltip("#helpIcon", { * content: "Click here for more info", * position: "top", * trigger: ["hover", "focus"] * }) * ``` * * @example HTML with data-ss * ```html * * * * HTML * ``` */ export class Tooltip { private triggerElement: HTMLElement | null; private tooltipElement: HTMLElement | null = null; private arrowElement: HTMLElement | null = null; private options: Required; private showTimeout: ReturnType | null = null; private hideTimeout: ReturnType | null = null; private isVisible: boolean = false; constructor( selectorOrElement: string | HTMLElement, options: TooltipOptions = {}, ) { this.triggerElement = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; const triggers = options.trigger ? Array.isArray(options.trigger) ? options.trigger : [options.trigger] : ["hover", "focus"]; this.options = { content: options.content ?? this.triggerElement?.getAttribute("title") ?? "", position: options.position ?? "top", trigger: triggers as TooltipTrigger[], showDelay: options.showDelay ?? 200, hideDelay: options.hideDelay ?? 100, animationDuration: options.animationDuration ?? 150, offset: options.offset ?? 8, tooltipClass: options.tooltipClass ?? "tooltip__popup", maxWidth: options.maxWidth ?? 250, allowHTML: options.allowHTML ?? false, interactive: options.interactive ?? false, arrow: options.arrow ?? true, zIndex: options.zIndex ?? 9999, }; if (!this.triggerElement) { console.warn("[Stylescape] Tooltip trigger element not found"); return; } // Remove native title to prevent browser tooltip if (this.triggerElement.hasAttribute("title")) { this.triggerElement.removeAttribute("title"); } this.init(); } // ======================================================================== // Public Methods // ======================================================================== /** * Show the tooltip */ public show(): void { if (!this.triggerElement) return; this.clearTimeouts(); this.showTimeout = setTimeout(() => { this.createTooltip(); this.position(); this.tooltipElement?.classList.add( `${this.options.tooltipClass}--visible`, ); this.isVisible = true; }, this.options.showDelay); } /** * Hide the tooltip */ public hide(): void { this.clearTimeouts(); this.hideTimeout = setTimeout(() => { this.tooltipElement?.classList.remove( `${this.options.tooltipClass}--visible`, ); setTimeout(() => { this.destroyTooltip(); this.isVisible = false; }, this.options.animationDuration); }, this.options.hideDelay); } /** * Toggle tooltip visibility */ public toggle(): void { if (this.isVisible) { this.hide(); } else { this.show(); } } /** * Update tooltip content */ public setContent(content: string): void { this.options.content = content; if (this.tooltipElement) { const contentEl = this.tooltipElement.querySelector( `.${this.options.tooltipClass}__content`, ); if (contentEl) { if (this.options.allowHTML) { contentEl.innerHTML = content; } else { contentEl.textContent = content; } } } } /** * Update tooltip position */ public setPosition(position: TooltipPosition): void { this.options.position = position; if (this.tooltipElement) { this.position(); } } /** * Check if tooltip is visible */ public get visible(): boolean { return this.isVisible; } /** * Destroy the tooltip */ public destroy(): void { this.clearTimeouts(); this.destroyTooltip(); this.removeEventListeners(); this.triggerElement = null; } // ======================================================================== // Static Factory // ======================================================================== /** * Initialize all tooltips with data-ss="tooltip" */ public static initTooltips(): Tooltip[] { const tooltips: Tooltip[] = []; document .querySelectorAll('[data-ss="tooltip"]') .forEach((el) => { const content = el.dataset.ssTooltipContent || el.getAttribute("title") || ""; const position = (el.dataset.ssTooltipPosition as TooltipPosition) || "top"; const trigger = (el.dataset.ssTooltipTrigger?.split( ",", ) as TooltipTrigger[]) || ["hover", "focus"]; tooltips.push( new Tooltip(el, { content, position, trigger, }), ); }); return tooltips; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { if (!this.triggerElement) return; // Set up ARIA const id = `tooltip-${Date.now()}-${Math.random().toString(36).slice(2)}`; this.triggerElement.setAttribute("aria-describedby", id); // Add event listeners based on trigger const triggers = this.options.trigger as TooltipTrigger[]; if (triggers.includes("hover")) { this.triggerElement.addEventListener( "mouseenter", this.handleMouseEnter, ); this.triggerElement.addEventListener( "mouseleave", this.handleMouseLeave, ); } if (triggers.includes("focus")) { this.triggerElement.addEventListener("focus", this.handleFocus); this.triggerElement.addEventListener("blur", this.handleBlur); } if (triggers.includes("click")) { this.triggerElement.addEventListener("click", this.handleClick); } } private removeEventListeners(): void { if (!this.triggerElement) return; this.triggerElement.removeEventListener( "mouseenter", this.handleMouseEnter, ); this.triggerElement.removeEventListener( "mouseleave", this.handleMouseLeave, ); this.triggerElement.removeEventListener("focus", this.handleFocus); this.triggerElement.removeEventListener("blur", this.handleBlur); this.triggerElement.removeEventListener("click", this.handleClick); } private createTooltip(): void { if (this.tooltipElement) return; const tooltip = document.createElement("div"); tooltip.className = this.options.tooltipClass; tooltip.setAttribute("role", "tooltip"); tooltip.setAttribute( "id", this.triggerElement?.getAttribute("aria-describedby") || "", ); tooltip.style.zIndex = String(this.options.zIndex); tooltip.style.maxWidth = `${this.options.maxWidth}px`; // Content const content = document.createElement("div"); content.className = `${this.options.tooltipClass}__content`; if (this.options.allowHTML) { content.innerHTML = this.options.content; } else { content.textContent = this.options.content; } tooltip.appendChild(content); // Arrow if (this.options.arrow) { this.arrowElement = document.createElement("div"); this.arrowElement.className = `${this.options.tooltipClass}__arrow`; tooltip.appendChild(this.arrowElement); } // Interactive mode if (this.options.interactive) { tooltip.addEventListener("mouseenter", this.clearTimeouts); tooltip.addEventListener("mouseleave", () => this.hide()); } document.body.appendChild(tooltip); this.tooltipElement = tooltip; } private destroyTooltip(): void { this.tooltipElement?.remove(); this.tooltipElement = null; this.arrowElement = null; } private position(): void { if (!this.triggerElement || !this.tooltipElement) return; const triggerRect = this.triggerElement.getBoundingClientRect(); const tooltipRect = this.tooltipElement.getBoundingClientRect(); const scrollX = window.scrollX; const scrollY = window.scrollY; let position = this.options.position; // Auto-position if (position === "auto") { position = this.calculateBestPosition(triggerRect, tooltipRect); } let top: number; let left: number; switch (position) { case "top": top = triggerRect.top + scrollY - tooltipRect.height - this.options.offset; left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2; break; case "bottom": top = triggerRect.bottom + scrollY + this.options.offset; left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2; break; case "left": top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2; left = triggerRect.left + scrollX - tooltipRect.width - this.options.offset; break; case "right": top = triggerRect.top + scrollY + (triggerRect.height - tooltipRect.height) / 2; left = triggerRect.right + scrollX + this.options.offset; break; default: top = triggerRect.top + scrollY - tooltipRect.height - this.options.offset; left = triggerRect.left + scrollX + (triggerRect.width - tooltipRect.width) / 2; } // Keep within viewport left = Math.max( 8, Math.min(left, window.innerWidth - tooltipRect.width - 8), ); this.tooltipElement.style.top = `${top}px`; this.tooltipElement.style.left = `${left}px`; this.tooltipElement.dataset.position = position; // Position arrow if (this.arrowElement) { this.arrowElement.dataset.position = position; } } private calculateBestPosition( triggerRect: DOMRect, tooltipRect: DOMRect, ): TooltipPosition { const space = { top: triggerRect.top, bottom: window.innerHeight - triggerRect.bottom, left: triggerRect.left, right: window.innerWidth - triggerRect.right, }; const height = tooltipRect.height + this.options.offset; const width = tooltipRect.width + this.options.offset; if (space.top >= height) return "top"; if (space.bottom >= height) return "bottom"; if (space.right >= width) return "right"; if (space.left >= width) return "left"; return "top"; } private clearTimeouts = (): void => { if (this.showTimeout) { clearTimeout(this.showTimeout); this.showTimeout = null; } if (this.hideTimeout) { clearTimeout(this.hideTimeout); this.hideTimeout = null; } }; // ======================================================================== // Event Handlers // ======================================================================== private handleMouseEnter = (): void => { this.show(); }; private handleMouseLeave = (): void => { this.hide(); }; private handleFocus = (): void => { this.show(); }; private handleBlur = (): void => { this.hide(); }; private handleClick = (event: Event): void => { event.preventDefault(); this.toggle(); }; } export default Tooltip;