/** * Popover Manager for LiveComponents * * Manages popover components with: * - Native Popover API support (Chrome 114+) * - Fallback for older browsers * - Position calculation relative to anchor * - Auto-repositioning on scroll/resize * - Light-dismiss handling */ /** * Check if Popover API is supported * @returns {boolean} */ function supportsPopoverAPI() { // Check for popover property if (HTMLElement.prototype.hasOwnProperty('popover')) { return true; } // Check for showPopover method if ('showPopover' in HTMLElement.prototype) { return true; } // Check for CSS @supports (only in browser environment) if (typeof CSS !== 'undefined' && CSS.supports) { try { return CSS.supports('popover', 'auto'); } catch (e) { return false; } } return false; } export class PopoverManager { constructor() { this.popovers = new Map(); // componentId → popover element this.usePopoverAPI = supportsPopoverAPI(); this.resizeHandler = null; this.scrollHandler = null; if (this.usePopoverAPI) { console.log('[PopoverManager] Using native Popover API'); } else { console.log('[PopoverManager] Using fallback implementation'); this.setupGlobalHandlers(); } } /** * Show popover * @param {string} componentId - Component ID * @param {Object} options - Popover options * @returns {Object} Popover instance */ show(componentId, options = {}) { const { content = '', title = '', anchorId = null, position = 'top', showArrow = true, offset = 8, closeOnOutsideClick = true, zIndex = 1060 } = options; if (!anchorId) { console.warn('[PopoverManager] Popover requires anchorId'); return null; } const anchor = document.getElementById(anchorId); if (!anchor) { console.warn(`[PopoverManager] Anchor element not found: ${anchorId}`); return null; } // Remove existing popover for this component this.hide(componentId); if (this.usePopoverAPI) { return this.showWithPopoverAPI(componentId, anchor, content, title, position, showArrow, offset, zIndex); } else { return this.showWithFallback(componentId, anchor, content, title, position, showArrow, offset, closeOnOutsideClick, zIndex); } } /** * Show popover using native Popover API */ showWithPopoverAPI(componentId, anchor, content, title, position, showArrow, offset, zIndex) { const popover = document.createElement('div'); popover.setAttribute('popover', 'auto'); // 'auto' = light-dismiss enabled popover.className = `livecomponent-popover popover--${position}`; popover.id = `popover-${componentId}`; popover.style.zIndex = zIndex.toString(); popover.innerHTML = ` ${showArrow ? `
` : ''} ${title ? `

${title}

` : ''}
${content}
`; // Set anchor using anchor attribute (when supported) if (popover.setPopoverAnchor) { popover.setPopoverAnchor(anchor); } else { // Fallback: position manually this.positionPopover(popover, anchor, position, offset); } document.body.appendChild(popover); popover.showPopover(); this.popovers.set(componentId, popover); // Setup repositioning on scroll/resize this.setupRepositioning(popover, anchor, position, offset); return { element: popover, hide: () => this.hide(componentId), isVisible: () => popover.matches(':popover-open') }; } /** * Show popover using fallback implementation */ showWithFallback(componentId, anchor, content, title, position, showArrow, offset, closeOnOutsideClick, zIndex) { const popover = document.createElement('div'); popover.className = `livecomponent-popover popover-fallback popover--${position}`; popover.id = `popover-${componentId}`; popover.style.zIndex = zIndex.toString(); popover.setAttribute('role', 'tooltip'); popover.setAttribute('aria-hidden', 'false'); popover.innerHTML = ` ${showArrow ? `
` : ''} ${title ? `

${title}

` : ''}
${content}
`; document.body.appendChild(popover); // Position popover this.positionPopover(popover, anchor, position, offset); // Show popover popover.style.display = 'block'; popover.classList.add('popover--show'); this.popovers.set(componentId, popover); // Setup click-outside handling if (closeOnOutsideClick) { const clickHandler = (e) => { if (!popover.contains(e.target) && !anchor.contains(e.target)) { this.hide(componentId); } }; setTimeout(() => { document.addEventListener('click', clickHandler); popover._clickHandler = clickHandler; }, 0); } // Setup repositioning on scroll/resize this.setupRepositioning(popover, anchor, position, offset); return { element: popover, hide: () => this.hide(componentId), isVisible: () => popover.classList.contains('popover--show') }; } /** * Hide popover * @param {string} componentId - Component ID */ hide(componentId) { const popover = this.popovers.get(componentId); if (!popover) { return; } if (this.usePopoverAPI) { if (popover.hidePopover) { popover.hidePopover(); } } else { popover.classList.remove('popover--show'); popover.style.display = 'none'; // Remove click handler if (popover._clickHandler) { document.removeEventListener('click', popover._clickHandler); delete popover._clickHandler; } } // Remove from DOM if (popover.parentNode) { popover.parentNode.removeChild(popover); } this.popovers.delete(componentId); } /** * Position popover relative to anchor * @param {HTMLElement} popover - Popover element * @param {HTMLElement} anchor - Anchor element * @param {string} position - Position (top, bottom, left, right) * @param {number} offset - Offset in pixels */ positionPopover(popover, anchor, position, offset) { const anchorRect = anchor.getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let top = 0; let left = 0; switch (position) { case 'top': top = anchorRect.top - popoverRect.height - offset; left = anchorRect.left + (anchorRect.width / 2) - (popoverRect.width / 2); break; case 'bottom': top = anchorRect.bottom + offset; left = anchorRect.left + (anchorRect.width / 2) - (popoverRect.width / 2); break; case 'left': top = anchorRect.top + (anchorRect.height / 2) - (popoverRect.height / 2); left = anchorRect.left - popoverRect.width - offset; break; case 'right': top = anchorRect.top + (anchorRect.height / 2) - (popoverRect.height / 2); left = anchorRect.right + offset; break; case 'auto': // Auto-position: choose best position based on viewport const positions = ['bottom', 'top', 'right', 'left']; for (const pos of positions) { const testPos = this.calculatePosition(anchorRect, popoverRect, pos, offset); if (this.isPositionValid(testPos, popoverRect, viewportWidth, viewportHeight)) { position = pos; ({ top, left } = testPos); break; } } break; } // Ensure popover stays within viewport left = Math.max(8, Math.min(left, viewportWidth - popoverRect.width - 8)); top = Math.max(8, Math.min(top, viewportHeight - popoverRect.height - 8)); popover.style.position = 'fixed'; popover.style.top = `${top}px`; popover.style.left = `${left}px`; } /** * Calculate position for a given direction */ calculatePosition(anchorRect, popoverRect, position, offset) { let top = 0; let left = 0; switch (position) { case 'top': top = anchorRect.top - popoverRect.height - offset; left = anchorRect.left + (anchorRect.width / 2) - (popoverRect.width / 2); break; case 'bottom': top = anchorRect.bottom + offset; left = anchorRect.left + (anchorRect.width / 2) - (popoverRect.width / 2); break; case 'left': top = anchorRect.top + (anchorRect.height / 2) - (popoverRect.height / 2); left = anchorRect.left - popoverRect.width - offset; break; case 'right': top = anchorRect.top + (anchorRect.height / 2) - (popoverRect.height / 2); left = anchorRect.right + offset; break; } return { top, left }; } /** * Check if position is valid (within viewport) */ isPositionValid({ top, left }, popoverRect, viewportWidth, viewportHeight) { return top >= 0 && left >= 0 && top + popoverRect.height <= viewportHeight && left + popoverRect.width <= viewportWidth; } /** * Setup repositioning on scroll/resize */ setupRepositioning(popover, anchor, position, offset) { const reposition = () => { if (this.popovers.has(popover.id.replace('popover-', ''))) { this.positionPopover(popover, anchor, position, offset); } }; window.addEventListener('scroll', reposition, { passive: true }); window.addEventListener('resize', reposition); popover._repositionHandlers = { scroll: reposition, resize: reposition }; } /** * Setup global handlers for fallback mode */ setupGlobalHandlers() { // Global handlers are set up per-popover in showWithFallback } /** * Cleanup all popovers */ destroy() { for (const componentId of this.popovers.keys()) { this.hide(componentId); } } }