import { type Placement, type MiddlewareData } from '@floating-ui/dom'; export class PortalUtils { static calculateAvailableSpace(referenceElement: HTMLElement): { spaceAbove: number; spaceBelow: number; spaceLeft: number; spaceRight: number; viewportHeight: number; viewportWidth: number; } { const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; const spaceRight = viewportWidth - rect.right; const spaceLeft = rect.left; return { spaceAbove, spaceBelow, spaceLeft, spaceRight, viewportHeight, viewportWidth }; } static getOptimalPlacement(referenceElement: HTMLElement): Placement { const { spaceAbove, spaceBelow, spaceLeft, spaceRight } = this.calculateAvailableSpace(referenceElement); if (spaceBelow >= 200 && spaceBelow >= spaceAbove) { return 'bottom'; } else if (spaceAbove >= 200 && spaceAbove > spaceBelow) { return 'top'; } if (spaceRight >= 200 && spaceRight >= spaceLeft) { return 'right'; } else if (spaceLeft >= 200) { return 'left'; } return 'bottom'; } static findBoundaryElements(component: HTMLElement): Element[] | undefined { const boundaryElements: Element[] = []; let currentElement = component.parentElement; while (currentElement && currentElement !== document.body) { const computedStyle = window.getComputedStyle(currentElement); const overflow = computedStyle.overflow; const overflowY = computedStyle.overflowY; const overflowX = computedStyle.overflowX; if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll' || overflowX === 'auto' || overflowX === 'scroll') { boundaryElements.push(currentElement); } if (currentElement.hasAttribute('data-floating-boundary') || currentElement.classList.contains('floating-boundary') || currentElement.classList.contains('scroll-container')) { boundaryElements.push(currentElement); } currentElement = currentElement.parentElement; } return boundaryElements.length > 0 ? boundaryElements : undefined; } static calculateOptimalHeight( referenceRect: { x: number; y: number; width: number; height: number }, viewportHeight: number, placement: Placement ): number { const spaceBelow = viewportHeight - (referenceRect.y + referenceRect.height); const spaceAbove = referenceRect.y; if (placement.startsWith('top')) { return Math.max(spaceAbove - 20, 100); } else if (placement.startsWith('bottom')) { return Math.max(spaceBelow - 20, 100); } else if (placement.startsWith('left') || placement.startsWith('right')) { return Math.max(Math.min(spaceAbove, spaceBelow) - 20, 100); } return Math.max(Math.min(spaceAbove, spaceBelow) - 20, 100); } static extractStylesAsCSS(styles: any): string { if (typeof styles === 'string') { return styles; } if (Array.isArray(styles)) { return styles.map(style => this.extractStylesAsCSS(style)).join('\n'); } if (styles && typeof styles === 'object' && styles.cssText) { return styles.cssText; } return ''; } static generateStyleId(): string { return `nile-popover-styles-${Math.random().toString(36).substring(2, 11)}`; } static isPositioningOptimal( placement: Placement, referenceElement: HTMLElement ): boolean { const { spaceAbove, spaceBelow, spaceLeft, spaceRight } = this.calculateAvailableSpace(referenceElement); const isAbove = placement.startsWith('top'); const isBelow = placement.startsWith('bottom'); const isLeft = placement.startsWith('left'); const isRight = placement.startsWith('right'); if (isAbove && spaceBelow > spaceAbove) return false; if (isBelow && spaceAbove > spaceBelow) return false; if (isLeft && spaceRight > spaceLeft) return false; if (isRight && spaceLeft > spaceRight) return false; return true; } static applyCollisionData( element: HTMLElement, middlewareData: MiddlewareData, placement: Placement ): void { if (middlewareData.flip) { const { overflows } = middlewareData.flip; element.setAttribute('data-placement', placement); if (overflows && overflows.length > 0) { const overflowPlacements = overflows.map(overflow => overflow.placement).join(','); element.setAttribute('data-overflow', overflowPlacements); } else { element.removeAttribute('data-overflow'); } } if (middlewareData.shift) { const { x, y } = middlewareData.shift; if (x !== undefined && y !== undefined && (x !== 0 || y !== 0)) { element.setAttribute('data-shift', `${x},${y}`); } else { element.removeAttribute('data-shift'); } } if (middlewareData.size) { const { availableWidth, availableHeight } = middlewareData.size; if (availableWidth !== undefined) { element.setAttribute('data-available-width', availableWidth.toString()); } if (availableHeight !== undefined) { element.setAttribute('data-available-height', availableHeight.toString()); } } } } export class PortalContentUtils { static createBasePopover(component: any): HTMLElement { const popoverBox = document.createElement('div'); popoverBox.className = 'popover__box'; popoverBox.setAttribute('part', 'popover'); return popoverBox; } static addTitleSection(popoverBox: HTMLElement, component: any): void { if (!component.title) return; const titleDiv = document.createElement('div'); titleDiv.className = 'popover__title'; titleDiv.textContent = component.title; popoverBox.appendChild(titleDiv); } static addContentSection(popoverBox: HTMLElement, component: any): void { const contentDiv = document.createElement('div'); contentDiv.setAttribute('part', 'base'); contentDiv.style.display = 'contents'; // Clone all child elements that are not explicitly slotted const allChildren = Array.from(component.children); allChildren.forEach((child: Element) => { const slot = child.getAttribute('slot'); // Only clone elements that don't have slot="anchor", "title", or "action" if (!slot || (slot !== 'anchor' && slot !== 'title' && slot !== 'action')) { const clonedChild = child.cloneNode(true) as HTMLElement; contentDiv.appendChild(clonedChild); } }); popoverBox.appendChild(contentDiv); } static cloneContent(originalContent: HTMLElement, targetContent: HTMLElement): void { Array.from(originalContent.childNodes).forEach(node => { const clonedNode = node.cloneNode(true); targetContent.appendChild(clonedNode); }); Array.from(originalContent.attributes).forEach(attr => { targetContent.setAttribute(attr.name, attr.value); }); } static addActionSection(popoverBox: HTMLElement, component: any): void { const actionDiv = document.createElement('div'); actionDiv.className = 'popover__action'; const actionSlots = component.querySelectorAll('[slot="action"]'); actionSlots.forEach((slot: Element) => { const clonedSlot = slot.cloneNode(true) as HTMLElement; actionDiv.appendChild(clonedSlot); }); popoverBox.appendChild(actionDiv); } static addArrowElement(popoverBox: HTMLElement, component: any): void { const arrowDiv = document.createElement('div'); arrowDiv.className = 'popup__arrow'; arrowDiv.setAttribute('part', 'arrow'); arrowDiv.setAttribute('role', 'presentation'); popoverBox.appendChild(arrowDiv); } static createPortalPopover(component: any): HTMLElement { const popoverBox = this.createBasePopover(component); this.addTitleSection(popoverBox, component); this.addContentSection(popoverBox, component); this.addActionSection(popoverBox, component); if (component.arrow) { this.addArrowElement(popoverBox, component); } return popoverBox; } static updatePortalContent(clonedPopover: HTMLElement, component: any): void { if (!clonedPopover) return; const titleElement = clonedPopover.querySelector('.popover__title'); if (titleElement && component.title) { titleElement.textContent = component.title; } const contentElement = clonedPopover.querySelector('[part="base"]') as HTMLElement; if (contentElement) { contentElement.innerHTML = ''; const allChildren = Array.from(component.children); allChildren.forEach((child: Element) => { const slot = child.getAttribute('slot'); if (!slot || (slot !== 'anchor' && slot !== 'title' && slot !== 'action')) { const clonedChild = child.cloneNode(true) as HTMLElement; contentElement.appendChild(clonedChild); } }); } const actionElement = clonedPopover.querySelector('.popover__action') as HTMLElement; if (actionElement) { actionElement.innerHTML = ''; const actionSlots = component.querySelectorAll('[slot="action"]'); actionSlots.forEach((slot: Element) => { const clonedSlot = slot.cloneNode(true) as HTMLElement; actionElement.appendChild(clonedSlot); }); } } } export class PortalEventUtils { static setupPortalEventListeners(clonedPopover: HTMLElement, component: any): void { if (!clonedPopover) return; this.setupClickListeners(clonedPopover, component); this.setupContentListeners(clonedPopover, component); this.setupActionListeners(clonedPopover, component); } static setupClickListeners(clonedPopover: HTMLElement, component: any): void { if (!clonedPopover) return; clonedPopover.addEventListener('click', (event) => { event.stopPropagation(); }); } static setupContentListeners(clonedPopover: HTMLElement, component: any): void { if (!clonedPopover) return; const contentElement = clonedPopover.querySelector('[part="base"]'); if (contentElement) { contentElement.addEventListener('nile-click', (event) => { if (component.handleContentClick) { component.handleContentClick(event); } }); contentElement.addEventListener('nile-input', (event) => { if (component.handleContentInput) { component.handleContentInput(event); } }); contentElement.addEventListener('nile-change', (event) => { if (component.handleContentChange) { component.handleContentChange(event); } }); } } static setupActionListeners(clonedPopover: HTMLElement, component: any): void { if (!clonedPopover) return; const actionElement = clonedPopover.querySelector('.popover__action'); if (actionElement) { actionElement.addEventListener('click', (event) => { const target = event.target as HTMLElement; if (target.tagName === 'BUTTON' || target.closest('button')) { if (component.handleActionClick) { component.handleActionClick(event); } } }); actionElement.addEventListener('nile-click', (event) => { if (component.handleActionClick) { component.handleActionClick(event); } }); } } static forwardEventToOriginal(event: Event, component: any, eventType: string): void { const syntheticEvent = new CustomEvent(eventType, { bubbles: true, cancelable: true, detail: (event as CustomEvent).detail }); component.dispatchEvent(syntheticEvent); } }