/** * Tooltip Manager for LiveComponents * * Provides tooltip functionality for LiveComponent elements with: * - Automatic positioning * - Validation error tooltips * - Accessibility support * - Smooth animations * - Multiple positioning strategies */ export class TooltipManager { constructor() { this.tooltips = new Map(); // element → tooltip element this.activeTooltip = null; this.config = { delay: 300, // ms before showing tooltip hideDelay: 100, // ms before hiding tooltip maxWidth: 300, // px offset: 8, // px offset from element animationDuration: 200, // ms zIndex: 10000 }; } /** * Initialize tooltip for element * * @param {HTMLElement} element - Element to attach tooltip to * @param {Object} options - Tooltip options */ init(element, options = {}) { // Get tooltip content from data attributes or options const content = options.content || element.dataset.tooltip || element.getAttribute('title') || element.getAttribute('aria-label') || ''; if (!content) { return; // No tooltip content } // Remove native title attribute to prevent default tooltip if (element.hasAttribute('title')) { element.dataset.originalTitle = element.getAttribute('title'); element.removeAttribute('title'); } // Get positioning const position = options.position || element.dataset.tooltipPosition || 'top'; // Setup event listeners this.setupEventListeners(element, content, position, options); } /** * Setup event listeners for tooltip * * @param {HTMLElement} element - Element * @param {string} content - Tooltip content * @param {string} position - Position (top, bottom, left, right) * @param {Object} options - Additional options */ setupEventListeners(element, content, position, options) { let showTimeout; let hideTimeout; const showTooltip = () => { clearTimeout(hideTimeout); showTimeout = setTimeout(() => { this.show(element, content, position, options); }, this.config.delay); }; const hideTooltip = () => { clearTimeout(showTimeout); hideTimeout = setTimeout(() => { this.hide(element); }, this.config.hideDelay); }; // Mouse events element.addEventListener('mouseenter', showTooltip); element.addEventListener('mouseleave', hideTooltip); element.addEventListener('focus', showTooltip); element.addEventListener('blur', hideTooltip); // Touch events (for mobile) element.addEventListener('touchstart', showTooltip, { passive: true }); element.addEventListener('touchend', hideTooltip, { passive: true }); // Store cleanup function element._tooltipCleanup = () => { clearTimeout(showTimeout); clearTimeout(hideTimeout); element.removeEventListener('mouseenter', showTooltip); element.removeEventListener('mouseleave', hideTooltip); element.removeEventListener('focus', showTooltip); element.removeEventListener('blur', hideTooltip); element.removeEventListener('touchstart', showTooltip); element.removeEventListener('touchend', hideTooltip); }; } /** * Show tooltip * * @param {HTMLElement} element - Element * @param {string} content - Tooltip content * @param {string} position - Position * @param {Object} options - Additional options */ show(element, content, position, options = {}) { // Hide existing tooltip if (this.activeTooltip) { this.hide(); } // Create tooltip element const tooltip = document.createElement('div'); tooltip.className = 'livecomponent-tooltip'; tooltip.setAttribute('role', 'tooltip'); tooltip.setAttribute('aria-hidden', 'false'); tooltip.textContent = content; // Apply styles tooltip.style.cssText = ` position: absolute; max-width: ${this.config.maxWidth}px; padding: 0.5rem 0.75rem; background: #1f2937; color: white; border-radius: 0.375rem; font-size: 0.875rem; line-height: 1.4; z-index: ${this.config.zIndex}; pointer-events: none; opacity: 0; transition: opacity ${this.config.animationDuration}ms ease; word-wrap: break-word; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); `; // Add to DOM document.body.appendChild(tooltip); // Position tooltip this.positionTooltip(tooltip, element, position); // Animate in requestAnimationFrame(() => { tooltip.style.opacity = '1'; }); // Store references this.tooltips.set(element, tooltip); this.activeTooltip = tooltip; // Set aria-describedby on element const tooltipId = `tooltip-${Date.now()}`; tooltip.id = tooltipId; element.setAttribute('aria-describedby', tooltipId); } /** * Position tooltip relative to element * * @param {HTMLElement} tooltip - Tooltip element * @param {HTMLElement} element - Target element * @param {string} position - Position (top, bottom, left, right) */ positionTooltip(tooltip, element, position) { const rect = element.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const scrollX = window.scrollX || window.pageXOffset; const scrollY = window.scrollY || window.pageYOffset; const offset = this.config.offset; let top, left; switch (position) { case 'top': top = rect.top + scrollY - tooltipRect.height - offset; left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2); break; case 'bottom': top = rect.bottom + scrollY + offset; left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2); break; case 'left': top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2); left = rect.left + scrollX - tooltipRect.width - offset; break; case 'right': top = rect.top + scrollY + (rect.height / 2) - (tooltipRect.height / 2); left = rect.right + scrollX + offset; break; default: top = rect.top + scrollY - tooltipRect.height - offset; left = rect.left + scrollX + (rect.width / 2) - (tooltipRect.width / 2); } // Keep tooltip within viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; // Horizontal adjustment if (left < scrollX) { left = scrollX + 8; } else if (left + tooltipRect.width > scrollX + viewportWidth) { left = scrollX + viewportWidth - tooltipRect.width - 8; } // Vertical adjustment if (top < scrollY) { top = scrollY + 8; } else if (top + tooltipRect.height > scrollY + viewportHeight) { top = scrollY + viewportHeight - tooltipRect.height - 8; } tooltip.style.top = `${top}px`; tooltip.style.left = `${left}px`; } /** * Hide tooltip * * @param {HTMLElement} element - Element (optional, hides active tooltip if not provided) */ hide(element = null) { const tooltip = element ? this.tooltips.get(element) : this.activeTooltip; if (!tooltip) { return; } // Animate out tooltip.style.opacity = '0'; // Remove after animation setTimeout(() => { if (tooltip.parentNode) { tooltip.parentNode.removeChild(tooltip); } // Remove aria-describedby if (element) { element.removeAttribute('aria-describedby'); } // Clean up references if (element) { this.tooltips.delete(element); } if (this.activeTooltip === tooltip) { this.activeTooltip = null; } }, this.config.animationDuration); } /** * Show validation error tooltip * * @param {HTMLElement} element - Form element * @param {string} message - Error message */ showValidationError(element, message) { // Remove existing error tooltip this.hideValidationError(element); // Show error tooltip with error styling const tooltip = document.createElement('div'); tooltip.className = 'livecomponent-tooltip livecomponent-tooltip--error'; tooltip.setAttribute('role', 'alert'); tooltip.textContent = message; tooltip.style.cssText = ` position: absolute; max-width: ${this.config.maxWidth}px; padding: 0.5rem 0.75rem; background: #dc2626; color: white; border-radius: 0.375rem; font-size: 0.875rem; line-height: 1.4; z-index: ${this.config.zIndex}; pointer-events: none; opacity: 0; transition: opacity ${this.config.animationDuration}ms ease; word-wrap: break-word; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); `; document.body.appendChild(tooltip); this.positionTooltip(tooltip, element, 'top'); // Animate in requestAnimationFrame(() => { tooltip.style.opacity = '1'; }); // Store reference element._validationTooltip = tooltip; // Add error class to element element.classList.add('livecomponent-error'); } /** * Hide validation error tooltip * * @param {HTMLElement} element - Form element */ hideValidationError(element) { const tooltip = element._validationTooltip; if (!tooltip) { return; } // Animate out tooltip.style.opacity = '0'; setTimeout(() => { if (tooltip.parentNode) { tooltip.parentNode.removeChild(tooltip); } delete element._validationTooltip; element.classList.remove('livecomponent-error'); }, this.config.animationDuration); } /** * Initialize all tooltips in component * * @param {HTMLElement} container - Component container */ initComponent(container) { // Find all elements with tooltip attributes const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]'); elements.forEach(element => { this.init(element); }); } /** * Cleanup tooltips for component * * @param {HTMLElement} container - Component container */ cleanupComponent(container) { const elements = container.querySelectorAll('[data-tooltip], [title], [aria-label]'); elements.forEach(element => { // Hide tooltip this.hide(element); // Cleanup event listeners if (element._tooltipCleanup) { element._tooltipCleanup(); delete element._tooltipCleanup; } // Restore original title if (element.dataset.originalTitle) { element.setAttribute('title', element.dataset.originalTitle); delete element.dataset.originalTitle; } }); } /** * Update configuration * * @param {Object} newConfig - New configuration */ updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; } } // Create singleton instance export const tooltipManager = new TooltipManager();