/** * 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 { LitElement, html, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { styles } from './nile-virtual-select.css'; import '../nile-icon'; import '../nile-popup/nile-popup'; import '../nile-tag/nile-tag'; import '../nile-checkbox/nile-checkbox'; import '../nile-loader/nile-loader'; import { animateTo, stopAnimations } from '../internal/animate'; import { classMap } from 'lit/directives/class-map.js'; import { query, state } from 'lit/decorators.js'; import { defaultValue } from '../internal/default-value'; import { FormControlController } from '../internal/form'; import { getAnimation, setDefaultAnimation, } from '../utilities/animation-registry'; import { HasSlotController } from '../internal/slot'; import { waitForEvent } from '../internal/event'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit'; import type { NileFormControl } from '../internal/nile-element'; import type NileOption from '../nile-option/nile-option'; import type NilePopup from '../nile-popup/nile-popup'; import { ifDefined } from 'lit/directives/if-defined.js'; import type { VirtualOption, NileRemoveEvent, RenderItemConfig } from './types.js'; import { VirtualSelectSelectionManager } from './selection-manager.js'; import { VirtualSelectSearchManager } from './search-manager.js'; import { VirtualSelectRenderer } from './renderer.js'; import { PortalManager } from './portal-manager.js'; import { ResizeController } from '@lit-labs/observers/resize-controller.js'; import { VisibilityManager } from '../utilities/visibility-manager.js'; /** * Nile Virtual Select component. * * @tag nile-virtual-select * */ /** * @summary Virtual select component for handling large datasets with virtual scrolling. * @status stable * @since 2.0 * * @dependency nile-icon * @dependency nile-popup * @dependency nile-tag * @dependency nile-checkbox * * @slot label - The input's label. Alternatively, you can use the `label` attribute. * @slot prefix - Used to prepend a presentational icon or similar element to the combobox. * @slot clear-icon - An icon to use in lieu of the default clear icon. * @slot expand-icon - The icon to show when the control is expanded and collapsed. * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute. * * @event nile-change - Emitted when the control's value changes. * @event nile-clear - Emitted when the control's value is cleared. * @event nile-input - Emitted when the control receives input. * @event nile-focus - Emitted when the control gains focus. * @event nile-blur - Emitted when the control loses focus. * @event nile-show - Emitted when the select's menu opens. * @event nile-after-show - Emitted after the select's menu opens and all animations are complete. * @event nile-hide - Emitted when the select's menu closes. * @event nile-after-hide - Emitted after the select's menu closes and all animations are complete. * @event nile-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied. * @event nile-search - Emitted when the user types in the search input. The event payload includes the search query for backend search functionality. * @event nile-scroll - Emitted when the user scrolls within the virtualized container. The event payload includes scroll position information. * @event nile-scroll-start - Emitted when the user starts scrolling within the virtualized container. * @event nile-scroll-end - Emitted when the user stops scrolling and reaches the bottom of the virtualized container (debounced). * * @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control-label - The label's wrapper. * @csspart form-control-input - The select's wrapper. * @csspart form-control-help-text - The help text's wrapper. * @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button. * @csspart prefix - The container that wraps the prefix slot. * @csspart display-input - The element that displays the selected option's label, an `` element. * @csspart listbox - The listbox container where options are slotted. * @csspart tags - The container that houses option tags when `multiple` is used. * @csspart tag - The individual tags that represent each multiselect option. * @csspart clear-button - The clear button. * @csspart expand-icon - The container that wraps the expand icon. * @csspart footer - The footer container with show selected and clear all options. */ @customElement('nile-virtual-select') export class NileVirtualSelect extends NileElement implements NileFormControl { static styles: CSSResultGroup = styles; private readonly formControlController = new FormControlController(this, { assumeInteractionOn: ['nile-blur', 'nile-input'], }); private readonly hasSlotController = new HasSlotController( this, 'help-text', 'label' ); private readonly portalManager = new PortalManager(this); @query('.select') popup: NilePopup; @query('.select__combobox') combobox: HTMLElement; @query('.select__display-input') displayInput: HTMLInputElement; @query('.select__value-input') valueInput: HTMLInputElement; @query('.virtualized') virtualizedContainer: HTMLElement; @state() private hasFocus = false; @state() displayLabel = ''; @state() selectedOptions: VirtualOption[] = []; @state() oldValue: string | string[] = ''; private scrollTimeout: number | undefined; private scrolling = false; private visibilityManager?: VisibilityManager; /** The name of the select, submitted as a name/value pair with form data. */ @property() name = ''; /** Array of all option items for virtual scrolling */ @property({ type: Array }) data: any = []; /** Original unfiltered option items for search functionality */ @state() private originalOptionItems: any = []; /** * The current value of the select. When `multiple` is enabled, the value will be an array of selected values. */ @property({ converter: { fromAttribute: (value: string) => value.split(' '), toAttribute: (value: string[]) => value.join(' '), }, }) value: string | string[] = ''; /** The default value of the form control. Primarily used for resetting the form control. */ @defaultValue() defaultValue: string | string[] = ''; /** The select's size. */ @property() size: 'small' | 'medium' | 'large' = 'medium'; /** Placeholder text to show as a hint when the select is empty. */ @property() placeholder = 'Select...'; /** Enable automatic resizing of tags area */ @property({ type: Boolean }) autoResize = false; /** Current search value */ @state() searchValue: string = ''; /** Enable search functionality */ @property({ type: Boolean, reflect: true }) searchEnabled = false; @property({ type: Boolean, reflect: true, attribute: true }) enableVisibilityEffect = false; /** Search input placeholder */ @property({attribute:'internal-search-placeholder'}) internalSearchPlaceHolder = 'Search...'; /** Disable local search filtering */ @property({ type: Boolean, reflect: true }) disableLocalSearch = false; /** Show loading state */ @property({ type: Boolean, reflect: true }) optionsLoading = false; /** Show loading state using nile-loader */ @property({ type: Boolean, reflect: true, attribute: true }) loading = false; /** Allows more than one option to be selected. */ @property({ type: Boolean, reflect: true }) multiple = false; /** Help text */ @property({ attribute: 'help-text', reflect: true }) helpText = ''; /** Error message */ @property({ attribute: 'error-message', reflect: true }) errorMessage = ''; /** Sets the input to a warning state */ @property({ type: Boolean }) warning = false; /** Sets the input to an error state */ @property({ type: Boolean }) error = false; /** Sets the input to a success state */ @property({ type: Boolean }) success = false; /** Disables the select control. */ @property({ type: Boolean, reflect: true }) disabled = false; /** Adds a clear button when the select is not empty. */ @property({ type: Boolean, reflect: true }) clearable = false; /** The select's open state. */ @property({ type: Boolean, reflect: true }) open = false; /** * Enable this option to prevent the listbox from being clipped when the component is placed inside a container with * `overflow: auto|scroll`. Hoisting uses a fixed positioning strategy that works in many, but not all, scenarios. */ @property({ type: Boolean }) hoist = false; /** Draws a filled select. */ @property({ type: Boolean, reflect: true }) filled = false; /** Draws a pill-style select with rounded edges. */ @property({ type: Boolean, reflect: true }) pill = false; /** The select's label. If you need to display HTML, use the `label` slot instead. */ @property() label = ''; /** * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox * inside of the viewport. */ @property({ reflect: true }) placement: 'top' | 'bottom' = 'bottom'; /** * By default, form controls are associated with the nearest containing `
` element. This attribute allows you * to place the form control outside of a form and associate it with the form that has this `id`. The form must be in * the same document or shadow root for this to work. */ @property({ reflect: true }) form = ''; /** The select's required attribute. */ @property({ type: Boolean, reflect: true }) required = false; /** Show no results message */ @property({ type: Boolean }) showNoResults: boolean = false; /** No results message */ @property({ type: String }) noResultsMessage: string = 'No results found'; /** Show selected options only */ @property({ type: Boolean }) showSelected = false; /** Enhanced configuration for rendering items with support for display text, value, and search text */ @property({ attribute:false}) renderItemConfig?: RenderItemConfig; /** Block value change events */ @property({ type: Boolean, reflect: true }) blockValueChange = false; /** Disable width synchronization */ @property({ type: Boolean, reflect: true }) noWidthSync = false; /** * When true, the listbox will be appended to the document body instead of the parent container. * This is useful when the parent has overflow: hidden, clip-path, or transform applied. */ @property({ type: Boolean, reflect: true }) portal = false; /** * The maximum number of selected options to show when `multiple` is true. After the maximum, "+n" will be shown to * indicate the number of additional items that are selected. Set to 0 to remove the limit. */ @property({ attribute: 'max-options-visible', type: Number }) maxOptionsVisible = 3; @state() oldMaxOptionsVisible: number = 1; @property({ type: Boolean, reflect: true, attribute: true }) enableTabClose = false; @property({ type: Boolean, reflect: true, attribute: true }) descriptionSearchEnabled = false; @property({ type: Boolean, reflect: true, attribute: true, converter: { fromAttribute: (value) => value === 'true', toAttribute: (value) => String(value), }, }) allowHtmlLabel = true; @property({ type: Boolean, reflect: true, attribute: true }) enableDescription = false; @state() showListbox: boolean = false; @state() private wasShowSelectedCheckedOnClose: boolean = false; /** Gets the validity state object */ get validity() { return this.valueInput?.validity; } /** Gets the validation message */ get validationMessage() { return this.valueInput?.validationMessage ?? ''; } connectedCallback() { super.connectedCallback(); this.initializeComponent(); this.setupEventListeners(); this.updateComplete.then(() => { if (this.value && this.data.length > 0) { this.selectionChanged(); } }); } disconnectedCallback() { this.removeOpenListeners(); this.visibilityManager?.cleanup(); // Clear any pending scroll timeout to prevent memory leaks if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); this.scrollTimeout = undefined; } // Clean up body append elements this.portalManager.cleanupPortalAppend(); } protected updated(changedProperties: PropertyValues): void { if(changedProperties.has('value')) { this.selectionChanged(); } if (changedProperties.has('autoResize')) { const tagsDiv = this.shadowRoot?.querySelector('.select__tags') as HTMLElement; if (this.autoResize && tagsDiv) { this.resizeController.observe(tagsDiv); } else if (tagsDiv) { this.resizeController.unobserve(tagsDiv); } } } private initializeComponent(): void { this.open = false; this.emit('nile-init'); } /** * Get display text for an item using renderItemConfig */ private getDisplayText(item: any): string { if (this.renderItemConfig?.getDisplayText) { return this.renderItemConfig.getDisplayText(item); } // Fallback to basic display return item?.label || item?.name || item?.toString() || ''; } /** * Get value for an item using renderItemConfig or fallback to item.value */ private getItemValue(item: any): string { if (this.renderItemConfig?.getValue) { return this.renderItemConfig.getValue(item); } return item?.value || item; } /** * Get description for an item using renderItemConfig or fallback to item.description */ private getItemDescription(item: any): string { if (this.renderItemConfig?.getDescription) { return this.renderItemConfig.getDescription(item); } return item?.description || item || ''; } /** * Get description for an item using renderItemConfig or fallback to item.description */ private getItemPrefix(item: any): string { if (this.renderItemConfig?.getPrefix) { return this.renderItemConfig.getPrefix(item); } return item?.prefix || item || ''; } /** * Get suffix for an item using renderItemConfig or fallback to item.suffix */ private getItemSuffix(item: any): string { if (this.renderItemConfig?.getSuffix) { return this.renderItemConfig.getSuffix(item); } return item?.suffix || item || ''; } /** * Get search text for an item using renderItemConfig or fallback to display text */ private getSearchText(item: any): string { if (this.renderItemConfig?.getSearchText) { return this.renderItemConfig.getSearchText(item); } return this.getDisplayText(item); } private setupEventListeners(): void { this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); this.handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); this.handleWindowError = this.handleWindowError.bind(this); this.handleWindowResize = this.handleWindowResize.bind(this); this.handleWindowScroll = this.handleWindowScroll.bind(this); } private addOpenListeners(): void { document.addEventListener('focusin', this.handleDocumentFocusIn); document.addEventListener('keydown', this.handleDocumentKeyDown); document.addEventListener('mousedown', this.handleDocumentMouseDown); window.addEventListener('error', this.handleWindowError); if (this.portal) { window.addEventListener('resize', this.handleWindowResize); window.addEventListener('scroll', this.handleWindowScroll, true); } } private removeOpenListeners(): void { document.removeEventListener('focusin', this.handleDocumentFocusIn); document.removeEventListener('keydown', this.handleDocumentKeyDown); document.removeEventListener('mousedown', this.handleDocumentMouseDown); window.removeEventListener('error', this.handleWindowError); window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('scroll', this.handleWindowScroll, true); } private handleFocus(): void { this.hasFocus = true; this.emit('nile-focus'); } private handleBlur(): void { this.hasFocus = false; this.emit('nile-blur'); } protected firstUpdated(_changed: PropertyValues) { this.visibilityManager = new VisibilityManager({ host: this, target: this.combobox, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.open, onAnchorOutOfView: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', name: this.name, }); }, onDocumentHidden: () => { this.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', name: this.name, }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } private handleDocumentFocusIn(event: FocusEvent): void { if (!this.open) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitPopup = this.popup && path.includes(this.popup); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortalAppend) { this.hide(); } } private handleDocumentKeyDown(event: KeyboardEvent): void { if (this.shouldIgnoreKeyPress(event)) { return; } if (this.isEscapeKey(event)) { this.handleEscapeKey(event); } if (this.isEnterOrSpaceKey(event)) { this.handleEnterOrSpaceKey(event); } } private shouldIgnoreKeyPress(event: KeyboardEvent): boolean { const target = event.target as HTMLElement; const isClearButton = target.closest('.select__clear') !== null; const isIconButton = target.closest('nile-icon-button') !== null; return isClearButton || isIconButton; } private isEscapeKey(event: KeyboardEvent): boolean { return event.key === 'Escape' && this.open; } private handleEscapeKey(event: KeyboardEvent): void { event.preventDefault(); event.stopPropagation(); this.hide(); this.displayInput.focus({ preventScroll: true }); } private isEnterOrSpaceKey(event: KeyboardEvent): boolean { return event.key === 'Enter' || event.key === ' '; } private handleEnterOrSpaceKey(event: KeyboardEvent): void { event.preventDefault(); event.stopImmediatePropagation(); if (!this.open) { this.show(); return; } if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } private handleDocumentMouseDown = (event: MouseEvent): void => { if (!this.open) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitPopup = this.popup && path.includes(this.popup); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitPopup && !hitPortalAppend) { this.hide(); } }; /** * This is a workaround for an error in the Lit Labs virtualizer. * Since there are no specific guidelines available to fix the issue, * we are catching only the error message related to the virtualizer. */ private handleWindowError = (event: ErrorEvent): void => { const errorMessage = event.error?.message || event.message || ''; if (errorMessage.includes('Cannot read properties of null (reading \'insertBefore\')')) { event.preventDefault(); return; } }; private handleWindowResize = (): void => { this.portalManager.updatePortalAppendPosition(); }; private handleWindowScroll = (): void => { this.portalManager.updatePortalAppendPosition(); }; private handleFooterClick(event: MouseEvent): void { event.stopPropagation(); event.preventDefault(); } private toggleShowSelected(event: Event): void { event.stopPropagation(); event.preventDefault(); if (this.selectedOptions?.length === 0) { return; } this.showSelected = !this.showSelected; if (this.showSelected) { this.searchValue = ""; const selectedValues = Array.isArray(this.value) ? this.value : [this.value]; this.data = this.originalOptionItems.filter((item: any) => { const itemValue = this.getItemValue(item); return selectedValues.some(val => String(val) === String(itemValue)); }); } else { this.data = [...this.originalOptionItems]; } // Reset measured height when show selected toggles (content height may change) this.portalManager.resetMeasuredHeight(); this.requestUpdate(); this.repaintOptionsContainer(); } private unSelectAll(): void { this.showSelected = false; this.value = this.multiple ? [] : ''; this.data = [...this.originalOptionItems]; this.selectionChanged(); this.emit('nile-change', { value: this.value, name: this.name }); this.emit('nile-clear', { value: this.multiple ? this.value : '', name: this.name }); this.resetScrollPosition(); } private handleLabelClick(): void { this.displayInput.focus(); this.hide(); } private handleComboboxMouseDown(event: MouseEvent): void { if (this.shouldIgnoreComboboxClick(event)) { return; } event.preventDefault(); this.displayInput.focus({ preventScroll: true }); this.open = !this.open; } private resizeController = new ResizeController(this, { callback: (entries: ResizeObserverEntry[]) => { for (const entry of entries) { if (entry.target.classList.contains('select__tags')) { this.calculateTotalWidthOfTags(); } } } }); private shouldIgnoreComboboxClick(event: MouseEvent): boolean { const path = event.composedPath(); const isIconButton = path.some( el => el instanceof Element && el.tagName.toLowerCase() === 'nile-icon-button' ); return this.disabled || isIconButton; } private handleComboboxKeyDown(event: KeyboardEvent): void { if (this.isEnterOrSpaceKey(event)) { event.preventDefault(); this.open = !this.open; } } private handleClearClick(event: MouseEvent): void { event.stopPropagation(); this.clearSelection(); } private clearSelection(): void { const oldValue = this.value; this.value = this.multiple ? [] : ''; this.selectionChanged(); this.updateComplete.then(() => { this.nileInput({ value: this.value, name: this.name }); this.nileChange({ value: this.value, name: this.name }); this.emit('nile-clear'); }); } private handleClearMouseDown(event: MouseEvent): void { event.stopPropagation(); } private handleOptionClick(event: MouseEvent): void { const target = event.target as HTMLElement; const option = target.closest('nile-option'); if (this.shouldBlockValueChange(option)) { return; } const oldValue = this.value; this.oldValue = oldValue; if (option && !option.disabled) { this.handleOptionSelection(option); } } private shouldBlockValueChange(option: Element | null): boolean { if (this.blockValueChange && option) { this.emit('nile-block-change', { value: (option as any)?.value, name: this.name }); this.hide(); return true; } return false; } private handleOptionSelection(option: Element): void { const optionValue = (option as any).value; if (this.multiple) { this.toggleOptionSelection(optionValue); } else { this.setSelectedOptions(optionValue); } this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true }) ); if (this.value !== this.oldValue) { this.updateComplete.then(() => { this.nileInput({ value: this.value, name: this.name }); this.nileChange({ value: this.value, name: this.name }); }); } if (!this.multiple) { this.hide(); this.displayInput.focus({ preventScroll: true }); } } private setSelectedOptions(optionValue: string): void { this.value = optionValue; this.selectionChanged(); } private toggleOptionSelection(optionValue: string): void { const currentValues = Array.isArray(this.value) ? this.value : []; if (currentValues.includes(optionValue)) { this.value = currentValues.filter(v => v !== optionValue); } else { this.value = [...currentValues, optionValue]; } this.selectionChanged(); } private handleTagRemove(event: NileRemoveEvent, option: VirtualOption): void { event.stopPropagation(); if (!this.disabled) { this.removeTagFromSelection(option); this.emitTagRemovalEvent(option); } } private removeTagFromSelection(option: VirtualOption): void { let currentValue = this.value; if (!Array.isArray(currentValue)) { currentValue = currentValue ? [currentValue] : []; } const newValue = currentValue.filter((v: string) => v !== option.value); this.value = newValue; this.selectionChanged(); } private emitTagRemovalEvent(option: VirtualOption): void { this.updateComplete.then(() => { this.nileInput({ value: this.value, name: this.name }); this.nileChange({ value: this.value, name: this.name }); this.emit('nile-tag-remove', { value: this.value, name: this.name, removedtagvalue: option.value }); }); } private selectionChanged(): void { const itemsToSearch = this.originalOptionItems.length > 0 ? this.originalOptionItems : this.data; this.selectedOptions = VirtualSelectSelectionManager.createVirtualOptionsFromValues( this.value, itemsToSearch, this.getDisplayText.bind(this), this.renderItemConfig?.getValue, this.allowHtmlLabel ); if (this.multiple) { if (this.placeholder && this.value.length === 0) { this.displayLabel = ''; } else { this.displayLabel = this.selectedOptions.length + ' selected'; } } else { const currentValue = Array.isArray(this.value) ? this.value[0] : this.value; const label = this.selectedOptions[0]?.getTextLabel(); this.displayLabel = label ? label : currentValue ?? ''; } this.updateValidity(); if (this.selectedOptions.length === 0) { this.showSelected = false; if(this.originalOptionItems?.length > 0 && !this.searchValue) { this.data = [...this.originalOptionItems]; } this.repaintOptionsContainer(); } this.calculateTotalWidthOfTags(); } handleSearchFocus(): void { document.removeEventListener('keydown', this.handleDocumentKeyDown); } handleSearchBlur(): void { document.addEventListener('keydown', this.handleDocumentKeyDown); } handleSearchChange(e: any): void { this.searchValue = e.detail.value; if (this.portal) { this.portalManager.updatePortalAppendPosition(); } this.emit('nile-search', { query: this.searchValue, name: this.name }); if (!this.disableLocalSearch) { this.filterVirtualOptions(this.searchValue); this.repaintOptionsContainer(); // Reset measured height when search changes (content height may change) this.portalManager.resetMeasuredHeight(); } } repaintOptionsContainer() { this.resetScrollPosition(); this.updateComplete.then(() => { const virtualized = this.shadowRoot?.querySelector('.virtualized') as HTMLElement; if (virtualized) { if (this.data.length < 5) { virtualized.classList.add('no-scroll'); } else { virtualized.classList.remove('no-scroll'); } } }); } handleScroll(e: Event): void { if(this.showSelected) { return; } const target = e.target as HTMLElement; this.emit('nile-scroll', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name }); if (!this.scrolling) { this.scrolling = true; this.emit('nile-scroll-start', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name }); } clearTimeout(this.scrollTimeout); this.scrollTimeout = window.setTimeout(() => { if (this.scrolling) { this.scrolling = false; } }, 300); const isAtBottom = Math.ceil(target.scrollTop) >= Math.floor(target.scrollHeight - target.offsetHeight); if (isAtBottom && !this.searchValue) { this.emit('nile-scroll-end', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name, isAtBottom: true }); } } filterVirtualOptions(searchValue: string): void { const result = VirtualSelectSearchManager.filterVirtualOptions( searchValue, this.originalOptionItems, this.data, this.getDisplayText.bind(this), this.getItemDescription.bind(this), this.descriptionSearchEnabled, this.renderItemConfig?.getSearchText ); if (this.portal) { this.portalManager.updatePortalAppendPosition(); this.updateComplete.then(() => { requestAnimationFrame(() => { this.resetScrollPosition(); }); }); } else { this.resetScrollPosition(); } this.data = result.filteredItems; this.showNoResults = result.showNoResults; this.showSelected = false; this.requestUpdate(); } private handleInvalid(event: Event): void { this.formControlController.setValidity(false); this.formControlController.emitInvalidEvent(event); } @watch('disabled', { waitUntilFirstUpdate: true }) handleDisabledChange(): void { if (this.disabled) { this.open = false; this.handleOpenChange(); } } @watch('value', { waitUntilFirstUpdate: true }) handleValueChange(): void { this.selectionChanged(); this.requestUpdate(); // Update body append content if it's active if (this.portal && this.portalManager.portalContainerElement) { this.portalManager.updatePortalAppendPosition(); } } @watch('data', { waitUntilFirstUpdate: true }) handleDataChange(): void { if (this.data.length > 0 && this.open && !this.showSelected && !this.searchValue) { this.originalOptionItems = [...this.data]; } this.selectionChanged(); // Show no results message when data is empty and not loading if (!this.optionsLoading && !this.loading && this.data.length === 0) { this.showNoResults = true; } else if (this.data.length > 0) { this.showNoResults = false; } this.requestUpdate(); // Reset measured height when data changes (content height may change) this.portalManager.resetMeasuredHeight(); // Update body append content if it's active if (this.portal && this.portalManager.portalContainerElement) { this.portalManager.updatePortalAppendPosition(); } } @watch('renderItemConfig', { waitUntilFirstUpdate: true }) handleRenderItemConfigChange(): void { if (this.value && this.data.length > 0) { this.selectionChanged(); this.requestUpdate(); } } @watch('optionsLoading', { waitUntilFirstUpdate: true }) handleOptionsLoadingChange(): void { // Show no results message when loading stops and there are no results if (!this.optionsLoading && this.data.length === 0) { this.showNoResults = true; } this.requestUpdate(); // Update body append content if it's active if (this.portal && this.portalManager.portalContainerElement) { this.portalManager.updatePortalAppendPosition(); } } @watch('portal', { waitUntilFirstUpdate: true }) handlePortalAppendChange(): void { if (this.open) { if (this.portal) { this.portalManager.setupPortalAppend(); } else { this.portalManager.cleanupPortalAppend(); } } } @watch('open', { waitUntilFirstUpdate: true }) async handleOpenChange(): Promise { if (this.open && !this.disabled) { this.visibilityManager?.setup(); this.showListbox = true; await this.updateComplete; await this.handleOpen(); if (this.portal) { this.portalManager.setupPortalAppend(); } } else { this.visibilityManager?.cleanup(); await this.handleClose(); this.showListbox = false; if (this.portal) { this.portalManager.cleanupPortalAppend(); } } } private async handleOpen(): Promise { this.emit('nile-show', { value: this.value, name: this.name }); this.addOpenListeners(); this.showNoResults = !this.data?.length; await stopAnimations(this); // Hide popup initially to prevent flickering during positioning calculation // This allows content to be measured while invisible if (this.popup?.popup) { this.popup.popup.style.visibility = 'hidden'; } this.popup.active = true; // Wait for positioning to complete before showing the popup await new Promise(resolve => requestAnimationFrame(resolve)); // Wait for next frame to allow Floating UI to calculate position await new Promise(resolve => requestAnimationFrame(resolve)); // Show popup after positioning is calculated if (this.popup?.popup) { this.popup.popup.style.visibility = ''; } const { keyframes, options } = getAnimation(this, 'select.show', { dir: 'ltr', }); await animateTo(this.popup.popup, keyframes, options); if (this.wasShowSelectedCheckedOnClose) { this.showSelected = false; this.data = [...this.originalOptionItems]; this.wasShowSelectedCheckedOnClose = false; } this.filterVirtualOptions(""); this.resetScrollPosition(); this.emit('nile-after-show', { value: this.value, name: this.name }); } private async handleClose(): Promise { this.emit('nile-hide', { value: this.value, name: this.name }); this.removeOpenListeners(); this.wasShowSelectedCheckedOnClose = this.showSelected; await stopAnimations(this); const { keyframes, options } = getAnimation(this, 'select.hide', { dir: 'ltr', }); await animateTo(this.popup.popup, keyframes, options); this.popup.active = false; // Reset visibility style when closing if (this.popup?.popup) { this.popup.popup.style.visibility = ''; } this.searchValue = ''; // Reset measured height when popup closes (content may change on next open) this.portalManager.resetMeasuredHeight(); this.emit('nile-after-hide', { value: this.value, name: this.name }); } async show(): Promise { if (this.open || this.disabled) { this.open = false; return undefined; } this.open = true; return waitForEvent(this, 'nile-after-show'); } async hide(): Promise { if (!this.open || this.disabled) { this.open = false; return undefined; } this.open = false; return waitForEvent(this, 'nile-after-hide'); } checkValidity(): boolean { return this.valueInput.checkValidity(); } getForm(): HTMLFormElement | null { return this.formControlController.getForm(); } reportValidity(): boolean { return this.valueInput.reportValidity(); } setCustomValidity(message: string): void { this.valueInput.setCustomValidity(message); this.formControlController.updateValidity(); } focus(options?: FocusOptions): void { this.displayInput.focus(options); } blur(): void { this.displayInput.blur(); } onInputChange(event: Event): void { event.stopPropagation(); } render(): TemplateResult { const hasLabelSlot = this.hasSlotController.test('label'); const hasHelpTextSlot = this.hasSlotController.test('help-text'); const hasLabelSuffixSlot = this.hasSlotController.test('label-suffix'); const hasCustomSelect = this.hasSlotController.test('custom-select'); const hasLabel = this.label ? true : !!hasLabelSlot; const hasClearIcon = this.clearable && !this.disabled && this.value.length > 0; const isPlaceholderVisible = !!this.placeholder && this.value.length === 0; const hasHelpText = !!this.helpText; const hasErrorMessage = !!this.errorMessage; return html`
${this.renderLabel(hasLabel, hasLabelSuffixSlot)} ${this.renderFormControlInput(hasCustomSelect, hasClearIcon, isPlaceholderVisible, hasHelpText, hasErrorMessage)}
`; } private renderLabel(hasLabel: boolean, hasLabelSuffixSlot: boolean): TemplateResult { return html` ${hasLabelSuffixSlot ? html` ` : ``} `; } private renderFormControlInput( hasCustomSelect: boolean, hasClearIcon: boolean, isPlaceholderVisible: boolean, hasHelpText: boolean, hasErrorMessage: boolean ): TemplateResult { return html`
${this.renderPopup(hasCustomSelect, hasClearIcon, isPlaceholderVisible)} ${this.renderHelpText(hasHelpText, hasErrorMessage)}
`; } private renderPopup(hasCustomSelect: boolean, hasClearIcon: boolean, isPlaceholderVisible: boolean): TemplateResult { return html` ${this.renderCustomSelect(hasCustomSelect)} ${this.renderCombobox(hasCustomSelect, hasClearIcon)} ${this.showListbox ? this.renderListbox() : html``} `; } private renderCustomSelect(hasCustomSelect: boolean): TemplateResult { return hasCustomSelect ? html`` : html``; } private renderCombobox(hasCustomSelect: boolean, hasClearIcon: boolean): TemplateResult { return html`
${this.renderPrefix()} ${this.renderDisplayInput()} ${this.renderTags()} ${this.renderValueInput()} ${this.renderClearButton(hasClearIcon)} ${this.renderSuffix()} ${this.renderExpandIcon()}
`; } private renderPrefix(): TemplateResult { return html` `; } private renderDisplayInput(): TemplateResult { return html` `; } private renderTags(): TemplateResult { return this.multiple ? html`
${this.selectedOptions.map((option, index) => { if ( index < this.maxOptionsVisible || this.maxOptionsVisible <= 0 ) { const classes = { select__invisible: index + 1 > this.oldMaxOptionsVisible && this.maxOptionsVisible === Infinity, }; return html` this.handleTagRemove(event, option)} > ${option.getTextLabel()} `; } else if (index === this.maxOptionsVisible) { return html` +${this.selectedOptions.length - index} More `; } else { return null; } })}
` : html``; } private renderValueInput(): TemplateResult { return html` this.focus()} @invalid=${this.handleInvalid} /> `; } private renderClearButton(hasClearIcon: boolean): TemplateResult { return hasClearIcon ? html` ` : html``; } private renderSuffix(): TemplateResult { return html` `; } private renderExpandIcon(): TemplateResult { return html` `; } private renderListbox(): TemplateResult { return html`
${this.renderSearch()} ${this.renderLoader()} ${this.getVirtualizedContent()} ${this.renderFooter()}
`; } private renderSearch(): TemplateResult { return this.searchEnabled ? html` ` : html``; } private renderLoader(): TemplateResult { if (this.loading) { return html` `; } if (this.optionsLoading) { return html` `; } return html``; } private renderFooter(): TemplateResult { return this.multiple ? html` ` : html``; } private renderHelpText(hasHelpText: boolean, hasErrorMessage: boolean): TemplateResult { return html` ${hasHelpText ? html` ${this.helpText} ` : ``} ${hasErrorMessage ? html` ${this.errorMessage} ` : ``} `; } getVirtualizedContent(): TemplateResult { return VirtualSelectRenderer.getVirtualizedContent( this.data, this.searchEnabled, this.getDisplayText.bind(this), this.value, this.multiple, this.renderItemConfig?.getDisplayText, this.renderItemConfig?.getValue, this.renderItemConfig?.getDescription, this.renderItemConfig?.getPrefix, this.renderItemConfig?.getSuffix, this.showNoResults, this.noResultsMessage, this.optionsLoading || this.loading, this.handleScroll.bind(this), this.allowHtmlLabel, this.enableDescription ); } nileInput(value: any): void { this.emit('nile-input', value); } nileChange(value: any): void { this.emit('nile-change', value); } private updateValidity(): void { this.updateComplete.then(() => { this.formControlController.updateValidity(); }); } private calculateWidthOfSelectTagsDiv(): number | null { if (this.shadowRoot) { const selectTagsDiv = this.shadowRoot.querySelector('.select__tags') as HTMLElement; if (selectTagsDiv) { const width = selectTagsDiv.offsetWidth; return width - 70; } } return null; } private calculateTotalWidthOfTags(): void { if (this.maxOptionsVisible !== Infinity) { this.oldMaxOptionsVisible = this.maxOptionsVisible; } this.maxOptionsVisible = Infinity; setTimeout(() => { let widths: number[] = []; if (this.shadowRoot) { const tags = this.shadowRoot.querySelectorAll('nile-tag'); tags.forEach(tag => { if (tag instanceof HTMLElement) { widths.push(tag.offsetWidth); } }); } if (this.value.length !== widths.length) { return; } let sum = widths.reduce( (accumulator, currentValue) => accumulator + currentValue, 0 ); const widthOfSelectTagsDiv = this.calculateWidthOfSelectTagsDiv(); if (!widthOfSelectTagsDiv) { return; } let summ = 0; let lastindex = 0; for (let i = 0; i < widths.length; i++) { summ += widths[i]; if (summ > widthOfSelectTagsDiv) { lastindex = i; break; } } this.maxOptionsVisible = lastindex; }, 1); } private async resetScrollPosition(): Promise { await this.portalManager.resetScrollPosition(); } } setDefaultAnimation('select.show', { keyframes: [ { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }, ], options: { duration: 100, easing: 'ease' }, }); setDefaultAnimation('select.hide', { keyframes: [ { opacity: 1, scale: 1 }, { opacity: 0, scale: 0.9 }, ], options: { duration: 100, easing: 'ease' }, }); export default NileVirtualSelect; declare global { interface HTMLElementTagNameMap { 'nile-virtual-select': NileVirtualSelect; } }