const TOOLTIP_SELECTOR = '[data-tooltip], [title]'; const TOOLTIP_BOUND_ATTRIBUTE = 'lfTooltipBound'; /** * Owns the single LayerForge tooltip and binds tooltip metadata added to * registered UI roots. Native `title` attributes are normalized to * `data-tooltip` so the browser cannot display a second tooltip alongside it. */ export class TooltipManager { constructor() { this.tooltipElement = null; this.tooltipTarget = null; this.observedRoots = new Map(); this.viewportFrame = null; this.hideTimer = null; this.tooltipDismissCallback = null; this.viewportListenersAttached = false; this.tooltipInteractionListenersAttached = false; this.documentListenersAttached = false; this.handleTooltipMouseEnter = () => { this.cancelScheduledHide(); }; this.handleTooltipMouseLeave = () => { if (this.tooltipElement?.getAttribute('data-persistent') === 'true') return; this.scheduleHide(); }; this.handleDocumentPointerDown = (event) => { if (!this.tooltipTarget || this.tooltipElement?.getAttribute('data-persistent') !== 'true') { return; } const eventTarget = event.target; if (eventTarget instanceof Node && (this.tooltipTarget.contains(eventTarget) || this.tooltipElement?.contains(eventTarget))) { return; } this.hideTooltip(); }; this.handleDocumentKeyDown = (event) => { if (event.key !== 'Escape' || !this.tooltipTarget || this.tooltipElement?.getAttribute('data-persistent') !== 'true') { return; } event.preventDefault(); event.stopPropagation(); this.hideTooltip(); }; this.handleViewportChange = () => { if (!this.tooltipTarget || !this.tooltipElement) return; if (this.viewportFrame !== null || typeof window === 'undefined') return; this.viewportFrame = window.requestAnimationFrame(() => { this.viewportFrame = null; if (this.tooltipTarget) { this.positionTooltip(this.tooltipTarget); } }); }; } observeRoot(root) { this.ensureTooltipElement(); this.bindTooltips(root); if (!this.observedRoots.has(root) && typeof MutationObserver !== 'undefined') { const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'attributes') { const target = mutation.target; if (target instanceof HTMLElement) { this.bindTooltips(target); } continue; } for (const node of mutation.addedNodes) { if (node instanceof HTMLElement) { this.bindTooltips(node); } } } }); observer.observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ['title', 'data-tooltip'], }); this.observedRoots.set(root, observer); } return () => this.unobserveRoot(root); } unobserveRoot(root) { this.observedRoots.get(root)?.disconnect(); this.observedRoots.delete(root); this.hideTooltip(root); } bindTooltips(container) { const targets = []; if (container.matches?.(TOOLTIP_SELECTOR)) { targets.push(container); } container.querySelectorAll(TOOLTIP_SELECTOR).forEach((target) => { targets.push(target); }); targets.forEach((target) => { this.normalizeTooltipTarget(target); if (target.dataset[TOOLTIP_BOUND_ATTRIBUTE] === '1') return; target.dataset[TOOLTIP_BOUND_ATTRIBUTE] = '1'; target.addEventListener('mouseenter', () => { this.cancelScheduledHide(); this.showTooltip(target); }); target.addEventListener('focus', () => this.showTooltip(target)); target.addEventListener('mouseleave', () => this.handleTargetLeave(target)); target.addEventListener('blur', () => this.handleTargetLeave(target)); }); } handleTargetLeave(target) { if (this.tooltipTarget === target && this.tooltipElement?.getAttribute('data-persistent') === 'true') { return; } if (this.tooltipTarget === target && this.tooltipElement?.getAttribute('data-interactive') === 'true') { this.scheduleHide(target); return; } this.hideTooltip(target); } scheduleHideTooltip(scope) { this.scheduleHide(scope); } scheduleHide(scope) { this.cancelScheduledHide(); if (typeof window === 'undefined') { this.hideTooltip(scope); return; } this.hideTimer = window.setTimeout(() => { this.hideTimer = null; this.hideTooltip(scope); }, 120); } cancelScheduledHide() { if (this.hideTimer === null) return; if (typeof window !== 'undefined') { window.clearTimeout(this.hideTimer); } this.hideTimer = null; } normalizeTooltipTarget(target) { const title = target.getAttribute('title'); if (title !== null && !target.hasAttribute('data-tooltip')) { target.setAttribute('data-tooltip', title); } if (target.hasAttribute('title')) { target.removeAttribute('title'); } } setTooltip(target, text, options = {}) { if (!target) return; if (text) { target.setAttribute('data-tooltip', text); } else { target.removeAttribute('data-tooltip'); } if (options.html) { target.setAttribute('data-tooltip-html', 'true'); } else { target.removeAttribute('data-tooltip-html'); } target.removeAttribute('title'); this.bindTooltips(target); } removeTooltip(target) { target.removeAttribute('data-tooltip'); target.removeAttribute('data-tooltip-html'); target.removeAttribute('title'); this.hideTooltip(target); } showTooltip(target, contentOverride, options = {}) { this.cancelScheduledHide(); if (!target || !this.ensureTooltipElement()) return; if ('isConnected' in target && !target.isConnected) return; if (this.tooltipTarget && this.tooltipTarget !== target) { this.hideTooltip(); } this.normalizeTooltipTarget(target); const content = contentOverride !== undefined ? contentOverride : target.getAttribute('data-tooltip'); if (!content) return; const tooltip = this.tooltipElement; if (!tooltip) return; this.tooltipTarget = target; tooltip.replaceChildren(); const renderAsHtml = options.html || target.getAttribute('data-tooltip-html') === 'true'; const isInteractive = renderAsHtml && options.interactive !== false; tooltip.setAttribute('data-content-mode', renderAsHtml ? 'html' : 'text'); if (isInteractive) { tooltip.setAttribute('data-interactive', 'true'); } else { tooltip.removeAttribute('data-interactive'); } if (options.persistent) { tooltip.setAttribute('data-persistent', 'true'); } else { tooltip.removeAttribute('data-persistent'); } this.tooltipDismissCallback = options.onDismiss ?? null; if (renderAsHtml) { tooltip.innerHTML = content; } else { tooltip.textContent = content; } tooltip.style.display = 'block'; tooltip.setAttribute('data-visible', 'true'); tooltip.setAttribute('aria-hidden', 'false'); this.positionTooltip(target); } isVisibleFor(target) { return this.tooltipTarget === target && this.tooltipElement?.style.display === 'block'; } hideTooltip(scope) { if (scope && this.tooltipTarget && scope !== this.tooltipTarget && !scope.contains(this.tooltipTarget)) { return; } this.cancelScheduledHide(); this.tooltipTarget = null; const dismissCallback = this.tooltipDismissCallback; this.tooltipDismissCallback = null; if (!this.tooltipElement) { dismissCallback?.(); return; } this.tooltipElement.style.display = 'none'; this.tooltipElement.style.maxHeight = ''; this.tooltipElement.replaceChildren(); this.tooltipElement.removeAttribute('data-visible'); this.tooltipElement.removeAttribute('data-content-mode'); this.tooltipElement.removeAttribute('data-interactive'); this.tooltipElement.removeAttribute('data-persistent'); this.tooltipElement.setAttribute('aria-hidden', 'true'); dismissCallback?.(); } destroy() { this.observedRoots.forEach((observer) => observer.disconnect()); this.observedRoots.clear(); this.hideTooltip(); if (this.tooltipInteractionListenersAttached && this.tooltipElement) { this.tooltipElement.removeEventListener('mouseenter', this.handleTooltipMouseEnter); this.tooltipElement.removeEventListener('mouseleave', this.handleTooltipMouseLeave); this.tooltipInteractionListenersAttached = false; } if (this.viewportFrame !== null && typeof window !== 'undefined') { window.cancelAnimationFrame(this.viewportFrame); this.viewportFrame = null; } if (this.viewportListenersAttached && typeof window !== 'undefined') { window.removeEventListener('resize', this.handleViewportChange); window.removeEventListener('scroll', this.handleViewportChange, true); this.viewportListenersAttached = false; } if (this.documentListenersAttached && typeof document !== 'undefined') { document.removeEventListener('pointerdown', this.handleDocumentPointerDown, true); document.removeEventListener('keydown', this.handleDocumentKeyDown, true); this.documentListenersAttached = false; } this.tooltipElement?.remove(); this.tooltipElement = null; } ensureTooltipElement() { if (typeof document === 'undefined' || !document.body) return null; if (this.tooltipElement?.isConnected) return this.tooltipElement; const existing = document.getElementById('lf-global-tooltip'); this.tooltipElement = existing instanceof HTMLDivElement ? existing : document.createElement('div'); this.tooltipElement.id = 'lf-global-tooltip'; this.tooltipElement.className = 'lf-global-tooltip'; this.tooltipElement.setAttribute('role', 'tooltip'); this.tooltipElement.setAttribute('aria-hidden', 'true'); if (!this.tooltipElement.isConnected) { document.body.appendChild(this.tooltipElement); } if (!this.tooltipInteractionListenersAttached) { this.tooltipElement.addEventListener('mouseenter', this.handleTooltipMouseEnter); this.tooltipElement.addEventListener('mouseleave', this.handleTooltipMouseLeave); this.tooltipInteractionListenersAttached = true; } if (!this.documentListenersAttached) { document.addEventListener('pointerdown', this.handleDocumentPointerDown, true); document.addEventListener('keydown', this.handleDocumentKeyDown, true); this.documentListenersAttached = true; } if (!this.viewportListenersAttached && typeof window !== 'undefined') { window.addEventListener('resize', this.handleViewportChange); window.addEventListener('scroll', this.handleViewportChange, true); this.viewportListenersAttached = true; } return this.tooltipElement; } positionTooltip(target) { if (!this.tooltipElement || this.tooltipTarget !== target) return; const rect = target.getBoundingClientRect(); this.tooltipElement.style.maxHeight = ''; const tooltipRect = this.tooltipElement.getBoundingClientRect(); const margin = 12; const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const maxLeft = Math.max(margin, viewportWidth - tooltipRect.width - margin); const maxTop = Math.max(margin, viewportHeight - tooltipRect.height - margin); let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); left = Math.min(Math.max(margin, left), maxLeft); let top = rect.top - tooltipRect.height - 10; if (top < margin) { top = Math.min(maxTop, rect.bottom + 10); } top = Math.min(Math.max(margin, top), maxTop); this.tooltipElement.style.left = `${Math.round(left)}px`; this.tooltipElement.style.top = `${Math.round(top)}px`; } } export const tooltipManager = new TooltipManager();