Introduction
So far in this tutorial we've covered a lot of ground, but not that much to even constitute a half proportion. We still have a lot to cover and appreciate in the world of autocompleters.
Talking about this chapter, we'll explore a particularly interesting thing in here - how to customise the content of the suggestions of an autocompleter.
Specifically, we'll see how to add multiple bits of information to every suggestion (such as the price of the food item), and then modify a couple of code blocks to make everything work as usual.
What customisation?
When it comes to customising the suggestions of our autocompleter, a handful of things come to mind - is the customisation related to the style of the suggestions, or related to their markup.
In this case, the customisation is related to their content.
It isn't a necessity that every autocompleter on this planet has to show one-liner suggestions like 'Pizza'
, 'Pasta'
etc. That is, there is no sort of rule that tells us that suggestions couldn't include other information, such as a food item's price in addition to its name.
Let's take the example of our old food items list.
We've surely been seeing suggestions that contain only the name of the matching food item such as 'Pizza'
, 'Pasta'
, as shown below:
What if each suggestion also displays the price of the food item next to it, as shown below:
This is what the term 'customisation' represents here - each suggestion gets to show whatever we decide to show in it.
In the following sections, we'll create a customised autocompleter for a few HTML entities along with their symbols.
Creating a list
First of, we'll need a list of all the desirable HTML entites along with their symbols that we can use in our autocompleter.
Now it doesn't sound sensible at all to create this list using an array of strings, where each string contains the name of the HTML entity and its symbol (though, we could do this!):
var list = [
'Logical AND ∧',
'Logical OR ∨',
'Logical XOR ⨁',
'Universal Quantifier ∀',
'Existential Quantifier ∃',
'Summation ∑',
'Apostrophe '',
/* ... */
]
∧
. If we were using a string such as 'Logical AND ∧'
to represent each suggestion item, accomplishing this would be extremely difficult!Rather what sounds sensible is to create the list as an array of objects where each object represents a given HTML entity; holding its name and symbol.
The benefit of using objects is that we can assign properties to them to hold respective information of a given suggestion. As we'll see in the Autocomplete Grouping and Autocomplete Sorting chapters, these properties can be used to accordingly group the items list into different categories, sort it and much more.
Consider the following code:
var list = [
{name: 'Logical AND', code: '∧'},
{name: 'Logical OR', code: '∨'},
{name: 'Logical XOR', code: '⨁'},
{name: 'Universal Quantifier', code: '∀'},
{name: 'Existential Quantifier', code: '∃'},
{name: 'Summation', code: '∑'},
{name: 'Apostrophe', code: '''},
{name: 'Ampersand', code: '&'},
{name: 'Approximate', code: '≈'},
{name: 'Lesser than', code: '<'},
{name: 'Greater than', code: '>'},
{name: 'Lowercase Alpha', code: 'α'},
{name: 'Lowercase Beta', code: 'β'},
{name: 'Lowercase Gamma', code: 'γ'},
{name: 'Lowercase Delta', code: 'δ'},
{name: 'Lowercase Sigma', code: 'σ'},
{name: 'Left Arrow', code: '←'},
{name: 'Right Arrow', code: '→'},
{name: 'Up Arrow', code: '↑'},
{name: 'Down Arrow', code: '↓'}
]
We've created a list of 20 HTML entities each represented by an object whose name
property holds the name of the entity and code
holds the markup code of the entity.
Now although the list is created, our code still doesn't know of its format i.e what property value do we need to include in each <li>
element, or based on which property's value do we need to match every item in the list.
We have to convert this list into a native list whose format our autocompleter knows. A long discussion begins...
Converting to a native list
There isn't any restriction on the format of the lists being passed to an autocompleter - they can be lists of strings, lists of objects, lists of lists and so on. We can't just take it for granted that every such list contains objects with a name
property or that every such list has elements of type string.
This just doesn't work!
What we have to rather do is understand the provided list and create our own native list from it. By 'native' we mean that the list has elements whose format we know beforehand.
Now the way we'll be accomplishing this task is by using hints of the format of the original list. Obviously we can't always figure out the format of any list on our own; we do need clues or directions to proceed.
In our case the first hint can be whether the original list i.e list
, consists of objects or plain strings. If the former is true, i.e list
contains objects, then we might want to further know which property of all these objects do we wish to include in the search.
Both these hints can be laid using global variables, as follows:
- The variable
itemType
would specify the type of each item oflist
as a string. - The variable
itemPropName
would specify the name of the property of each item that we wish to include in the search. This would only be sensible if each item is an object.
Let's consider an example:
var itemType = "object";
itemPropName = "name";
Here itemType
specifies that each item of list
is an object, while itemPropName
specifies that for each item, we wish to include its name
property in the search.
If list
is an array of strings, these hints will look something as follows:
var itemType = "string";
itemPropName = "";
By looking at this code, we can easily tell that list
is an array of strings. Furthermore, since each element is a string, we won't be including any of its properties in the search - likewise itemPropName
is ""
.
""
to denote that no property exists on any of list
's elements. You can even use false
or null
in place of ""
. It's all your choice!Taking a closer look, we see that both these hints can be merged into one simple hint, as shown below:
var itemPropName = "name";
The name itemPropName
is an abbreviation for 'property name'. If it is a non-empty string, this means that list
has elements of type object that have a property with the name itemPropName
.
On the otherhand, if it's empty, then this represents the fact that list
has no property name, or in simpler words, it consists of plain strings.
Once we've got a hint, we can easily construct a new list from the original one in just no time. The steps for this are highlighted as follows:
- If
itemPropName
is an empty string, we iterate overlist
and for each element, we push it directly onto a new list. - If
itemPropName
is not an empty string, we iterate overlist
and for each element, we push its respective property value onto the new list.
But what does the new list look like? We have not seen it as of yet...
What do you think, should it be a list of objects or a list of strings? Try thinking on this question for a moment and see where you end up.
First of all understand what do we need in a native list - it needs to have all the values that we wish to put in the searching algorithm each time a query is input in inputArea
.
For example, if list
is as shown above, then our native list must contain the names of all the entities, if we wish to include the names of the entities in the search.
So the best choice at the moment is to run over list
, and for each element, push the corresponding value in a new list. The value could be extracted using list[i]
if list
is an array of strings, or using list[i][itemPropName]
if it's a list of objects.
The original list will be used when we need to layout suggestions for a given query, as we shall see later in this chapter.
All this coding hassle will go in a new function createNativeList()
.
Consider the code below:
var nativeList = [];
function createNativeList() {
for (var i = 0, len = list.length; i < len; i++) {
nativeList.push(itemPropName ? list[i][itemPropName] : list[i]);
}
}
? :
, also known as the conditional operator. See how it work at JavaScript Ternary Operator.It'll iterate over list
, and for each element, copy it into the nativeList
global array as it is, if it's a string, or otherwise extract out the desired property's value from it, if it's an object.
Creating a suggestion element
As we've just seen above, that list
can have elements of type object as well, if it does have elements of type object then the following onkeyup
handler won't work as expected:
inputArea.onkeyup = function() {
/* ... */
for (var i = 0; i < list.length; i++) {
if (list[i].toLowerCase().indexOf(q.toLowerCase()) !== -1) {
suggestions.push('<li>' + list[i] + '</li>');
}
}
/* ... */
}
The issue arises from the way we access each item in list
— it might be an object ajd
We have to definitely change the handler in order to adapt to the changes we've made in the section above.
Do you have any suggestions on how to recode this?
The array nativeList
contains items that we wish to include in the search, likewise it'll go in the if
conditional in line 8.
Furthermore, because list[i]
doesn't necessarily has to be a string, we can check its type using typeof
and then extract the respective piece of information from it.
However, even after we do check the type, if it's an object we still don't know which property's value to include in the suggestion's content and that how do we need to lay out different bits of information in the suggestion.
In other words, a check is useless!
It's much more sensible to create a function to be invoked in this place, that is defined by the developer himself. Since it's the developer who defines list
, he knows of its format and even what to show in each suggestion and how to show it.
Using a function the developer can define how he wants each suggestion to look, and then this function could be called in place of the expression list[i]
to create each suggestion in that very way.
Let's call the function createSuggestion()
.
Below we define createSuggestion()
in a way so that each suggestion contains the symbol of the corresponding entity followed by its name:
// the developer defines this function
function createSuggestion(item) {
return item.code + ' ' + item.name;
}
Now to get this function into action we just ought to make two slight changes in the onkeyup
handler, as follows:
inputArea.onkeyup = function() {
/* ... */
for (var i = 0; i < list.length; i++) {
if (nativeList[i].toLowerCase().indexOf(q.toLowerCase()) !== -1) {
suggestions.push('<li>' + createSuggestion(list[i]) + '</li>');
}
}
/* ... */
}
With all this in place, we finally get a customised autocompleter as a result. Superb development skills!
Rewrite createSuggestion()
so that the entity symbol of each suggestion is enclosed within a <span>
element that has the following CSS class:
.entity-code {
color: red;
font-weight: 900
}
Simply include the <span>
element in the string returned by createSuggestion()
.
function createSuggestion(item) {
return '<span class="entity-code">' item.code + '</span> ' + item.name;
}
A problem arises
Well, it's amazing to know that we now have full control over how each suggestion is laid out in our autocompleter, however not without any problems.
Due to this customisation, our old suggestion selection logic is prone to errors. If we select a suggestion that has some HTML element in it, some gibberish will be shown in inputArea
, as is the case in the autocompleter created in Task 1.
We as developers know this gibberish - it's HTML code - and even that where is it coming from - recall the fact that upon selecting a suggestion we used to put its innerHTML
value into inputArea
.
In Task 1, every suggestion contained a <span>
tag and so upon clicking any one resulted in the same tag showing up in inputArea
.
To rectify this problem, there's only one straightforward way - when a suggestion is clicked, go to its corresponding element in list
and extract out its respective information to be put in inputArea
.
However in the first place, we need to know which element in list
does a given suggestion correspond to. Consider the following explanation of this:
list = ['Hello', 'Bye']
. We type in 'B'
in inputArea
and likewise 'Bye'
shows up in suggestionsBox
. This suggestion comes from the second element in list
. The moment we click it, we go to the second element of list
, extract out its name
and put it into inputArea
.In other words, for every suggestion we need to know the index of its corresponding element in list
. This can be achieved in two ways:
- One is to put the index directly inside a property of each suggestion element.
- The other is to put the index inside a
data-index
attribute of each suggestion element.
We'll go with option 2, owing to its ease of implementation - it only requires a few changes compared to the former where we would have to rewrite a decent amount of code.
Consider the code below:
inputArea.onkeyup = function() {
/* ... */
for (var i = 0; i < list.length; i++) {
if (nativeList[i].toLowerCase().indexOf(q.toLowerCase()) !== -1) {
suggestions.push('<li data-index="' + i + '">' + createSuggestion(list[i]) + '</li>');
}
}
/* ... */
}
Each suggestion is given a data-index
attribute that holds the index of its corresponding element in list
.
As with creating a suggestion, we don't know what do we need to put into inputArea
- it's the developer who knows this stuff. For example, he might want to just put the name of the entity in the input element, or just its code.
Therefore, we'll create another function getInputAreaValue()
that takes in a list item and returns its respective value to go into inputArea
:
function getInputAreaValue(item) {
return item.name
}
This requires an alteration in selectSuggestion()
:
function selectSuggestion(s) {
inputArea.value = getInputAreaValue(list[Number(s.getAttribute("data-index"))])
hideSuggestionsBox();
}
Now the moment a suggestion is clicked, its data-index
property is read, the underlying element in list
accessed, and a value put in inputArea
according to the function getInputAreaValue()
.
Finally, everything is now under control and harmony!
Another hidden problem
Although on the front line, nothing seems to be erroneous in our autocomplete algorithm now; yet there is. We still have a problem - this time a hidden one!
Let's see if you can determine it...
It has something to do with when we click on a given suggestion element.
Recall that we handle each suggestion's click indirectly by handling the click event on the whole suggestionsBox
element, and checking if the target of the click is an <li>
element.
Now suppose that our autocompleter is the one demonstrated in the link above, where each suggestion has a <span>
element that holds the symbol of the HTML entity.
With this configuration in place, let's say we click on one of the <span>
elements which means that we've, in effect, clicked on a suggestion element. Ideally the suggestion shall be selected, but it doesn't. This is the issue.
This happens because of our faulty check in the onclick
handler of suggestionsBox
.
<li>
element, failing to recognise the fact that in some cases we could be clicking on something that is a descendant of the <li>
element, which is equivalent of saying that we've clicked on the <li>
element.What we should rather do is check if the target of the click has an <li>
element in its ancestor chain upto suggestionsBox
.
This could be accomplished pretty simply using a while
loop. We start with the target of the click event, and then successively move up its ancestor chain, until we reach suggestionsBox
. If an <li>
element is found in this chain, we select the suggestion, otherwise we do nothing.
Let's code this:
suggestionsBox.onclick = function(e) {
// start with the target of the click event
var t = e.target;
while (t !== suggestionsBox) {
// check if t is a suggestion element
if (t.nodeName === "LI") {
inputArea.value = t.innerHTML;
suggestionsBox.style.display = "none";
return;
}
// move up its chain
t = t.parentNode;
}
}
In the while
loop, we iterate over all the ancestors of e.target
, including itself, upto the point where we reach suggestionsBox
.
suggestionsBox
is useless.For each of these elements, if it's detected to be an <li>
element, this means that we've reached a suggestion, and therefore right away select it.
The statement t = t.parentNode
(in line 14) is crucial here - it assigns to t
the next element up the chain, that is, the parent of the element currently saved in t
.
And now, without any doubt, we can claim that our autocompleter is free from any sort of flaws.