Lookup Field Dual Keyboard Focus (Answered with working Autocomplete lookup component and JS example for VF/SLDS)
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
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.
Using arrow key to navigate
Console output to show parent page updated field with value selected