import { LitElement, html, CSSResultArray, TemplateResult, nothing, } from 'lit'; import { customElement, query, state, property } from 'lit/decorators.js'; import { styles } from './nile-auto-complete.css'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValues } from 'lit'; import { NileDropdown } from '../nile-dropdown'; import { watch } from '../internal/watch'; import { AutoCompletePortalManager } from './portal-manager'; import { NileInput } from '../nile-input'; import { virtualize } from '@lit-labs/virtualizer/virtualize.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { VisibilityManager } from '../utilities/visibility-manager.js'; // Define the custom element 'nile-auto-complete' @customElement('nile-auto-complete') export class NileAutoComplete extends NileElement { static styles: CSSResultGroup = styles; private visibilityManager?: VisibilityManager; @query('nile-dropdown') dropdownElement: NileDropdown; @query('nile-input') inputElement: NileInput; // Define component properties @property({ type: Boolean, reflect: true }) disabled: boolean = false; @property({ type: Boolean }) isDropdownOpen: boolean = false; /** * When true, the dropdown menu 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; private readonly portalManager = new AutoCompletePortalManager(this); @property({ type: Boolean }) enableVirtualScroll: boolean = false; @property({ type: Boolean }) openOnFocus: boolean = false; @property({ type: String }) value: string = ''; @property({ type: String }) placeholder: string = 'Type here ..'; @property({ type: Boolean }) noBorder: boolean = false; @property({ type: Boolean }) noOutline: boolean = false; @property({ type: Boolean }) noPadding: boolean = false; @property({ type: Boolean }) loading: boolean = false; @property({ attribute:false}) filterFunction: (item:string,searchedValue:string)=>boolean=(item:string,searchedValue:string)=>item.toLowerCase().includes(searchedValue.toLowerCase()); @property({ attribute:false}) renderItemFunction: (item:any)=>string = (item:any)=>item; @property({ type: Array }) allMenuItems: any = []; @property({ type: Boolean, reflect: true }) enableVisibilityEffect = false; @property({ type: Boolean, reflect: true }) enableTabClose = false; @property({ type: Boolean, reflect: true, attribute: true }) noDropdownClose = false; @property({ type: String }) label = ''; @state() menuItems: any = []; protected async firstUpdated(_changed: PropertyValues) { await this.updateComplete; this.visibilityManager = new VisibilityManager({ host: this, target: this.inputElement.input, enableVisibilityEffect: this.enableVisibilityEffect, enableTabClose: this.enableTabClose, isOpen: () => this.isDropdownOpen, onAnchorOutOfView: () => { this.isDropdownOpen = false; this.dropdownElement?.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', }); }, onDocumentHidden: () => { this.isDropdownOpen = false; this.dropdownElement?.hide(); this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', }); }, emit: (event, detail) => this.emit(`nile-${event}`, detail), }); } connectedCallback() { super.connectedCallback(); this.renderItemFunction=(item:any)=>item; this.handleDocumentFocusIn = this.handleDocumentFocusIn.bind(this); this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); this.handleWindowResize = this.handleWindowResize.bind(this); this.handleWindowScroll = this.handleWindowScroll.bind(this); } disconnectedCallback() { super.disconnectedCallback(); this.removeOpenListeners(); this.visibilityManager?.cleanup(); this.portalManager.cleanupPortalAppend(); } protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (changedProperties.has('allMenuItems')){ this.menuItems = this.applyFilter(this.allMenuItems,this.filterFunction); this.setVirtualMenuWidth(); if (this.portal && this.isDropdownOpen) { this.portalManager.updatePortalOptions(); } } if (changedProperties.has('isDropdownOpen')) { this.menuItems = this.applyFilter(this.allMenuItems,this.filterFunction); this.handleDropdownOpenChange(); } if (changedProperties.has('value')){ this.menuItems = this.applyFilter(this.allMenuItems,this.filterFunction); if (this.portal && this.isDropdownOpen) { this.portalManager.updatePortalOptions(); } } if (changedProperties.has('portal')) { this.handlePortalChange(); } } @watch('portal', { waitUntilFirstUpdate: true }) handlePortalChange(): void { if (this.isDropdownOpen) { if (this.portal) { this.portalManager.setupPortalAppend(); } else { this.portalManager.cleanupPortalAppend(); } } } private handleDropdownOpenChange(): void { if (this.isDropdownOpen) { this.addOpenListeners(); this.visibilityManager?.setup(); if (this.portal) { this.portalManager.setupPortalAppend(); } } else { this.removeOpenListeners(); this.visibilityManager?.cleanup(); if (this.portal) { this.portalManager.cleanupPortalAppend(); } } } private addOpenListeners(): void { document.addEventListener('focusin', this.handleDocumentFocusIn); document.addEventListener('mousedown', this.handleDocumentMouseDown); if (this.portal) { window.addEventListener('resize', this.handleWindowResize); window.addEventListener('scroll', this.handleWindowScroll, true); } } private removeOpenListeners(): void { document.removeEventListener('focusin', this.handleDocumentFocusIn); document.removeEventListener('mousedown', this.handleDocumentMouseDown); window.removeEventListener('resize', this.handleWindowResize); window.removeEventListener('scroll', this.handleWindowScroll, true); } private handleDocumentFocusIn(event: FocusEvent) { if (!this.isDropdownOpen) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitDropdown = this.dropdownElement && path.includes(this.dropdownElement); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitDropdown && !hitPortalAppend) { this.isDropdownOpen = false; this.dropdownElement?.hide(); } } private handleDocumentMouseDown(event: MouseEvent) { if (!this.isDropdownOpen) return; const path = event.composedPath(); const hitSelf = path.includes(this); const hitDropdown = this.dropdownElement && path.includes(this.dropdownElement); const hitPortalAppend = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement); if (!hitSelf && !hitDropdown && !hitPortalAppend) { this.isDropdownOpen = false; this.dropdownElement?.hide(); } } private handleWindowResize = (): void => { this.portalManager.updatePortalAppendPosition(); }; private handleWindowScroll = (): void => { this.portalManager.updatePortalAppendPosition(); }; public render(): TemplateResult { const content=this.enableVirtualScroll?this.getVirtualizedContent():this.getContent(); return html` ${this.label ? html` `: nothing} ${this.loading?html``:nothing} ${this.menuItems.length > 0 && !this.loading ? content : nothing} `; } getVirtualizedContent():TemplateResult{ return html` ${virtualize({ items: this.menuItems, renderItem: (item:any):TemplateResult=>this.getItemRenderFunction(item), scroller:true })} ` } getContent():TemplateResult{ return html` ${this.menuItems.map((item: any) => this.getItemRenderFunction(item))} ` } getItemRenderFunction(item: any): TemplateResult { const value = this.renderItemFunction(item); let strValue = ""; if(value || typeof value === "number") { strValue = value.toString(); } const hasTooltip = !!item.tooltip; const shouldShowTooltip = hasTooltip && (!item.tooltip.for || item.tooltip.for === 'menu'); if (!shouldShowTooltip) { return html` ${unsafeHTML(strValue)} `; } let tooltipContent: string | null = null; const content = item.tooltip.content; if (content instanceof Promise) { tooltipContent = 'Loading...'; content.then((resolved: string) => { item.tooltip.content = resolved; this.requestUpdate(); }); } else { tooltipContent = content; } return html` ${unsafeHTML(strValue)} `; } handleSelect(event: CustomEvent) { this.value = event.detail.value; this.emit('nile-complete', { value: event.detail.value }); if (this.noDropdownClose) { this.isDropdownOpen = true; this.dropdownElement?.show(); } else { this.isDropdownOpen = false; this.dropdownElement?.hide(); } } private setVirtualMenuWidth() { const maxLengthOption = this.menuItems .reduce((acc: number, curr: any) => { const currLength = this.renderItemFunction(curr).length return acc > currLength ? acc : currLength }, 0) const defaultWith = 110; const pixelMultiplier = 9.5; const menuWidth = maxLengthOption * pixelMultiplier < defaultWith ? defaultWith : maxLengthOption * pixelMultiplier; this.style.setProperty("--virtual-scroll-container-width", menuWidth + "px"); } private handleSearch(event: CustomEvent) { this.value = event.detail.value; // Filter menu items based on the search value this.menuItems = this.applyFilter(this.allMenuItems,this.filterFunction); this.isDropdownOpen = this.menuItems.length > 0; if (this.isDropdownOpen) { this.dropdownElement?.show(); if (this.portal) { this.portalManager.updatePortalOptions(); } } } public handleFocus() { if (!this.openOnFocus) { return; } if(this.portal) { this.inputElement?.focus(); } // Delay opening the dropdown to allow focus to take effect setTimeout(() => { this.isDropdownOpen = true; this.dropdownElement?.show(); }, 300); } private handleClick() { this.isDropdownOpen = true; this.dropdownElement?.show(); } applyFilter(list: T[], filterFn: (item: T,searchValue?:string) => boolean): T[] { if(typeof(list)!=='object') return [] const res:T[]=[] list.forEach( el=> filterFn(el,this.value) && res.push(el) ) return res } } export default NileAutoComplete; declare global { interface HTMLElementTagNameMap { 'nile-auto-complete': NileAutoComplete; } }