LWC Lookup - Reusable lightning lookup component

Salesforce has built many read-to-use components in LWC/Aura frameworks like datatable, input etc. But, we don't find some ready-to-use components like lookup, modal which we often use in our custom component development. This post is to cover one of such components and not available as standard lwc component, i.e., lookup.


This is a simple and reusable lookup component which can be used on any lightning web/aura components and even on visualforce pages.

lookup.html

<template>
    <div class={formElementClass}>
        <div if:false={isLabelHidden}>
            <label class="slds-form-element__label" for="combobox-input">
                <template if:true={_required}>
                    <abbr class="slds-required vk-cs-required" title="required">🞲</abbr>
                </template>
                {label}
            </label>
            <lightning-helptext if:true={showHelpText} content={_helpText}></lightning-helptext>
        </div>
        <div class="slds-form-element__control">
            <div class={cbContainerClassClass}>
                <div class={cbComputedClass} aria-expanded={isAriaExpanded} aria-haspopup="listbox" role="combobox" onclick={handleTriggerClick}>
                    <div class={cfeComputedClass} role="none">
                        <span if:true={isOptionSelected}>
                            <span if:true={hasIcon} class={entityIcon.class} title={entityIcon.title}>
                                <svg class={entityIcon.iconClass} aria-hidden="true">
                                    <use xlink:href={entityIcon.url}></use>
                                </svg>
                                <span class="slds-assistive-text">{entityIcon.title}</span>
                            </span>
                        </span> 
                        <input id="combobox-input" 
                            type="text" 
                            role="textbox" 
                            value={selectedOption.label} 
                            readonly={_readOnly} 
                            required={_required} 
                            placeholder={_placeHolder} 
                            autocomplete="off"
                            aria-controls="listbox-id" 
                            class={inputClass}
                            onkeyup={handleKeyUp} 
                            onfocus={handleFocus}
                            onblur={handleBlur} />
                        <div class="slds-input__icon-group slds-input__icon-group_right">
                            <span if:false={isOptionSelected} class="slds-icon_container slds-icon-utility-search slds-input__icon slds-input__icon_right" title="Search">
                                <svg class={searchIconClass} aria-hidden="true">
                                    <use xlink:href="/_slds/icons/utility-sprite/svg/symbols.svg#search"></use>
                                </svg>
                                <span class="slds-assistive-text">Search</span>
                            </span>
                            <button if:true={isOptionSelected} class="slds-button slds-button_icon slds-input__icon slds-input__icon_right" title="Remove selected option" onclick={removeSelectedOption}>
                                <svg class={closeIconClass} aria-hidden="true">
                                    <use xlink:href="/_slds/icons/utility-sprite/svg/symbols.svg#close"></use>
                                </svg>
                                <span class="slds-assistive-text">Remove selected option</span>
                            </button>
                        </div>
                    </div>
                    <div id="listbox-id" role="listbox" class="slds-dropdown slds-dropdown_length-5 slds-dropdown_fluid" style="margin-top: 0;"
						onmousedown={handleDropdownMouseDown} onmouseup={handleDropdownMouseUp} onmouseleave={handleDropdownMouseLeave}>
                        <ul class="slds-listbox slds-listbox_vertical" role="presentation">
                            <template for:each={optionsToDisplay} for:item="option">
                                <li role="presentation" class={option.outerClass} key={option.index} id={option.index} onclick={handleSelection}>
                                    <div class={option.innerClass} role="option">
                                        <span if:true={hasIcon} class="slds-media__figure slds-listbox__option-icon">
                                            <span class="slds-icon_container">
                                                <lightning-icon icon-name={iconName} size={_iconSize} class="slds-icon-text-default"></lightning-icon>
                                            </span>
                                        </span>
                                        <span class="slds-media__body">
                                            <span class="slds-listbox__option-text slds-listbox__option-text_entity">
                                                {option.label}
                                            </span>
                                        </span>
                                    </div>
                                </li>
                            </template>
                            <li if:true={noResultsFound} class="slds-p-left_small">&#9785; No results found</li>
                        </ul>
                    </div>
                </div>
            </div>
            <div if:true={showMessage} role="alert" class="slds-form-element__help slds-is-absolute">{errorMessage}</div>
        </div>        
    </div>
</template>

lookup.js

import { LightningElement, track, api } from 'lwc';

const formElement = 'slds-form-element';
const hasError = ' slds-has-error';
const cbContainerClass = 'slds-combobox_container';
const hasSelection = ' slds-has-selection';
const cbClass = 'slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click';
const openMenu = ' slds-is-open';
const cbForm = 'slds-combobox__form-element slds-input-has-icon';
const hasIconRight = ' slds-input-has-icon_right';
const hasIconBothSides = ' slds-input-has-icon_left-right';
const optionOuterClass = 'slds-listbox__item';
const optionInnerClass = 'slds-media slds-media_center slds-listbox__option slds-listbox__option_entity slds-listbox__option_has-meta';
const entityIconClass = 'slds-icon_container slds-combobox__input-entity-icon';
const inputClass = 'slds-input slds-combobox__input slds-combobox__input-value';
const initOption = {value:'',label:''};

export default class Lookup extends LightningElement {
    @api label;
    @api value;
    @api disabled; 
    @api errorMessage = 'Complete this field';
    @api isTableElement = false;

    @track _iconName;
    @track _iconSize = 'x-small';
    @track _readOnly;
    @track _required;
    @track _placeHolder = 'Search & Select...';
    @track _variant = 'label-stacked'; //label-inline, label-hidden, and label-stacked
    @track _helpText;
    @track showHelpText = false;
    @track title;
    @track isLabelHidden = false;
    @track showMessage = false;
    @track isAriaExpanded = false;
    
    @track optionsToDisplay = [];
    @track selectedOption = {...initOption};
    @track alwaysOption = {...initOption};
    @track isOptionSelected = false;
    @track noResultsFound = false;

    @track formElementClass = formElement;
    @track cbContainerClassClass = cbContainerClass;
    @track cbComputedClass = cbClass;
    @track cfeComputedClass = cbForm+hasIconRight;
    @track entityIcon = {};
    @track searchIconClass;
    @track closeIconClass;

    _formElement = formElement;
    _cbContainerClass = cbContainerClass;
    _inputHasFocus = false;
    _cancelBlur = false;
    _options = [];
    
    @api set options(value){
        if(!value) return;
        let i=0;
        value.forEach(opt => {
            let obj = {...opt};
            obj.index = i++;
            if(this.value && this.value === obj.value){
                this.selectedOption = {...obj};
            }else if(obj.isDefault){
                this.selectedOption = {...obj};
            }else if(obj.showAlways){
                this.alwaysOption = obj;
            }
            obj.outerClass = optionOuterClass;
            obj.innerClass = optionInnerClass;
            this.optionsToDisplay.push(obj);
            this._options.push(obj);
        });
	}
	get options(){
		return this._options;
    }
    
    @api set iconName(value){
        this._iconName = value ? value : '';
        this.hasIcon = value ? true : false;
        let iconType = value.split(':')[0];
        let iconName = value.split(':')[1]
        this.entityIcon.title = iconName;
        this.entityIcon.class = entityIconClass+' slds-icon-'+iconType+'-'+iconName;
        this.entityIcon.url = '/_slds/icons/'+iconType+'-sprite/svg/symbols.svg#'+iconName;
    }
    get iconName(){
        return this._iconName;
    }

    @api set iconSize(value){
        this._iconSize = value ? value : 'x-small';
    }
    get iconSize(){
        return this._iconSize;
    }

    @api set required(value){
        this._required = value ? value : false;
    }
    get required(){
        return this._required;
    }

    @api set readOnly(value){
        this._readOnly = value ? value : false;
    }
    get readOnly(){
        return this._readOnly;
    }

    @api set placeHolder(value){
        this._placeHolder = value ? value : this._placeHolder;
    }
    get placeHolder(){
        return this._placeHolder;
    }

    @api set variant(value){ //label-inline, label-hidden, and label-stacked
        this._variant = value ? value : 'label-stacked';
        this.isLabelHidden = value === 'label-hidden';
        this._formElement = value === 'label-inline' ? formElement+' slds-form-element_horizontal' : formElement+' slds-form-element_stacked';
        this.formElementClass = this._formElement;
    }
    get variant(){
        return this._variant;
    }

    @api set helpText(value){
        this._helpText = value;
        this.showHelpText = value ? true : false;
    }
    get helpText(){
        return this._helpText;
    }

    get inputElement() {
        return this.template.querySelector('input');
    }

    @api
    focus(){
        if(this._connected){
            this.inputElement.focus();
        }
    }

    @api
    blur() {
        if(this._connected) {
            this.inputElement.blur();
        }
    }

    //Called after the component finishes inserting to DOM
    connectedCallback() {
        this._connected = true;
        this.hasIcon = this.iconName ? true : false;
        if(this.isTableElement) this.isLabelHidden = true;
        this._cbContainerClass += ' '+this._iconSize;
        this.cbContainerClassClass = this._cbContainerClass;
        this.entityIcon.iconClass = 'slds-icon slds-icon_'+this._iconSize;
        this.searchIconClass = 'slds-icon slds-icon-text-default slds-icon_'+this._iconSize;
        this.closeIconClass = 'slds-button__icon slds-icon_'+this._iconSize;
        this.inputClass = inputClass+' '+this._iconSize;
        if(this.value) this.showSelectedOption(this.selectedOption);
    }

    disconnectedCallback() {
        this._connected = false;
    }

    handleTriggerClick(event) {
        event.stopPropagation();
        this._cancelBlur = false;

        if(!this.isOptionSelected && !this._readOnly && !this.disabled && !this.noResultsFound){
            this.openOptionsMenu();
            this.inputElement.focus();
        }
    }

    openOptionsMenu(){
        if(this._options.length > 0)
            this.optionsToDisplay = [...this._options];        

        this.isAriaExpanded = true;
        this.cbComputedClass = cbClass+openMenu;
    }

    handleFocus() {
        //this.openOptionsMenu();
        this._inputHasFocus = true;
        this.dispatchEvent(new CustomEvent('focus'));
    }    
	
	handleBlur() {
        if(this._cancelBlur) return;
        this._inputHasFocus = false;
        this.closeOptionsMenu();

        if(this.required && !this.selectedOption.value){
            this.formElementClass += hasError;
            if(!this.isTableElement){
                this.showMessage = true;
                this.errorMessage = 'Complete this field';
            }            
        }
        this.dispatchEvent(new CustomEvent('blur'));
    }

    closeOptionsMenu(){
        this.isAriaExpanded = false;
        this.cbComputedClass = cbClass;
    }
    
    handleKeyUp(event) {
        if(event.target.readOnly) return;

        this.showMessage = false;
        this.noResultsFound = false;
        let searchKey = event.target.value;

        if(searchKey){            
            this.selectedOption.label = searchKey;
            if(this.cbComputedClass !== cbClass+openMenu) 
                this.cbComputedClass = cbClass+openMenu; //To show the options dropdown

            //Set the options to be displayed with search results
            searchKey = searchKey.toLowerCase();            
            this.optionsToDisplay = this._options.filter(opt=>opt.label.toLowerCase().includes(searchKey));

            if(this.alwaysOption.value != '')
                this.optionsToDisplay.push(this.alwaysOption);
            else if(this.optionsToDisplay.length === 0)
                this.noResultsFound = true;
        }else{
            //this.closeOptionsMenu();
            this.selectedOption = {...initOption};
            this.optionsToDisplay = this._options;
        }        
    }

    //DROPDOWN EVENTS	
	handleDropdownMouseDown(event){
        const mainButton = 0;
        if(event.button === mainButton){
            this._cancelBlur = true;
        }
    }	
	handleDropdownMouseUp() {
        this._cancelBlur = false;
    }

    handleSelection(event){
        event.stopPropagation(); //To stop event propagation up to a parent element

        this._cancelBlur = true;
        let obj = {...initOption};

        let childElm = event.target;
        let parentElm = childElm.parentNode;
        while(parentElm.tagName != 'LI') parentElm = parentElm.parentNode;
        let index = parentElm.id.split('-')[0]; //It will be in the format of option id-some number

        if(Number(index) < this._options.length)
            obj = this._options[index]; //Get the selected option details based on the id value of the object

        //Send to parent component only when selected option has a value
        if(!obj.value) return;
        this.showSelectedOption(obj);
        
        this.dispatchEvent(new CustomEvent('change', {detail: this.selectedOption})); //Dispatch change event
    }

    showSelectedOption(obj){
        this.selectedOption.value = obj.value;
        this.selectedOption.label = obj.label;
        this.isOptionSelected = true; //When it is true, it shows the option icon and close button
        this.formElementClass = this._formElement;
        this.cbContainerClassClass += hasSelection; //Add styles to the selected option
        this.cbComputedClass = cbClass; //To close the Options dropdown
        this.cfeComputedClass = cbForm;
        this.cfeComputedClass += this.hasIcon ? hasIconBothSides : hasIconRight; //Handles the option icon and close icon
        this._readOnly = true; //To avoid the input box editable
        this.showMessage = false;
    }

    removeSelectedOption(){
        this.selectedOption = {...initOption}; //Reset the selected option
        this.isOptionSelected = false; //To show search box
        this._readOnly = false; //Make search box editable
        this.cbContainerClassClass = this._cbContainerClass; //Reset the search box style
        this.cfeComputedClass = cbForm+hasIconRight; //To show the search icon only
        this.dispatchEvent(new CustomEvent('change', {detail: this.selectedOption})); //Dispatch change event
    }

}

lookup.css

.xx-small .slds-icon_container.slds-combobox__input-entity-icon {
    left: 0 !important;
    width: 1.1rem;
    height: 1.1rem;
}
.slds-combobox__input-entity-icon .slds-icon_xx-small{
    width: 1.1rem;
    height: 1.1rem;
}
[class*=slds-input-has-icon_left] .xx-small.slds-combobox__input.slds-combobox__input-value{
    padding-left: 1.5rem !important;
}
.slds-listbox_vertical .slds-listbox__option_has-meta .slds-media__figure{
    margin-top: 0;
}
.slds-combobox_container.slds-has-selection .slds-combobox__input-value, 
.slds-combobox_container.slds-has-selection .slds-combobox__input-value:focus {
    box-shadow: none;
}
.cursor-not-allowed {
    cursor: not-allowed;
}
.pointer-not-allowed {
    pointer-events: none;
}
.slds-form-element_horizontal .slds-form-element__label{
    padding-top:.5rem;
}
.vk-cs-required{
    vertical-align: top;
    font-size: 8px;
}

Now, let's reuse the above lookup component as many times as we want. In the below lookupHolder lwc component, I have added it two times, one works as Account lookup and another works as Opportunity lookup.

lookupHolder.html

<template>
    <lightning-card title="Lookup Component">
        <lightning-layout vertical-align="center">
            <lightning-layout-item size="4" padding="around-small">
                <c-lookup if:true={showAccLookup} label="Account" options={accounts} required icon-name="standard:account" 
                place-holder="Search Accounts" help-text="Select an account from the list" onchange={handleAccSelection}></c-lookup>
                <br/>Selected Account Id: {selectedAccId}
            </lightning-layout-item>
            <lightning-layout-item size="4" padding="around-small">
                <c-lookup if:true={showOppLookup} label="Opportunity" options={opps} icon-name="standard:opportunity"
                place-holder="Search Opportunities" onchange={handleOppSelection}></c-lookup>
                <br/>Selected Opportunity Id: {selectedOppId}                
            </lightning-layout-item>
        </lightning-layout>
    </lightning-card>
</template>

lookupHolder.js

import { LightningElement, track, wire } from 'lwc';
import getAccounts from '@salesforce/apex/AccountController.getAccountList';
import getOpportunities from '@salesforce/apex/AccountController.getOpportunities';

export default class LookupHolder extends LightningElement {
    @track error;
    @track accounts = [];
    @track showAccLookup = false;
    @track opps = [];
    @track showOppLookup = false;
    @track selectedAccId;
    @track selectedOppId;

    @wire(getAccounts)
    wAccs({error,data}){
        if(data){
            for(let i=0; i<data.length; i++){
                let obj = {value: data[i].Id, label: data[i].Name};
                this.accounts.push(obj);
            }
            this.showAccLookup = true;
        }else{
            this.error = error;
        }       
    }
    @wire(getOpportunities)
    wOpps({error,data}){
        if(data){
            for(let i=0; i<data.length; i++){
                let obj = {value: data[i].Id, label: data[i].Name};
                this.opps.push(obj);
            }
            this.showOppLookup = true;
        }else{
            this.error = error;
        }       
    }
 
 //On Account lookup change
    handleAccSelection(event){
        let selectedOption = event.detail;
        this.selectedAccId = selectedOption.value;
    }
 
 
 //On Opportunity lookup change
    handleOppSelection(event){
        let selectedOption = event.detail;
        this.selectedOppId = selectedOption.value;
    }
}

Click here to install this lookup component to your developer org.
Credits: Thanks to salesforce for bringing lwc open source project. This component is created with references from combobox.
PS: Thanks for checking this, hope this helps! Please share your feedback/suggestions.

6 Comments

  1. Neat code. So close to standard. thanks

    ReplyDelete
  2. Hi Venky,
    This is awesome. How can you prepopulate a Record from the lookupHolder? For example, I have an existing account that I want to have it prepopulated when the component launches, if the account is not there then I'd like to search for one. Is that possible?

    Thanks in advance!

    ReplyDelete
    Replies
    1. Hey Connan, thanks! Try to pass the id of the record for value attribute. Eg: . Please make sure the record id you are passing, should be in the options list.

      Delete
  3. Hi Venky,
    Thanks a lot! for sharing these examples is very helpful.

    ReplyDelete
  4. Please upload class

    ReplyDelete
Post a Comment
Previous Post Next Post