import { type Placement, type MiddlewareData } from '@floating-ui/dom'; export class PortalUtils { static calculateAvailableSpace(referenceElement: HTMLElement): { spaceAbove: number; spaceBelow: number; spaceLeft: number; spaceRight: number; viewportHeight: number; viewportWidth: number; } { 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; return { spaceAbove, spaceBelow, spaceLeft, spaceRight, viewportHeight, viewportWidth }; } static getOptimalPlacement(referenceElement: HTMLElement): Placement { const { spaceAbove, spaceBelow, spaceLeft, spaceRight } = this.calculateAvailableSpace(referenceElement); if (spaceBelow >= 200 && spaceBelow >= spaceAbove) { return 'bottom'; } else if (spaceAbove >= 200 && spaceAbove > spaceBelow) { return 'top'; } if (spaceRight >= 200 && spaceRight >= spaceLeft) { return 'right'; } else if (spaceLeft >= 200) { return 'left'; } return 'bottom'; } static findBoundaryElements(component: HTMLElement): Element[] | undefined { const boundaryElements: Element[] = []; let currentElement = component.parentElement; while (currentElement && currentElement !== document.body) { const computedStyle = window.getComputedStyle(currentElement); const overflow = computedStyle.overflow; const overflowY = computedStyle.overflowY; const overflowX = computedStyle.overflowX; if (overflow === 'auto' || overflow === 'scroll' || overflowY === 'auto' || overflowY === 'scroll' || overflowX === 'auto' || overflowX === 'scroll') { boundaryElements.push(currentElement); } if (currentElement.hasAttribute('data-floating-boundary') || currentElement.classList.contains('floating-boundary') || currentElement.classList.contains('scroll-container')) { boundaryElements.push(currentElement); } currentElement = currentElement.parentElement; } return boundaryElements.length > 0 ? boundaryElements : undefined; } static calculateOptimalHeight( referenceRect: { x: number; y: number; width: number; height: number }, viewportHeight: number, placement: Placement ): number { const spaceBelow = viewportHeight - (referenceRect.y + referenceRect.height); const spaceAbove = referenceRect.y; if (placement.startsWith('top')) { return Math.max(spaceAbove - 20, 100); } else if (placement.startsWith('bottom')) { return Math.max(spaceBelow - 20, 100); } else if (placement.startsWith('left') || placement.startsWith('right')) { return Math.max(Math.min(spaceAbove, spaceBelow) - 20, 100); } return Math.max(Math.min(spaceAbove, spaceBelow) - 20, 100); } static 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 ''; } static generateStyleId(): string { return `nile-dropdown-styles-${Math.random().toString(36).substring(2, 11)}`; } static isPositioningOptimal( placement: Placement, referenceElement: HTMLElement ): boolean { const { spaceAbove, spaceBelow, spaceLeft, spaceRight } = this.calculateAvailableSpace(referenceElement); const isAbove = placement.startsWith('top'); const isBelow = placement.startsWith('bottom'); const isLeft = placement.startsWith('left'); const isRight = placement.startsWith('right'); if (isAbove && spaceBelow > spaceAbove) return false; if (isBelow && spaceAbove > spaceBelow) return false; if (isLeft && spaceRight > spaceLeft) return false; if (isRight && spaceLeft > spaceRight) return false; return true; } static applyCollisionData( element: HTMLElement, middlewareData: MiddlewareData, placement: Placement ): void { if (middlewareData.flip) { const { overflows } = middlewareData.flip; element.setAttribute('data-placement', placement); if (overflows && overflows.length > 0) { const overflowPlacements = overflows.map(overflow => overflow.placement).join(','); element.setAttribute('data-overflow', overflowPlacements); } else { element.removeAttribute('data-overflow'); } } if (middlewareData.shift) { const { x, y } = middlewareData.shift; if (x !== undefined && y !== undefined && (x !== 0 || y !== 0)) { element.setAttribute('data-shift', `${x},${y}`); } else { element.removeAttribute('data-shift'); } } if (middlewareData.size) { const { availableWidth, availableHeight } = middlewareData.size; if (availableWidth !== undefined) { element.setAttribute('data-available-width', availableWidth.toString()); } if (availableHeight !== undefined) { element.setAttribute('data-available-height', availableHeight.toString()); } } } } export class PortalContentUtils { static createPortalPanel(component: any): HTMLElement { // Find the panel slot content (typically a nile-menu) const panelSlot = component.shadowRoot?.querySelector('.dropdown__panel') as HTMLSlotElement; if (!panelSlot) { // Fallback: try to find in light DOM const slot = component.querySelector('[slot]'); if (!slot || slot.getAttribute('slot') === 'trigger') { return document.createElement('div'); } } // Get assigned elements from the slot let assignedElements: Element[] = panelSlot?.assignedElements({ flatten: true }) as Element[] || []; // If no assigned elements, try direct children that aren't in trigger slot if (assignedElements.length === 0) { const children = Array.from(component.children) as Element[]; assignedElements = children.filter(child => { const slot = child.getAttribute('slot'); return !slot || slot !== 'trigger'; }); } // Create a container for the cloned panel const panelContainer = document.createElement('div'); panelContainer.className = 'dropdown__panel'; panelContainer.setAttribute('part', 'panel'); panelContainer.setAttribute('aria-hidden', 'false'); panelContainer.setAttribute('aria-labelledby', 'dropdown'); // Clone all assigned elements assignedElements.forEach((element: Element) => { const clonedElement = element.cloneNode(true) as HTMLElement; this.processInlineEventHandlers(clonedElement); panelContainer.appendChild(clonedElement); }); return panelContainer; } static updatePortalPanel(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Clear existing content clonedPanel.innerHTML = ''; // Re-clone the panel content const panelSlot = component.shadowRoot?.querySelector('.dropdown__panel') as HTMLSlotElement; let assignedElements: Element[] = panelSlot?.assignedElements({ flatten: true }) as Element[] || []; if (assignedElements.length === 0) { const children = Array.from(component.children) as Element[]; assignedElements = children.filter(child => { const slot = child.getAttribute('slot'); return !slot || slot !== 'trigger'; }); } assignedElements.forEach((element: Element) => { const clonedElement = element.cloneNode(true) as HTMLElement; this.processInlineEventHandlers(clonedElement); clonedPanel.appendChild(clonedElement); }); } static processInlineEventHandlers(element: HTMLElement): void { const eventHandlerAttrs = ['onclick', 'onchange', 'oninput', 'onsubmit', 'onfocus', 'onblur', 'onkeydown', 'onkeyup']; eventHandlerAttrs.forEach(attr => { element.removeAttribute(attr); element.querySelectorAll(`[${attr}]`).forEach(child => { child.removeAttribute(attr); }); }); } } export class PortalEventUtils { static setupPortalEventListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; this.setupMenuSelectListeners(clonedPanel, component); this.setupKeyDownListeners(clonedPanel, component); this.setupEventForwarding(clonedPanel, component); } static setupMenuSelectListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Listen for nile-select events from menu items clonedPanel.addEventListener('nile-select', (event: Event) => { const customEvent = event as CustomEvent; // Forward the event to the original component if (component.handlePanelSelect) { component.handlePanelSelect(customEvent); } }); } static setupKeyDownListeners(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; // Listen for keydown events (like Escape) clonedPanel.addEventListener('keydown', (event: KeyboardEvent) => { if (component.handleKeyDown) { component.handleKeyDown(event); } }); } static setupEventForwarding(clonedPanel: HTMLElement, component: any): void { if (!clonedPanel) return; const panelSlot = component.shadowRoot?.querySelector('.dropdown__panel') as HTMLSlotElement; if (!panelSlot) return; let assignedElements: Element[] = panelSlot.assignedElements({ flatten: true }) as Element[] || []; if (assignedElements.length === 0) { const children = Array.from(component.children) as Element[]; assignedElements = children.filter(child => { const slot = child.getAttribute('slot'); return !slot || slot !== 'trigger'; }); } const originalMenu = assignedElements[0]; if (!originalMenu) return; const findOriginalElement = (clonedElement: HTMLElement): HTMLElement | null => { const clonedMenuItem = clonedElement.closest('nile-menu-item'); if (clonedMenuItem) { const clonedItems = Array.from(clonedPanel.querySelectorAll('nile-menu-item')); const originalItems = Array.from(originalMenu.querySelectorAll('nile-menu-item')); const index = clonedItems.indexOf(clonedMenuItem); const originalMenuItem = originalItems[index]; if (originalMenuItem) { if (clonedElement === clonedMenuItem) { return originalMenuItem; } const clonedTagName = clonedElement.tagName.toLowerCase(); const clonedElements = Array.from(clonedMenuItem.querySelectorAll(clonedTagName)); const originalElements = Array.from(originalMenuItem.querySelectorAll(clonedTagName)); const elementIndex = clonedElements.indexOf(clonedElement); if (elementIndex >= 0 && originalElements[elementIndex]) { return originalElements[elementIndex] as HTMLElement; } return originalMenuItem; } } return null; }; const forwardEvent = (event: Event, originalElement: HTMLElement) => { const originalEvent = event as any; let newEvent: Event; if (originalEvent instanceof MouseEvent) { newEvent = new MouseEvent(event.type, { bubbles: true, cancelable: true, composed: true, button: originalEvent.button, buttons: originalEvent.buttons, clientX: originalEvent.clientX, clientY: originalEvent.clientY, ctrlKey: originalEvent.ctrlKey, shiftKey: originalEvent.shiftKey, altKey: originalEvent.altKey, metaKey: originalEvent.metaKey }); } else if (originalEvent instanceof KeyboardEvent) { newEvent = new KeyboardEvent(event.type, { bubbles: true, cancelable: true, composed: true, key: originalEvent.key, code: originalEvent.code, keyCode: originalEvent.keyCode, which: originalEvent.which, ctrlKey: originalEvent.ctrlKey, shiftKey: originalEvent.shiftKey, altKey: originalEvent.altKey, metaKey: originalEvent.metaKey }); } else if (originalEvent instanceof CustomEvent) { newEvent = new CustomEvent(event.type, { bubbles: true, cancelable: true, composed: true, detail: originalEvent.detail }); } else { newEvent = new Event(event.type, { bubbles: true, cancelable: true, composed: true }); } originalElement.dispatchEvent(newEvent); }; const syncElementState = (clonedElement: HTMLElement, originalElement: HTMLElement) => { if (clonedElement.tagName.toLowerCase() === 'nile-checkbox' && originalElement.tagName.toLowerCase() === 'nile-checkbox') { const clonedCheckbox = clonedElement as any; const originalCheckbox = originalElement as any; if ('checked' in originalCheckbox && 'checked' in clonedCheckbox) { clonedCheckbox.checked = originalCheckbox.checked; } if ('indeterminate' in originalCheckbox && 'indeterminate' in clonedCheckbox) { clonedCheckbox.indeterminate = originalCheckbox.indeterminate; } } }; const eventTypes = ['click', 'change', 'input', 'submit', 'focus', 'blur', 'keydown', 'keyup', 'mousedown', 'mouseup']; eventTypes.forEach(eventType => { clonedPanel.addEventListener(eventType, (event: Event) => { const target = event.target as HTMLElement; if (!target) return; const originalItem = findOriginalElement(target); if (originalItem) { if (eventType === 'click') { if (target.tagName.toLowerCase() === 'nile-checkbox') { event.stopImmediatePropagation(); event.preventDefault(); const originalCheckbox = originalItem as any; if (originalCheckbox && typeof originalCheckbox.click === 'function') { originalCheckbox.click(); setTimeout(() => { syncElementState(target, originalItem); }, 10); } else { forwardEvent(event, originalItem); setTimeout(() => { syncElementState(target, originalItem); }, 10); } } else { event.stopImmediatePropagation(); event.preventDefault(); forwardEvent(event, originalItem); } } else { forwardEvent(event, originalItem); } } }, true); }); const clonedCheckboxes = Array.from(clonedPanel.querySelectorAll('nile-checkbox')); const originalCheckboxes = Array.from(originalMenu.querySelectorAll('nile-checkbox')); clonedCheckboxes.forEach((clonedCheckbox, index) => { const originalCheckbox = originalCheckboxes[index]; if (originalCheckbox) { originalCheckbox.addEventListener('nile-change', () => { syncElementState(clonedCheckbox as HTMLElement, originalCheckbox as HTMLElement); }); } }); clonedPanel.addEventListener('nile-change', (event: CustomEvent) => { const target = event.target as HTMLElement; if (!target) return; const originalElement = findOriginalElement(target); if (originalElement) { const originalEvent = event as CustomEvent; if (target.tagName.toLowerCase() === 'nile-checkbox' && originalEvent.detail?.checked !== undefined) { const clonedCheckbox = target as any; clonedCheckbox.checked = originalEvent.detail.checked; } const newEvent = new CustomEvent('nile-change', { bubbles: true, cancelable: true, composed: true, detail: originalEvent.detail }); originalElement.dispatchEvent(newEvent); } }, true); } }