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`
`
}
getContent():TemplateResult{
return html`
`
}
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`
`;
}
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;
}
}