import {
type Placement,
type MiddlewareData,
type ComputePositionConfig,
type Boundary
} from '@floating-ui/dom';
export class PortalUtils {
static calculateAvailableSpace(referenceElement: HTMLElement): {
spaceAbove: number;
spaceBelow: number;
viewportHeight: number;
} {
const rect = referenceElement.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - rect.bottom;
const spaceAbove = rect.top;
return { spaceAbove, spaceBelow, viewportHeight };
}
static getOptimalPlacement(referenceElement: HTMLElement): Placement {
const { spaceAbove, spaceBelow } = this.calculateAvailableSpace(referenceElement);
if (spaceBelow < 200 && spaceAbove > spaceBelow) {
return 'top';
}
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 (spaceAbove > spaceBelow) {
return Math.max(spaceAbove - 20, 100);
}
else if (spaceBelow > spaceAbove) {
return Math.max(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-select-styles-${Math.random().toString(36).substring(2, 11)}`;
}
static isPositioningOptimal(
placement: Placement,
referenceElement: HTMLElement
): boolean {
const { spaceAbove, spaceBelow } = this.calculateAvailableSpace(referenceElement);
const isAbove = placement.startsWith('top');
const isBelow = placement.startsWith('bottom');
if (isAbove && spaceBelow > spaceAbove) return false;
if (isBelow && spaceAbove > spaceBelow) 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());
}
}
}
static createFloatingUIMiddleware(
boundary: Boundary,
onSizeApply: (params: {
availableWidth: number;
availableHeight: number;
elements: { floating: HTMLElement };
rects: { reference: { x: number; y: number; width: number; height: number } };
}) => void
): ComputePositionConfig['middleware'] {
return [];
}
}
export class PortalContentUtils {
static createBaseListbox(component: any): HTMLElement {
const listbox = document.createElement('div');
listbox.className = `select__listbox ${component.searchEnabled ? 'select__search-enabled ' : ''}`;
listbox.setAttribute('tabindex', '-1');
return listbox;
}
static addSearchSection(listbox: HTMLElement, component: any): void {
if (!component.searchEnabled) return;
const searchDiv = document.createElement('div');
searchDiv.className = 'select__search';
searchDiv.innerHTML = `
`;
listbox.appendChild(searchDiv);
}
static addLoadingSection(listbox: HTMLElement, component: any): void {
if (!component.optionsLoading) return;
const loaderSpan = document.createElement('span');
loaderSpan.className = 'select__loader';
loaderSpan.innerHTML = `
`;
listbox.appendChild(loaderSpan);
}
static addOptionsSection(listbox: HTMLElement, component: any): void {
const optionsContainer = document.createElement('div');
optionsContainer.className = `select__options ${component.searchEnabled ? 'select__options__search-enabled' : ''}`;
this.addNoResultsMessage(optionsContainer, component);
this.addClonedOptions(optionsContainer, component);
listbox.appendChild(optionsContainer);
}
static addNoResultsMessage(container: HTMLElement, component: any): void {
if (!component.showNoResults) return;
const noResultsDiv = document.createElement('div');
noResultsDiv.className = 'select__no-results';
noResultsDiv.textContent = component.noResultsMessage;
container.appendChild(noResultsDiv);
}
static addClonedOptions(container: HTMLElement, component: any): void {
const options = Array.from(component.querySelectorAll('nile-option'));
options.forEach((option: Element) => {
const clonedOption = this.cloneOption(option as HTMLElement);
container.appendChild(clonedOption);
});
}
static cloneOption(option: HTMLElement): HTMLElement {
const clonedOption = option.cloneNode(true) as HTMLElement;
Array.from(option.attributes).forEach((attr: Attr) => {
clonedOption.setAttribute(attr.name, attr.value);
});
this.copyOptionProperties(option, clonedOption);
return clonedOption;
}
static copyOptionProperties(originalOption: HTMLElement, clonedOption: HTMLElement): void {
const properties = ['selected', 'disabled', 'current', 'hidden'];
properties.forEach(prop => {
if ((originalOption as any)[prop] !== undefined) {
(clonedOption as any)[prop] = (originalOption as any)[prop];
}
});
}
static addFooterSection(listbox: HTMLElement, component: any): void {
const hasCustomFooter = component.querySelectorAll('[slot="pre-footer"]').length > 0;
const hasBuiltInFooter = component.multiple;
if (!hasCustomFooter && !hasBuiltInFooter) return;
const footerArea = document.createElement('div');
footerArea.setAttribute('part', 'footer-area');
footerArea.className = 'select__footer-area';
if (hasCustomFooter) {
this.addCustomFooter(footerArea, component);
}
if (hasBuiltInFooter) {
const builtInFooter = this.createFooter(component);
footerArea.appendChild(builtInFooter);
}
listbox.appendChild(footerArea);
}
static addCustomFooter(container: HTMLElement, component: any): void {
const footerContent = Array.from(component.querySelectorAll('[slot="pre-footer"]'));
if (footerContent.length === 0) return;
const customFooter = document.createElement('div');
customFooter.className = 'select__custom-footer';
customFooter.setAttribute('part', 'custom-footer');
footerContent.forEach((el: Element) => {
customFooter.appendChild(el.cloneNode(true));
});
customFooter.addEventListener('click', () => component.hide());
container.appendChild(customFooter);
}
static createFooter(component: any): HTMLElement {
const footerDiv = document.createElement('div');
footerDiv.setAttribute('part', 'footer');
footerDiv.className = 'select__footer';
this.addShowSelectedToggle(footerDiv, component);
this.addClearAllButton(footerDiv, component);
return footerDiv;
}
static addShowSelectedToggle(footer: HTMLElement, component: any): void {
const showSelectedSpan = document.createElement('span');
showSelectedSpan.style.cursor = 'pointer';
const checkbox = document.createElement('nile-checkbox');
checkbox.disabled = component.selectedOptions.length === 0;
checkbox.checked = component.showSelected;
checkbox.innerHTML = ' Show Selected';
showSelectedSpan.appendChild(checkbox);
footer.appendChild(showSelectedSpan);
}
static addClearAllButton(footer: HTMLElement, component: any): void {
if (component.selectedOptions.length === 0) return;
const clearAllSpan = document.createElement('span');
clearAllSpan.className = 'select__clear';
clearAllSpan.textContent = 'Clear All';
clearAllSpan.style.cursor = 'pointer';
footer.appendChild(clearAllSpan);
}
static updateClonedOptions(clonedListbox: HTMLElement, component: any): void {
const optionsContainer = clonedListbox.querySelector('.select__options') as HTMLElement;
if (!optionsContainer) return;
const originalOptions = Array.from(component.querySelectorAll('nile-option'));
const clonedOptions = Array.from(optionsContainer.querySelectorAll('nile-option'));
originalOptions.forEach((originalOption: Element, index: number) => {
const clonedOption = clonedOptions[index] as HTMLElement;
if (clonedOption) {
this.updateClonedOption(originalOption as HTMLElement, clonedOption);
}
});
}
static updateClonedOption(originalOption: HTMLElement, clonedOption: HTMLElement): void {
(clonedOption as any).selected = (originalOption as any).selected;
(clonedOption as any).disabled = (originalOption as any).disabled;
(clonedOption as any).current = (originalOption as any).current;
(clonedOption as any).hidden = (originalOption as any).hidden;
this.updateClonedOptionAttributes(originalOption, clonedOption);
}
static updateClonedOptionAttributes(originalOption: HTMLElement, clonedOption: HTMLElement): void {
const attributes = [
{ prop: 'selected', attr: 'selected' },
{ prop: 'disabled', attr: 'disabled' },
{ prop: 'current', attr: 'current' },
{ prop: 'hidden', attr: 'hidden' }
];
attributes.forEach(({ prop, attr }) => {
if ((originalOption as any)[prop]) {
clonedOption.setAttribute(attr, '');
} else {
clonedOption.removeAttribute(attr);
}
});
}
static createPortalListbox(component: any): HTMLElement {
const listbox = this.createBaseListbox(component);
this.addSearchSection(listbox, component);
this.addLoadingSection(listbox, component);
this.addOptionsSection(listbox, component);
this.addFooterSection(listbox, component);
return listbox;
}
}
export class PortalEventUtils {
static setupPortalEventListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
this.setupOptionClickListeners(clonedListbox, component);
this.setupScrollListeners(clonedListbox, component);
this.setupSearchListeners(clonedListbox, component);
this.setupFooterListeners(clonedListbox, component);
this.setupSlotListeners(clonedListbox, component);
}
static setupOptionClickListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
clonedListbox.addEventListener('mouseup', (event) => {
const target = event.target as HTMLElement;
const option = target.closest('nile-option');
if (option) {
this.handleOptionClick(option as HTMLElement, component);
}
});
}
static handleOptionClick(option: HTMLElement, component: any): void {
const originalOption = component.querySelector(`nile-option[value="${option.getAttribute('value')}"]`) as HTMLElement;
if (originalOption) {
const syntheticEvent = new MouseEvent('mouseup', {
bubbles: true,
cancelable: true,
view: window
});
Object.defineProperty(syntheticEvent, 'target', {
value: originalOption,
writable: false
});
if (component.handleOptionClick) {
component.handleOptionClick(syntheticEvent);
}
}
}
static setupScrollListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
clonedListbox.addEventListener('scroll', (event) => {
if (component.handleScroll) {
component.handleScroll(event);
}
});
}
static setupSearchListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
const searchInput = clonedListbox.querySelector('nile-input') as HTMLElement;
if (searchInput) {
searchInput.addEventListener('nile-input', (event) => {
if (component.handleSearchChange) {
component.handleSearchChange(event);
}
});
searchInput.addEventListener('nile-focus', (event) => {
if (component.handleSearchFocus) {
component.handleSearchFocus();
}
});
searchInput.addEventListener('nile-blur', (event) => {
if (component.handleSearchBlur) {
component.handleSearchBlur();
}
});
}
}
static setupFooterListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
const footer = clonedListbox.querySelector('.select__footer') as HTMLElement;
if (footer) {
this.setupFooterClickHandlers(footer);
this.setupShowSelectedToggle(footer, component);
this.setupClearAllHandler(footer, component);
}
this.setupCustomFooterListeners(clonedListbox, component);
}
static setupCustomFooterListeners(clonedListbox: HTMLElement, component: any): void {
const customFooter = clonedListbox.querySelector('.select__custom-footer') as HTMLElement;
if (!customFooter) return;
customFooter.addEventListener('click', (event) => {
event.stopPropagation();
});
}
static setupFooterClickHandlers(footer: HTMLElement): void {
footer.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
});
}
static setupShowSelectedToggle(footer: HTMLElement, component: any): void {
const showSelectedSpan = footer.querySelector('span[style*="cursor: pointer"]');
if (showSelectedSpan) {
showSelectedSpan.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
if (component.toggleShowSelected) {
component.toggleShowSelected(event);
}
});
}
}
static setupClearAllHandler(footer: HTMLElement, component: any): void {
footer.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.classList.contains('select__clear')) {
event.stopPropagation();
event.preventDefault();
if (component.unSlectAll) {
component.unSlectAll();
}
}
});
}
static setupSlotListeners(clonedListbox: HTMLElement, component: any): void {
if (!clonedListbox) return;
const slot = clonedListbox.querySelector('slot');
if (slot) {
slot.addEventListener('slotchange', () => {
if (component.updatePortalContent) {
component.updatePortalContent();
}
});
}
}
}