[SalesForce] Lookup Field Dual Keyboard Focus (Answered with working Autocomplete lookup component and JS example for VF/SLDS)

The SLDS docs describe:

Lookups allow the user to have dual keyboard focus: while focus in the
input search field, the user can type text into the field and
simultaneously use arrow keys to navigate up and down the results
list.

However, for the life of me I cannot get this to work.

The question is: Do you have to do all the JS work yourself thus the statement above is false since it seems to state that it just happens.

A bit off topic but if someone has a link or canned JS to start this it would be great. (Not asking anyone to write is but if it is out there it sure would help a lot of people I would think)

Link do Doc: https://lightningdesignsystem.com/components/lookups/#flavor-advanced-modal – Section Accessability

MRR:

May be some artifacts in here but it should display at least

<div class="slds-form-element">

                    <label class="slds-form-element__label" for="plan_searchInput">Lookup Plan</label>

                    <div class="slds-form-element__control slds-input-has-icon slds-input-has-icon--right">

                        <input id="plan_searchInput" class="slds-input" type="text"
                               placeholder="Enter Plan Name"
                               aria-autocomplete="list"
                               autocomplete="off" role="combobox"
                               aria-expanded="true" aria-activedescendant="lookup-option-410"


                        />
                    </div>
                </div>

                <div class="slds-lookup__menu" role="listbox" id="plan_results" style="display: block;">
                    <!--Search Results UL-->
                    <ul class="slds-lookup__list" role="presentation" id="plan_results_ul"
                        style="max-height: 60px;">
                        <li role="presentation">
                            <span class="slds-lookup__item-action slds-lookup__item-action--label" id="lookup-option-409" role="option">
                              <span class="slds-truncate">item 1</span>
                            </span>
                        </li>
                        <li role="presentation">
                            <span class="slds-lookup__item-action slds-lookup__item-action--label" id="lookup-option-410" role="option">
                              <span class="slds-truncate">item 2</span>
                            </span>
                        </li>
                        <li role="presentation">
                            <span class="slds-lookup__item-action slds-lookup__item-action--label" id="lookup-option-411" role="option">
                              <span class="slds-truncate">item 3</span>
                            </span>
                        </li>
                    </ul>

                </div>
 </div>

Best Answer

Ok, So I have been working with this for a few days now and have built a component that can handle autocomplete, arrow up and down, highlighting, scrolling in the list, selections, enter key, etc. Posting my current solution here that others may find useful as all the examples out there did not work very well for me or had their own quirks like arrow keys not working etc.

Keep in mind I am no JS developer so it may or may not be rough but for now it works.

Component allows for search of Name field by default but you can pass in additional fields and add a replaceable subtext to display in the search results for example instead of showing just "John Doe" you can show "John Doe (Bank of America)" to provide more context to the user

Note This page is using a VF template that wraps the SLDS requirements for VF and includes the jQuery library and the SLDS CSS file. You can adjust the page to pop it in your own template or page with similar stuff

Visualforce Component

<apex:attribute name="UniqueIdentifier" description="The identifier for this input" type="String" required="true"/>
<apex:attribute name="searchObjectAPIName" description="The API Name of the sObject being searched" type="String"
                required="true"/>
<apex:attribute name="FieldToUpdate" type="SObjectField" description="The field to store the selected value"
                required="true"/>
<apex:attribute name="AdditionalFields" type="String[]" description="Additional Fields to search" required="false"
                default="[]"/>
<apex:attribute name="AddFieldsSubText" type="String"
                description="The Text to display in search results after the name. Can use %0 etc to merge addfields index values. Merge value will be the vale at the addFields Index specified in the string"
                required="false" default=""/>
<apex:attribute name="onSetID" description="Name of JS function to call when value is selected" type="string"
                required="true"/>

<apex:includeScript value="{!URLFOR($Resource.SLDS_Assets,'/js/remoteAutoComplete.js')}" loadOnReady="true"/>

<script>
    var debugMode = '{!$CurrentPage.parameters.debug}' == 'true';
    function onSetId(optionsUsed, selectedRecordId) {

        if (debugMode) console.log(optionsUsed);

        if (typeof window["{!onSetID}"] != 'undefined') window["{!onSetID}"](optionsUsed, selectedRecordId);
    }
</script>

<div id="{!UniqueIdentifier}_input" class="slds-form-element">

    <label class="slds-form-element__label" for="{!UniqueIdentifier}_searchInput">Lookup Plan</label>

    <div class="slds-form-element__control slds-input-has-icon slds-input-has-icon--right"
         xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <!-- xmlns added here so that rerender does not break with the svg. Otherwise a rerender would cause the page to stall -->
        <svg aria-hidden="true" class="slds-input__icon slds-icon-text-default">
            <use xlink:href="{!URLFOR($Resource.SLDS_Assets,'/assets/icons/utility-sprite/svg/symbols.svg#search')}"></use>
        </svg>

        <input id="{!UniqueIdentifier}_searchInput" class="slds-input" type="text"
               placeholder="Enter {!UniqueIdentifier} Name"
               onkeyup="findRecords(event, '{!searchObjectAPIName}', $(this),'{!UniqueIdentifier}_searchText', '{!UniqueIdentifier}_id_div' ,'{!UniqueIdentifier}_results', {!AdditionalFields}, '{!AddFieldsSubText}')"
               onkeydown="return searchInputKeyDown(event, '{!UniqueIdentifier}_searchInput','{!UniqueIdentifier}_results');"
               onblur="//toggleAutocompelteResults('plan_results',false);"
               aria-autocomplete="list"
               autocomplete="off" role="combobox"
               aria-expanded="false" aria-activedescendant=""


        />

        <div class="slds-lookup__menu" tabindex="1" role="listbox" id="{!UniqueIdentifier}_results"
             style="display:none;">
            <div id="{!UniqueIdentifier}_fi" class="slds-lookup__item"
                 xmlns="http://www.w3.org/2000/svg"
                 xmlns:xlink="http://www.w3.org/1999/xlink">
                <button class="slds-button" type="button">
                    <svg aria-hidden="true"
                         class="slds-icon slds-icon-text-default slds-icon--small">
                        <use xlink:href="{!URLFOR($Resource.SLDS_Assets,'/assets/icons/utility-sprite/svg/symbols.svg#search')}"></use>
                    </svg>
                    <span id="{!UniqueIdentifier}_searchText"> </span>
                </button>
            </div>

            <!--Search Results UL-->
            <ul class="slds-lookup__list" role="listbox" id="{!UniqueIdentifier}_results_ul"
                style="max-height: 80px;">
            </ul>

        </div>

    </div>

    <div id="{!UniqueIdentifier}_id_div">
        <apex:inputHidden value="{!FieldToUpdate}"/>
    </div>

</div>

JavaScript Code

/**
 * @desc Prevents the enter key behavior when in the lookup element unless we have a selected item, if so then trigger click of that li
 * @param e
 * @param resultsEle
 * @returns {boolean}
 */
function preventEnter(e, resultsDiv, callback) {

    if (e.which == 13 || e.keyCode == 13) {

        var that = resultsDiv;

        if (that.find('li.selected').length != 0) {
            that.find('li.selected').trigger('click');
            that.find('li:not(:last-child).selected').removeClass('active selected slds-theme--shade');

            toggleAutocompelteResults(that.prop('id'), e.target.id, false);
        }

        return false;
    }

    return callback();
}

/**
 * @desc Handles the KeyDown events of the search input. Ignores enter key
 * @param e
 * @param inputEleId
 * @param resultsEleId
 * @returns {boolean}
 */
function searchInputKeyDown(e, inputEleId, resultsEleId) {
    var resultsDiv = $('#' + resultsEleId);

    return preventEnter(e, resultsDiv , function () {

        var searchEle = $('#' + inputEleId);
        var results_ul = $('#' + resultsEleId + '_ul');
        var moveTo;
        var key = 'which' in e ? e.which : e.keyCode;

        if(debugMode) console.log(key);
        //If Tab key pressed and lookup results visible then prevent the event
        if (key == 9) {
            if ($('#' + resultsEleId).is(':visible')) {
                e.preventDefault();
                return false;
            }
        }

        //If keyPress is the up or down arrow
        if (key == 40 || key == 38) {
            e.preventDefault(); //block default action

            var that = $('#' + resultsEleId);

            if (that.find('li:not(.nosel)').length != 0) { //Do not move to elements that have nosel class

                if (that.find('li.selected').length == 0) { //If a previously selected value set scroll to there
                    moveTo = $(that.find('li')[0]).addClass('active selected slds-theme--shade');
                } else {

                    switch (key) {
                        case 40: //Down key
                            moveTo = that.find('li:not(:last-child).selected'); //Find the first item below the current selected one
                            moveTo.removeClass('active selected slds-theme--shade').next().addClass('active selected slds-theme--shade');
                            break;
                        case 38: //Up key
                            moveTo = that.find('li:not(:first-child).selected'); //Find the first item above the current selected one
                            moveTo.removeClass('active selected slds-theme--shade').prev().addClass('active selected slds-theme--shade');
                    }

                }

                if (moveTo.length != 0) { //if we have moveTo LI then move to it

                    var liOffset = $(moveTo).innerHeight(); //Size of the LI's. Only works if all elements in the list are same size
                    var selIdx = results_ul.find('li.selected').index(); //Find the position idx of the currently selected item

                    if (key == 38) { //If moving UP need to subtract the distance to travel by 1 step
                        console.log('removing 1');
                        selIdx -= 1;
                    }

                    //Animate the scroll
                    results_ul.animate({
                        scrollTop: liOffset * selIdx
                    }, 300);

                    $(searchEle).val(results_ul.find('li.selected').attr('data-recordname'));
                }

                $(searchEle).attr('aria-activedescendant', results_ul.find('li.selected').prop('id'));

            }


        }
        return true;
    });


}

/**
 * @desc
 * @param resultsDivId
 * @param searchTextInput
 * @param show
 */
function toggleAutocompelteResults(resultsDivId, searchTextInput, show) {
    //return;
    var ele = $('#' + resultsDivId);

    if(debugMode || false) console.log($(ele).is(':visible') + ' - ' + show);

    if (show && $(ele).is(':visible') == false) {
        $(ele).toggle(show);
        $('#' + resultsDivId + '_ul').scrollTop(0);
    } else if (show == false && $(ele).is(':visible') == true) {
        $(ele).toggle(show);
    }

    $('#' + searchTextInput).attr('aria-activedescendant', '');
    ele.attr('aria-expanded', $(ele).is(':visible'));

}

/**
 * @desc Find records and populate results based on search element value and remote database lookup
 * @param e event object
 * @param objName the API name of the object we will be searching
 * @param searchInputEle The search input dom element
 * @param searchTextId The element Id of the first div in the results list that shows what is being searched
 * @param selectedIdEle The element Id of the hidden input where the selected VALUE will be put
 * @param resultsDivId the Id of the Result Listing <div> - Contains the <ii> results
 * @param optionalFields An array of optional fields to search on
 * @param optionFieldsSubtext The text to display in the results using merge fields to populate based on the optional fields array
 * @returns {boolean}
 */
function findRecords(e, objName, searchInputEle, searchTextId, selectedIdEle, resultsDivId, optionalFields, optionFieldsSubtext) {

    var key = 'which' in e ? e.which : e.keyCode;

    if (key == 13) return false;

    var searchVal = searchInputEle.val();
    var inputEleId = $(searchInputEle).prop('id');

    if (key == 27 || searchVal == '' || searchVal.length < 2) {
        toggleAutocompelteResults(resultsDivId, inputEleId, false);
    } else if (key != 40 && key != 38) {

        if (key != 9) {
            $('[id$=' + selectedIdEle + '] input').val(''); //Clear the selected ID value if not enter (prevented above) or tab
            //Possible trouble spot. Could comment out if needed
            onSetId(); //If we change then update the value to the parent page
        }

        $('#' + searchTextId).text('Searching for "' + searchVal + '"');


        var addFields = optionalFields || [];

        remoteRecordLookup(searchVal, objName, addFields, addFields, function (result, searchTerm) {
            var records = result;

            var resultsDiv_li_rows = "";
            var regex = new RegExp('(' + searchTerm + ')', 'gi');
            var elementValues = [];

            if(debugMode) console.log(searchVal); //debug

            if (records.length > 0) {

                for (var i = 0; i < records.length; i++) {

                    if(debugMode) console.log(records[i]);

                    var recName = records[i].Name || '';

                    if(debugMode) console.log(recName);

                elementValues.push({
                    selectedRecordId : records[i].Id,
                    selectedRecordName : records[i].Name,
                    resultsEleId: resultsDivId,
                    searchInputEleId: inputEleId,
                    idEleId: selectedIdEle
                });


                    if(typeof optionFieldsSubtext != 'undefined' && optionFieldsSubtext.length > 0){
                        var subText = optionFieldsSubtext;

                        if(addFields.length == 0){
                            recName += ' (' + subText + ')';
                        }else{
                            recName += ' (';

                            for (var idx=0;idx<addFields.length;idx++) {
                                var regExp = new RegExp('%' + idx,'gi');

                                var replaceValue = records[i];
                                var fldID = addFields[idx].split('\.');

                                for (var idIdx=0;idIdx<fldID.length;idIdx++){
                                    replaceValue = replaceValue[fldID[idIdx]];
                                }

                                if(debugMode) console.log(replaceValue);

                                subText = subText.replace(regExp,replaceValue);

                                if(debugMode) console.log(subText);
                            }

                            recName += subText + ')';
                        }
                    }

                    resultsDiv_li_rows += '<li tabindex="1" id="v' + records[i].Id +
                        '" role="presentation" class="slds-lookup__item" data-recordname="' + records[i].Name + '">' +
                        '<span class="slds-lookup__item-action slds-media slds-media--center" role="option">' +
                        '<div class="slds-media__body">' +
                        '<div class="slds-lookup__result-text">' + recName.replace(regex, "<mark>$1</mark>") + '</div>' +
                        '</div>' +
                        '</span>';



                }
            } else {
                resultsDiv_li_rows = '<li class="slds-lookup__item nosel">No Records Found</li>';
            }

            $('#' + resultsDivId + '_ul').html(resultsDiv_li_rows);

            $('#' + resultsDivId + '_ul li').each(function(idx, ele){
                console.log('Registering Event on: ' + ele);

                $(ele).on('click',function(){itemSelected(elementValues[idx]);});
            });

            toggleAutocompelteResults(resultsDivId, inputEleId, true);
        });

    } else {
        e.preventDefault(); //stop up and down arrow
    }

    return true;
}

/**
 * @desc Javascript Remoting for Visualforce to find records according to the options passed
 * @param q The text we are searching for
 * @param objName The sObject we are searching on
 * @param addFields (Optional) Additional fields to search on - Defaults to the Name field only
 * @param searchOn (Reserved)
 * @param onComplete Callback when done (results, q)
 */
function remoteRecordLookup(q, objName, addFields, searchOn, onComplete) {

    Visualforce.remoting.Manager.invokeAction(
        'Remote_Global.autoCompleteSearch',
        q,
        objName,
        addFields,
        addFields, //could useSearchOn to limit the fields to additionally search on
        function (result, event) {
            if (event.type == 'exception') {
                alert(event.message);
            } else {
                onComplete(result, q);
            }

        }, {buffer: true}
    );

}

/**
 * @desc Sets the value of the hidden input, closes the results list, and updates the value of the search input with the selected value
 * @param options
 * @param callback
 */
function itemSelected(options) {

    console.log(options);
     console.log(options["selectedRecordId"], options["selectedRecordName"]);
    $('#' + options["resultsEleId"]).fadeOut();
    $('#' + options["searchInputEleId"]).val(options["selectedRecordName"]);
    $('[id$=' + options["idEleId"] + '] input').val(options["selectedRecordId"]);

    onSetId(options, options["selectedRecordId"]);

}

Visualforce Remoteing

global class Remote_Global {

    public Remote_Global(Object con){}
    public Remote_Global(ApexPages.StandardController con){}

    @RemoteAction
    global static sObject[] autoCompleteSearch(String searchTerm, String sObjectName, String[] addFields, String[] searchOn) {

        //TODO: create utility method to get Display type that handles relationships

        sObject searchObject = (sObject) type.forName(sObjectName).newInstance();

        Map<String,Schema.SObjectField> flds = searchObject.getsObjectType().getDescribe().fields.getMap();
        //using string.escapeSingleQuotes on the search term causes VF remoting errors as the query string becomes messed up. Not using it and including single quotes in the search term do not seem to affect it. not tested for XXS vulnerability
        String srchString = '%' + searchTerm + '%';

        String query = 'Select ID, Name';

        if(addFields != null && !addFields.isEmpty())
            query += ', ' + string.join(addFields,',');

        query += ' From ' +
                sObjectName + ' Where ';

        if(searchOn == null || searchOn.isEmpty()){
            query += 'Name Like :srchString Order by Name ASC';
        }else{
            String[] likeClause = New String[]{'(Name Like :srchString)'};

            for(String s : searchOn){
                if(s == 'Account.Name' || flds.containsKey(s) && flds.get(s).getDescribe().getType() == Schema.DisplayType.STRING)
                    likeClause.add('(' + s + ' Like :srchString)');
            }

            query += string.join(likeClause, ' OR ' );
            query += ' Order by Name ASC';
        }
        system.debug(query);

        return database.query(query);

    }


}

Example Containing Page (usage not requiring a VF controller)

<apex:page id="dummyTestPage" standardController="Account" extensions="Remote_Global" standardStylesheets="false"
           showHeader="false"
           sidebar="true" applyHtmlTag="false" applyBodyTag="false" docType="html-5.0" cache="false">

    <html xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
    <apex:includeScript value="{!URLFOR($Resource.SLDS_Assets,'/js/remoteAutoComplete.js')}" loadOnReady="true"/>

    <script>
        function whenDone() {
            rrm();
        }
    </script>
    <apex:composition template="SLDS_Template">


        <apex:define name="body">
            <apex:form>
                <apex:actionFunction name="rrm" reRender="post_processing">

                </apex:actionFunction>

                <c:RemoteAutoComplete UniqueIdentifier="Account" searchObjectAPIName="Contact"
                                      FieldToUpdate="{!Account.Name}"
                                      AdditionalFields="['Account.Name','Account.BillingStreet']"
                                      AddFieldsSubText="%0 was the account and %1 was the street"
                                      onSetId="whenDone"/>

                <c:RemoteAutoComplete UniqueIdentifier="Street" searchObjectAPIName="Contact"
                                      FieldToUpdate="{!Account.BillingStreet}"
                                      AdditionalFields="['Account.Name','Account.BillingStreet']"
                                      AddFieldsSubText="%0 was the account and %1 was the street"
                                      onSetId="whenDone"/>

            </apex:form>
        </apex:define>

    </apex:composition>
    <apex:outputPanel id="post_processing">
        <script>
            console.log('Results {!Account.Name}');
            console.log('Results2 {!Account.BillingStreet}');
        </script>
    </apex:outputPanel>

    </html>
</apex:page>

The page shows an example of how to use the above. The page will rerender the post_processing to show that what was selected is actually updated in the parent page

Disclaimer Now, there are some bad practices in here and that is what I will be working on next but for now this works, and I believe works well.

Starting out Initial Load

Autocomplete on type with the subtext, part in ( ), populated Default accounts in DE org have the entire address in the BillingStreet by default which is why the entire address is showing for the street. Autocomplete

Using arrow key to navigate On down arrow

Console output to show parent page updated field with value selected Console output

Related Topic