Some checks failed
Deploy Application / deploy (push) Has been cancelled
347 lines
11 KiB
JavaScript
347 lines
11 KiB
JavaScript
/**
|
|
* 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 ? `<div class="popover-arrow popover-arrow--${position}"></div>` : ''}
|
|
${title ? `<div class="popover-header"><h4>${title}</h4></div>` : ''}
|
|
<div class="popover-body">${content}</div>
|
|
`;
|
|
|
|
// 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 ? `<div class="popover-arrow popover-arrow--${position}"></div>` : ''}
|
|
${title ? `<div class="popover-header"><h4>${title}</h4></div>` : ''}
|
|
<div class="popover-body">${content}</div>
|
|
`;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|