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-dropdown-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 createPortalPanel(component: any): HTMLElement { // Find the panel slot content (typically a nile-menu) const panelSlot = component.shadowRoot?.querySelector('.dropdown__panel') as HTMLSlotElement; if (!panelSlot) { // Fallback: try to find in light DOM const slot = component.querySelector('[slot]'); if (!slot || slot.getAttribute('slot') === 'trigger') { return document.createElement('div'); } } // Get assigned elements from the slot let assignedElements: Element[] = panelSlot?.assignedElements({ flatten: true }) as Element[] || []; // If no assigned elements, try direct children that aren't in trigger slot if (assignedElements.length === 0) { const children = Array.from(component.children) as Element[]; assignedElements = children.filter(child => { const slot = child.getAttribute('slot'); return !slot || slot !== 'trigger'; }); } // Create a container for the cloned panel const panelContainer = document.createElement('div'); panelContainer.className = 'dropdown__panel'; panelContainer.setAttribute('part', 'panel'); panelContainer.setAttribute('aria-hidden', 'false'); panelContainer.setAttribute('aria-labelledby', 'dropdown'); // Clone all assigned elements assignedElements.forEach((element: Element) => { const clonedElement = element.cloneNode(true) as HTMLElement; panelContainer.appendChild(clonedElement); }); return panelContainer; } static updatePortalPanel(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Clear existing content clonedPanel.innerHTML = ''; // Re-clone the panel content const panelSlot = component.shadowRoot?.querySelector('.dropdown__panel') as HTMLSlotElement; let assignedElements: Element[] = panelSlot?.assignedElements({ flatten: true }) as Element[] || []; if (assignedElements.length === 0) { const children = Array.from(component.children) as Element[]; assignedElements = children.filter(child => { const slot = child.getAttribute('slot'); return !slot || slot !== 'trigger'; }); } assignedElements.forEach((element: Element) => { const clonedElement = element.cloneNode(true) as HTMLElement; clonedPanel.appendChild(clonedElement); }); } } export class PortalEventUtils { static setupPortalEventListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; this.setupMenuSelectListeners(clonedPanel, component); this.setupKeyDownListeners(clonedPanel, component); } static setupMenuSelectListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Listen for nile-select events from menu items clonedPanel.addEventListener('nile-select', (event: Event) => { const customEvent = event as CustomEvent; // Forward the event to the original component if (component.handlePanelSelect) { component.handlePanelSelect(customEvent); } }); } static setupKeyDownListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Listen for keydown events (like Escape) clonedPanel.addEventListener('keydown', (event: KeyboardEvent) => { if (component.handleKeyDown) { component.handleKeyDown(event); } }); } }