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 DropdownPortalManager { private portalContainer: HTMLElement | null = null; private measuredPanelHeight: number | null = null; private component: any; private clonedPanel: HTMLElement | null = null; private cleanupAutoUpdate: (() => void) | null = null; private currentPlacement: Placement = 'bottom-start'; private currentMiddlewareData: MiddlewareData | null = null; private boundHandleWindowResize: (() => void) | 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.maxWidth = 'none'; container.style.minWidth = 'auto'; container.className = 'nile-dropdown-portal-append'; return container; } positionPortalAppend(): void { if (!this.portalContainer || !this.component.open) return; this.measurePanelHeight(); this.computeFloatingUIPosition(); } private measurePanelHeight(): void { if (this.measuredPanelHeight || !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.measuredPanelHeight = this.portalContainer.offsetHeight; this.portalContainer.style.visibility = ''; } private async computeFloatingUIPosition(): Promise { if (!this.portalContainer) return; const referenceElement = this.component.shadowRoot?.querySelector('[slot="anchor"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component.querySelector('[slot="trigger"]')?.assignedElements({ flatten: true })[0] as HTMLElement || 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 = this.getInitialPlacement(); const middleware = this.createFloatingUIMiddleware(boundary); return await computePosition(referenceElement, floatingElement, { placement: initialPlacement, strategy: 'fixed', middleware, platform: this.createCustomPlatform() }); } private getInitialPlacement(): Placement { return this.component.placement || 'bottom-start'; } private createFloatingUIMiddleware(boundary: Element[] | undefined): ComputePositionConfig['middleware'] { const middleware: ComputePositionConfig['middleware'] = [ offset(this.component.distance || 0), ]; // Add size middleware if sync is specified if (this.component.sync) { middleware.push( size({ apply: this.handleSizeMiddleware.bind(this), padding: 10, boundary: boundary }) ); } // Add flip middleware middleware.push( flip({ fallbackPlacements: this.getFallbackPlacements(), fallbackStrategy: 'bestFit', padding: 10, boundary: boundary }) ); // Add shift middleware middleware.push( shift({ padding: 10, crossAxis: true, boundary: boundary }) ); return middleware; } 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 ); if (this.component.sync === 'width' || this.component.sync === 'both') { elements.floating.style.width = `${rects.reference.width}px`; } if (this.component.sync === 'height' || this.component.sync === 'both') { elements.floating.style.height = `${rects.reference.height}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 getFallbackPlacements(): Placement[] { const basePlacement = this.component.placement || 'bottom-start'; const placements: Placement[] = []; if (basePlacement.startsWith('top')) { placements.push('bottom-start', 'bottom', 'bottom-end', 'top', 'top-end', 'right-start', 'left-start'); } else if (basePlacement.startsWith('bottom')) { placements.push('top-start', 'top', 'top-end', 'bottom', 'bottom-end', 'right-start', 'left-start'); } else if (basePlacement.startsWith('left')) { placements.push('right-start', 'right', 'right-end', 'left', 'left-end', 'top-start', 'bottom-start'); } else if (basePlacement.startsWith('right')) { placements.push('left-start', 'left', 'left-end', 'right', 'right-end', 'top-start', 'bottom-start'); } return placements; } private createCustomPlatform() { return platform; } private applyFloatingUIPosition( floatingElement: HTMLElement, referenceElement: HTMLElement, x: number, y: number, placement: Placement, middlewareData: MiddlewareData ): void { const referenceRect = referenceElement.getBoundingClientRect(); Object.assign(floatingElement.style, { left: `${x}px`, top: `${y}px`, position: 'fixed', pointerEvents: 'auto', width: 'auto', minWidth: 'auto' }); // Apply sync width if needed if (this.component.sync === 'width' || this.component.sync === 'both') { floatingElement.style.width = `${referenceRect.width}px`; } this.currentPlacement = placement; this.currentMiddlewareData = middlewareData; PortalUtils.applyCollisionData(floatingElement, middlewareData, placement); const placementClass = placement.split('-')[0]; floatingElement.className = `nile-dropdown-portal-append dropdown--open dropdown__panel--${placementClass}`; } private fallbackPositioning(): void { if (!this.portalContainer) return; const referenceElement = this.component.shadowRoot?.querySelector('[slot="anchor"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component.querySelector('[slot="trigger"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component; 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; let topPosition: number; let leftPosition: number = rect.left; let placementClass: string; let maxHeight: number; const placement = this.component.placement || 'bottom-start'; if (placement.startsWith('bottom')) { maxHeight = Math.max(spaceBelow - 20, 100); topPosition = rect.bottom + (this.component.distance || 0); placementClass = 'bottom'; } else if (placement.startsWith('top')) { maxHeight = Math.max(spaceAbove - 20, 100); topPosition = Math.max(rect.top - maxHeight - (this.component.distance || 0), 10); placementClass = 'top'; } else if (placement.startsWith('right')) { maxHeight = Math.max(Math.min(spaceRight, spaceBelow, spaceAbove) - 20, 100); topPosition = rect.top; leftPosition = rect.right + (this.component.distance || 0); placementClass = 'right'; } else { maxHeight = Math.max(Math.min(spaceLeft, spaceBelow, spaceAbove) - 20, 100); topPosition = rect.top; leftPosition = Math.max(rect.left - 200 - (this.component.distance || 0), 10); placementClass = 'left'; } this.portalContainer.style.left = `${leftPosition}px`; this.portalContainer.style.top = `${topPosition}px`; this.portalContainer.style.maxHeight = `${maxHeight}px`; this.portalContainer.style.pointerEvents = 'auto'; this.portalContainer.style.width = 'auto'; this.portalContainer.style.minWidth = 'auto'; if (this.component.sync === 'width' || this.component.sync === 'both') { this.portalContainer.style.width = `${rect.width}px`; } this.portalContainer.className = `nile-dropdown-portal-append dropdown__panel--${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; let availableWidth: number = viewportWidth - rect.left - 10; if (placementClass === 'top') { availableHeight = rect.top - 10; } else if (placementClass === 'bottom') { availableHeight = viewportHeight - rect.bottom - 10; } else if (placementClass === 'left') { availableHeight = Math.min(viewportHeight - rect.top, rect.bottom); availableWidth = rect.left - 10; } else { availableHeight = Math.min(viewportHeight - rect.top, rect.bottom); availableWidth = viewportWidth - rect.right - 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(); const referenceElement = this.component.shadowRoot?.querySelector('[slot="anchor"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component.querySelector('[slot="trigger"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component; this.cleanupAutoUpdate = autoUpdate( referenceElement, 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(() => { if (this.component.open) { this.clonedPanel = this.createPortalPanel(); this.portalContainer = this.createPortalAppendContainer(); this.portalContainer.appendChild(this.clonedPanel); document.body.appendChild(this.portalContainer); this.adoptStylesToPortalAppend(); this.clonedPanel.style.display = ''; this.positionPortalAppend(); this.setupPortalEventListeners(); this.setupAutoUpdatePositioning(); this.boundHandleWindowResize = this.handleWindowResize.bind(this); window.addEventListener('resize', this.boundHandleWindowResize); } }, 10); }); } private createPortalPanel(): HTMLElement { return PortalContentUtils.createPortalPanel(this.component); } private setupPortalEventListeners(): void { PortalEventUtils.setupPortalEventListeners(this.clonedPanel!, 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); } if (this.boundHandleWindowResize) { window.removeEventListener('resize', this.boundHandleWindowResize); this.boundHandleWindowResize = null; } this.portalContainer = null; this.clonedPanel = null; this.measuredPanelHeight = null; this.currentPlacement = 'bottom-start'; this.currentMiddlewareData = null; } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } resetMeasuredHeight(): void { this.measuredPanelHeight = 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; } isPositioningOptimal(): boolean { if (!this.portalContainer || !this.currentMiddlewareData) return true; const referenceElement = this.component.shadowRoot?.querySelector('[slot="anchor"]')?.assignedElements({ flatten: true })[0] as HTMLElement || this.component.querySelector('[slot="trigger"]')?.assignedElements({ flatten: true })[0] as HTMLElement || 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; } updatePortalPanel(): void { if (this.portalContainer && this.clonedPanel) { PortalContentUtils.updatePortalPanel(this.clonedPanel, this.component); this.setupPortalEventListeners(); this.forceReposition(); } } }