/** * Copyright Aquera Inc 2025 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import { autoUpdate, computePosition, flip, offset, shift, size, platform, type Placement, type MiddlewareData, type ComputePositionConfig, type Boundary } from '@floating-ui/dom'; import { PortalUtils } from '../nile-select/portal-utils'; export class PortalManager { private portalContainer: HTMLElement | null = null; private originalListboxParent: HTMLElement | null = null; private measuredPopupHeight: number | null = null; private component: any; 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-virtual-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; // Temporarily position the container to measure its height this.portalContainer.style.position = 'absolute'; this.portalContainer.style.visibility = 'hidden'; this.portalContainer.style.top = '0px'; this.portalContainer.style.left = '0px'; // Force a reflow to ensure the container is rendered this.portalContainer.offsetHeight; // Measure the actual height this.measuredPopupHeight = this.portalContainer.offsetHeight; // Reset visibility 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-virtual-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-virtual-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 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 ''; } private injectStylesToDocument(): void { if (!this.portalContainer) return; // Create a unique identifier for this component instance const styleId = `nile-virtual-select-styles-${Math.random().toString(36).substring(2, 11)}`; // Check if styles are already injected if (document.getElementById(styleId)) return; // Get the component's styles const componentStyles = (this.component.constructor as any).styles; if (!componentStyles) return; // Create a style element const styleElement = document.createElement('style'); styleElement.id = styleId; styleElement.textContent = this.extractStylesAsCSS(componentStyles); // Inject into document head document.head.appendChild(styleElement); // Store reference for cleanup (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(() => { const listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement; if (listbox) { this.originalListboxParent = listbox.parentElement as HTMLElement; this.portalContainer = this.createPortalAppendContainer(); this.portalContainer.appendChild(listbox); document.body.appendChild(this.portalContainer); // Adopt the component's styles to the body append container this.adoptStylesToPortalAppend(); listbox.style.display = ''; this.positionPortalAppend(); this.setupAutoUpdatePositioning(); window.addEventListener('resize', this.handleWindowResize.bind(this)); } }); } cleanupPortalAppend(): void { this.cleanupAutoUpdatePositioning(); if (this.portalContainer && this.portalContainer.parentNode) { const listbox = this.portalContainer.querySelector('#listbox') as HTMLElement; if (listbox && this.originalListboxParent) { this.originalListboxParent.appendChild(listbox); listbox.style.display = this.component.portal ? 'none' : ''; } // Clean up injected styles if they exist 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.measuredPopupHeight = null; this.currentPlacement = 'bottom'; this.currentMiddlewareData = null; } async resetScrollPosition(): Promise { await this.component.updateComplete; requestAnimationFrame(() => { let listbox: HTMLElement | null = null; if (this.component.portal && this.portalContainer) { listbox = this.portalContainer.querySelector('#listbox') as HTMLElement; } else { listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement; } if (!listbox || !listbox.isConnected) return; listbox.scrollTop = 0; const virtualized = listbox.querySelector('.virtualized') as HTMLElement; if (virtualized && virtualized.isConnected) { const fewItems = this.component.data?.length < 5; if (fewItems) { virtualized.style.overflowY = 'hidden'; virtualized.style.maxHeight = 'none'; listbox.style.overflowY = 'hidden'; listbox.style.maxHeight = 'fit-content'; } else { virtualized.style.overflowY = 'auto'; virtualized.style.maxHeight = ''; listbox.style.overflowY = 'auto'; listbox.style.maxHeight = ''; } virtualized.scrollTop = 0; } const virtualizer = listbox.querySelector('lit-virtualizer') as HTMLElement; if (virtualizer && virtualizer.isConnected) { virtualizer.scrollTop = 0; } }); } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } resetMeasuredHeight(): void { this.measuredPopupHeight = null; } forceReposition(): void { if (this.portalContainer) { this.computeFloatingUIPosition(); } } getCurrentPlacement(): Placement { return this.currentPlacement; } getCurrentMiddlewareData(): MiddlewareData | null { return this.currentMiddlewareData; } isUsingFloatingUI(): boolean { return this.cleanupAutoUpdate !== null; } 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; } }