Autocomplete Customised Suggestions

Chapter 4 21 mins

Learning outcomes:

  1. What is customisation
  2. Creating a native list
  3. Customising suggestions
  4. Solving problems

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:

Pizza
Pasta

What if each suggestion also displays the price of the food item next to it, as shown below:

Pizza $3.00
Pasta $2.50

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 '',
    /* ... */
]
Imagine that we were to sort all entities here based on their code, like ∧. 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.

This is just like we're receiving an encrypted message that we ought to decrypt on our end in order to understand and work with it.

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:

  1. The variable itemType would specify the type of each item of list as a string.
  2. 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 "".

We've used an empty string "" 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!
Remember, that the developer knows the format of the original list and so assigns values to these variables himself.

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:

  1. If itemPropName is an empty string, we iterate over list and for each element, we push it directly onto a new list.
  2. If itemPropName is not an empty string, we iterate over list 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]);
    }
}
In line 5, we've used the ternary operator ? :, 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!

Live Example

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;
}

Live Example

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:

Suppose that 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:

  1. One is to put the index directly inside a property of each suggestion element.
  2. 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!

Live Example

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.

We merely check if the target of the click is an <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.

Going beyond 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.

Live Example