/** * 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, } from '@floating-ui/dom'; import { PortalUtils } from '../nile-select/portal-utils'; export class ComboboxPortalManager { 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'; constructor(component: any) { this.component = component; } get portalContainerElement(): HTMLElement | null { return this.portalContainer; } private createPortalContainer(): HTMLElement { const container = document.createElement('div'); container.style.position = 'absolute'; container.style.zIndex = '9999'; container.style.pointerEvents = 'none'; container.className = 'nile-combobox-portal'; return container; } private async computePosition(): Promise { if (!this.portalContainer) return; const referenceElement = this.component.combobox || this.component; const floatingElement = this.portalContainer; try { const boundary = PortalUtils.findBoundaryElements(referenceElement); const initialPlacement = PortalUtils.getOptimalPlacement(referenceElement); const { x, y, placement } = await computePosition( referenceElement, floatingElement, { placement: initialPlacement, strategy: 'fixed', middleware: [ offset(4), size({ apply: ({ availableWidth, availableHeight, elements, rects }) => { 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`); }, padding: 10, boundary, }), flip({ fallbackPlacements: ['bottom', 'top', 'bottom-start', 'top-start'], fallbackStrategy: 'bestFit', padding: 10, boundary, }), shift({ padding: 10, crossAxis: true, boundary }), ], platform, }, ); const referenceRect = referenceElement.getBoundingClientRect(); Object.assign(floatingElement.style, { left: `${x}px`, top: `${y}px`, position: 'fixed', pointerEvents: 'auto', width: `${referenceRect.width}px`, }); this.currentPlacement = placement; const placementClass = placement.split('-')[0]; floatingElement.className = `nile-combobox-portal combobox__listbox--${placementClass}`; } catch { this.fallbackPositioning(); } } private fallbackPositioning(): void { if (!this.portalContainer) return; const ref = this.component.combobox || this.component; const rect = ref.getBoundingClientRect(); const vh = window.innerHeight; const spaceBelow = vh - rect.bottom; const spaceAbove = rect.top; let top: number; let maxH: number; let cls: string; if (spaceAbove > spaceBelow) { maxH = Math.max(spaceAbove - 20, 100); top = Math.max(rect.top - maxH - 4, 10); cls = 'top'; } else { maxH = Math.max(spaceBelow - 20, 100); top = rect.bottom + 4; cls = 'bottom'; } Object.assign(this.portalContainer.style, { left: `${rect.left}px`, top: `${top}px`, width: `${rect.width}px`, maxHeight: `${maxH}px`, pointerEvents: 'auto', }); this.portalContainer.className = `nile-combobox-portal combobox__listbox--${cls}`; this.portalContainer.style.setProperty('--auto-size-available-height', `${maxH}px`); this.portalContainer.style.setProperty('--auto-size-available-width', `${rect.width}px`); } private extractStylesAsCSS(styles: any): string { if (typeof styles === 'string') return styles; if (Array.isArray(styles)) return styles.map(s => this.extractStylesAsCSS(s)).join('\n'); if (styles && typeof styles === 'object' && styles.cssText) return styles.cssText; return ''; } private injectStyles(): void { if (!this.portalContainer) return; const styleId = `nile-combobox-styles-${Math.random().toString(36).substring(2, 11)}`; if (document.getElementById(styleId)) return; const componentStyles = (this.component.constructor as any).styles; if (!componentStyles) return; const el = document.createElement('style'); el.id = styleId; el.textContent = this.extractStylesAsCSS(componentStyles); document.head.appendChild(el); (this.portalContainer as any).__injectedStyleId = styleId; } setupPortal(): void { if (!this.component.portal) return; this.component.updateComplete.then(() => { const listbox = this.component.shadowRoot?.querySelector('#listbox') as HTMLElement; if (!listbox) return; this.originalListboxParent = listbox.parentElement as HTMLElement; this.portalContainer = this.createPortalContainer(); this.portalContainer.appendChild(listbox); document.body.appendChild(this.portalContainer); this.injectStyles(); listbox.style.display = ''; this.computePosition(); this.cleanupAutoUpdate = autoUpdate( this.component, this.portalContainer, () => this.computePosition(), { ancestorScroll: true, ancestorResize: true, elementResize: true, layoutShift: true, animationFrame: true }, ); }); } cleanupPortal(): void { if (this.cleanupAutoUpdate) { this.cleanupAutoUpdate(); this.cleanupAutoUpdate = null; } 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' : ''; } const sid = (this.portalContainer as any).__injectedStyleId; if (sid) document.getElementById(sid)?.remove(); this.portalContainer.parentNode.removeChild(this.portalContainer); } this.portalContainer = null; this.originalListboxParent = null; this.measuredPopupHeight = null; this.currentPlacement = 'bottom'; } updatePosition(): void { if (this.component.portal && this.portalContainer) { this.computePosition(); } } resetMeasuredHeight(): void { this.measuredPopupHeight = 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.filteredData?.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; } }); } }