import { autoUpdate, computePosition, flip, offset, shift, size, platform, type Placement, type MiddlewareData, type ComputePositionConfig } from '@floating-ui/dom'; import { PortalUtils, PortalContentUtils, PortalEventUtils } from './portal-utils'; export class AutoCompletePortalManager { private portalContainer: HTMLElement | null = null; private originalMenuParent: HTMLElement | null = null; private measuredMenuHeight: number | null = null; private component: any; private clonedMenu: 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.style.width = 'auto'; container.style.minWidth = 'auto'; container.className = 'nile-auto-complete-portal-append'; return container; } positionPortalAppend(): void { if (!this.portalContainer || !this.component.dropdownElement) return; this.measureMenuHeight(); this.computeFloatingUIPosition(); } private measureMenuHeight(): void { if (this.measuredMenuHeight || !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.measuredMenuHeight = this.portalContainer.offsetHeight; this.portalContainer.style.visibility = ''; } private async computeFloatingUIPosition(): Promise { if (!this.portalContainer) return; const referenceElement = this.component.inputElement || 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); // Use 'bottom-start' or 'top-start' to align left edges for auto-width menu const basePlacement = PortalUtils.getOptimalPlacement(referenceElement); const initialPlacement = basePlacement === 'top' ? 'top-start' : 'bottom-start'; 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-start', 'top-start', 'bottom', 'top', '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 { // For auto-complete, align left edge with input element // Use reference element's left position, not Floating UI's x (which might shift) const referenceRect = referenceElement.getBoundingClientRect(); Object.assign(floatingElement.style, { left: `${referenceRect.left}px`, top: `${y}px`, position: 'fixed', pointerEvents: 'auto', width: 'auto', minWidth: 'auto' }); this.currentPlacement = placement; this.currentMiddlewareData = middlewareData; PortalUtils.applyCollisionData(floatingElement, middlewareData, placement); const placementClass = placement.split('-')[0]; floatingElement.className = `nile-auto-complete-portal-append menu__listbox--${placementClass}`; } private fallbackPositioning(): void { if (!this.portalContainer) return; const referenceElement = this.component.inputElement || this.component; const rect = referenceElement.getBoundingClientRect(); const viewportHeight = window.innerHeight; const menuHeight = this.measuredMenuHeight || 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`; // Let the menu auto-size based on its content, don't force width to match input this.portalContainer.style.width = 'auto'; this.portalContainer.style.minWidth = 'auto'; this.portalContainer.style.maxHeight = `${maxHeight}px`; this.portalContainer.style.pointerEvents = 'auto'; this.portalContainer.className = `nile-auto-complete-portal-append menu__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(() => { // Try to find menu in shadow root first, then fallback to querySelector const menu = this.component.shadowRoot?.querySelector('#content-menu') as HTMLElement || this.component.querySelector('#content-menu') as HTMLElement; if (menu && this.component.isDropdownOpen) { this.originalMenuParent = menu.parentElement as HTMLElement; this.clonedMenu = this.createPortalMenu(); this.portalContainer = this.createPortalAppendContainer(); this.portalContainer.appendChild(this.clonedMenu); document.body.appendChild(this.portalContainer); this.adoptStylesToPortalAppend(); this.clonedMenu.style.display = ''; this.positionPortalAppend(); this.setupPortalEventListeners(); this.setupAutoUpdatePositioning(); window.addEventListener('resize', this.handleWindowResize.bind(this)); } }, 10); }); } private createPortalMenu(): HTMLElement { return PortalContentUtils.createPortalMenu(this.component); } private setupPortalEventListeners(): void { PortalEventUtils.setupPortalEventListeners(this.clonedMenu!, this.component); } 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.originalMenuParent = null; this.clonedMenu = null; this.measuredMenuHeight = null; this.currentPlacement = 'bottom'; this.currentMiddlewareData = null; } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } resetMeasuredHeight(): void { this.measuredMenuHeight = null; } updatePortalOptions(): void { if (this.portalContainer && this.clonedMenu) { PortalContentUtils.updatePortalMenuItems(this.clonedMenu, this.component); 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; } isPositioningOptimal(): boolean { if (!this.portalContainer || !this.currentMiddlewareData) return true; const referenceElement = this.component.inputElement || 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; } }