import { autoUpdate, computePosition, flip, offset, shift, size, platform, type Placement, type MiddlewareData, type ComputePositionConfig, type Boundary } from '@floating-ui/dom'; import { PortalUtils, PortalContentUtils, PortalEventUtils } from './portal-utils'; export class PortalManager { private portalContainer: HTMLElement | null = null; private originalListboxParent: HTMLElement | null = null; private measuredPopupHeight: number | null = null; private component: any; private clonedListbox: HTMLElement | null = null; private cleanupAutoUpdate: (() => void) | null = null; private currentPlacement: Placement = 'bottom'; private currentMiddlewareData: MiddlewareData | null = null; constructor(component: any) { this.component = component; } private createPortalAppendContainer(): HTMLElement { const container = document.createElement('div'); container.style.position = 'absolute'; container.style.zIndex = '9999'; container.style.pointerEvents = 'none'; container.className = 'nile-select-portal-append'; return container; } positionPortalAppend(): void { if (!this.portalContainer || !this.component.popup) return; this.measurePopupHeight(); this.computeFloatingUIPosition(); } private measurePopupHeight(): void { if (this.measuredPopupHeight || !this.portalContainer) return; this.portalContainer.style.position = 'absolute'; this.portalContainer.style.visibility = 'hidden'; this.portalContainer.style.top = '0px'; this.portalContainer.style.left = '0px'; this.portalContainer.offsetHeight; this.measuredPopupHeight = this.portalContainer.offsetHeight; this.portalContainer.style.visibility = ''; } private async computeFloatingUIPosition(): Promise { if (!this.portalContainer) return; const referenceElement = this.component.combobox || this.component; const floatingElement = this.portalContainer; try { const { x, y, placement, middlewareData } = await this.calculateFloatingUIPosition( referenceElement, floatingElement ); this.applyFloatingUIPosition(floatingElement, referenceElement, x, y, placement, middlewareData); } catch (error) { console.warn('Floating UI positioning failed, falling back to simple positioning:', error); this.fallbackPositioning(); } } private async calculateFloatingUIPosition( referenceElement: HTMLElement, floatingElement: HTMLElement ): Promise<{ x: number; y: number; placement: Placement; middlewareData: MiddlewareData }> { const boundary = PortalUtils.findBoundaryElements(referenceElement); const initialPlacement = PortalUtils.getOptimalPlacement(referenceElement); const middleware = this.createFloatingUIMiddleware(boundary); return await computePosition(referenceElement, floatingElement, { placement: initialPlacement, strategy: 'fixed', middleware, platform: this.createCustomPlatform() }); } private createFloatingUIMiddleware(boundary: Element[] | undefined): ComputePositionConfig['middleware'] { return [ offset(4), size({ apply: this.handleSizeMiddleware.bind(this), padding: 10, boundary: boundary }), flip({ fallbackPlacements: ['bottom', 'top', 'bottom-start', 'top-start', 'bottom-end', 'top-end'], fallbackStrategy: 'bestFit', padding: 10, boundary: boundary }), shift({ padding: 10, crossAxis: true, boundary: boundary }) ]; } private handleSizeMiddleware({ availableWidth, availableHeight, elements, rects }: { availableWidth: number; availableHeight: number; elements: { floating: HTMLElement }; rects: { reference: { x: number; y: number; width: number; height: number } }; }): void { const maxHeight = PortalUtils.calculateOptimalHeight( rects.reference, window.innerHeight, this.currentPlacement ); elements.floating.style.maxWidth = `${availableWidth}px`; elements.floating.style.maxHeight = `${maxHeight}px`; elements.floating.style.setProperty('--auto-size-available-width', `${availableWidth}px`); elements.floating.style.setProperty('--auto-size-available-height', `${maxHeight}px`); } private createCustomPlatform() { return platform; } private applyFloatingUIPosition( floatingElement: HTMLElement, referenceElement: HTMLElement, x: number, y: number, placement: Placement, middlewareData: MiddlewareData ): void { Object.assign(floatingElement.style, { left: `${x}px`, top: `${y}px`, position: 'fixed', pointerEvents: 'auto' }); this.currentPlacement = placement; this.currentMiddlewareData = middlewareData; PortalUtils.applyCollisionData(floatingElement, middlewareData, placement); const placementClass = placement.split('-')[0]; floatingElement.className = `nile-select-portal-append select__listbox--${placementClass}`; const referenceRect = (referenceElement as HTMLElement).getBoundingClientRect(); floatingElement.style.width = `${referenceRect.width}px`; } private fallbackPositioning(): void { if (!this.portalContainer) return; const referenceElement = this.component.combobox || this.component; const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const popupHeight = this.measuredPopupHeight || 200; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; let topPosition: number; let placementClass: string; let maxHeight: number; if (spaceAbove > spaceBelow) { maxHeight = Math.max(spaceAbove - 20, 100); topPosition = Math.max(rect.top - maxHeight - 4, 10); placementClass = 'top'; } else { maxHeight = Math.max(spaceBelow - 20, 100); topPosition = rect.bottom + 4; placementClass = 'bottom'; } this.portalContainer.style.left = `${rect.left}px`; this.portalContainer.style.top = `${topPosition}px`; this.portalContainer.style.width = `${rect.width}px`; this.portalContainer.style.maxHeight = `${maxHeight}px`; this.portalContainer.style.pointerEvents = 'auto'; this.portalContainer.className = `nile-select-portal-append select__listbox--${placementClass}`; this.calculateAndSetAutoSizeProperties(rect, topPosition, placementClass); } private calculateAndSetAutoSizeProperties(rect: DOMRect, topPosition: number, placementClass: string): void { if (!this.portalContainer) return; const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; let availableHeight: number; if (placementClass === 'top') { availableHeight = rect.top - 10; } else { availableHeight = viewportHeight - rect.bottom - 10; } const availableWidth = Math.min(rect.width, viewportWidth - rect.left - 10); this.portalContainer.style.setProperty('--auto-size-available-height', `${Math.max(availableHeight, 100)}px`); this.portalContainer.style.setProperty('--auto-size-available-width', `${Math.max(availableWidth, 200)}px`); } updatePortalAppendPosition(): void { if (this.component.portal && this.portalContainer) { this.positionPortalAppend(); } } handleWindowResize(): void { if (this.component.portal && this.portalContainer) { this.positionPortalAppend(); } } private setupAutoUpdatePositioning(): void { if (!this.portalContainer || !this.component) return; this.cleanupAutoUpdatePositioning(); this.cleanupAutoUpdate = autoUpdate( this.component, this.portalContainer, () => { this.computeFloatingUIPosition(); }, { ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, animationFrame: true } ); } private cleanupAutoUpdatePositioning(): void { if (this.cleanupAutoUpdate) { this.cleanupAutoUpdate(); this.cleanupAutoUpdate = null; } } private injectStylesToDocument(): void { if (!this.portalContainer) return; const styleId = PortalUtils.generateStyleId(); if (document.getElementById(styleId)) return; const componentStyles = (this.component.constructor as any).styles; if (!componentStyles) return; const styleElement = document.createElement('style'); styleElement.id = styleId; styleElement.textContent = PortalUtils.extractStylesAsCSS(componentStyles); document.head.appendChild(styleElement); (this.portalContainer as any).__injectedStyleId = styleId; } private adoptStylesToPortalAppend(): void { if (!this.portalContainer) return; this.injectStylesToDocument(); } setupPortalAppend(): void { if (!this.component.portal) return; this.component.updateComplete.then(() => { setTimeout(() => { const listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement; if (listbox) { this.originalListboxParent = listbox.parentElement as HTMLElement; this.clonedListbox = this.createPortalListbox(); this.portalContainer = this.createPortalAppendContainer(); this.portalContainer.appendChild(this.clonedListbox); document.body.appendChild(this.portalContainer); this.adoptStylesToPortalAppend(); this.clonedListbox.style.display = ''; this.positionPortalAppend(); this.setupPortalEventListeners(); this.updatePortalContent(); setTimeout(() => { this.updateFooterState(); }, 50); this.setupAutoUpdatePositioning(); window.addEventListener('resize', this.handleWindowResize.bind(this)); } }, 10); }); } private createPortalListbox(): HTMLElement { return PortalContentUtils.createPortalListbox(this.component); } private setupPortalEventListeners(): void { PortalEventUtils.setupPortalEventListeners(this.clonedListbox!, this.component); } private updatePortalContent(): void { if (!this.clonedListbox) return; PortalContentUtils.updateClonedOptions(this.clonedListbox, this.component); this.updateFooterState(); } private updateFooterState(): void { const footer = this.clonedListbox?.querySelector('.select__footer'); if (!footer || !this.component.multiple) return; const checkbox = footer.querySelector('nile-checkbox') as any; if (checkbox) { checkbox.disabled = this.component.selectedOptions.length === 0; checkbox.checked = this.component.showSelected; } const clearAllSpan = footer.querySelector('.select__clear') as HTMLElement; if (this.component.selectedOptions.length === 0) { if (clearAllSpan) { clearAllSpan.remove(); } } else { if (!clearAllSpan) { const newClearAllSpan = document.createElement('span'); newClearAllSpan.className = 'select__clear'; newClearAllSpan.textContent = 'Clear All'; newClearAllSpan.style.cursor = 'pointer'; footer.appendChild(newClearAllSpan); } } } cleanupPortalAppend(): void { this.cleanupAutoUpdatePositioning(); if (this.portalContainer && this.portalContainer.parentNode) { const injectedStyleId = (this.portalContainer as any).__injectedStyleId; if (injectedStyleId) { const styleElement = document.getElementById(injectedStyleId); if (styleElement) { styleElement.remove(); } } this.portalContainer.parentNode.removeChild(this.portalContainer); } window.removeEventListener('resize', this.handleWindowResize.bind(this)); this.portalContainer = null; this.originalListboxParent = null; this.clonedListbox = null; this.measuredPopupHeight = null; this.currentPlacement = 'bottom'; this.currentMiddlewareData = null; } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } resetMeasuredHeight(): void { this.measuredPopupHeight = null; } updatePortalOptions(): void { if (this.portalContainer && this.clonedListbox) { this.updatePortalContent(); this.forceReposition(); } } forceReposition(): void { if (this.portalContainer) { this.computeFloatingUIPosition(); } } getCurrentPlacement(): Placement { return this.currentPlacement; } getCurrentMiddlewareData(): MiddlewareData | null { return this.currentMiddlewareData; } isUsingFloatingUI(): boolean { return this.cleanupAutoUpdate !== null; } updatePositioningConfig(config: { offset?: number; padding?: number; boundary?: Element[] | 'viewport'; fallbackPlacements?: Placement[]; }): void { this.forceReposition(); } handleViewportChange(): void { if (this.portalContainer) { this.resetMeasuredHeight(); this.forceReposition(); } } isPositioningOptimal(): boolean { if (!this.portalContainer || !this.currentMiddlewareData) return true; const referenceElement = this.component.combobox || this.component; const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const spaceBelow = viewportHeight - rect.bottom; const spaceAbove = rect.top; const isAbove = this.currentPlacement.startsWith('top'); const isBelow = this.currentPlacement.startsWith('bottom'); if (isAbove && spaceBelow > spaceAbove) return false; if (isBelow && spaceAbove > spaceBelow) return false; return true; } }