Autocomplete Highlighting

Chapter 8 19 mins

Learning outcomes:

  1. What is highlighting
  2. Simple highlighting
  3. Highlighting in character search
  4. Combining everything

Introduction

Often times when we type a query in an input field, that's taken over by an autocompleter, each of the suggestions contains the matched phrase in a different style as compared to the rest of the string. In other words, the autocompleter highlights the matched (or unmatched) portions of each suggestion.

In the last Autocomplete Matching Expressions chapter, we discovered two new dimensions of considering a given list item to be a match for the entered query. Following from that development, in this chapter, we shall unravel how to highlight the exact matched portions in each matched suggestion - be it whole words, or individual characters.

What is highlighting?

Just like what highlighting means in real life, in an autocompleter it is to emphasize on a given set of characters, specifically a set that matches the entered query.

Consider the following example:

Pizza
Pasta

We have a list of suggestions shown, where each suggestion has the character 'p' in bold. In other words, the character 'p' is highlighted.

As you would agree, highlighting makes an autocompleter look more professional. Just compare the two cases: one where each suggestion is just shown as is, and one where each suggestion is shown with the matching portion emphasized, as in the example above.

Clearly, the latter gives a more professional feel. In the highlighted version, we can evidently see how exactly does the query match the given suggestion - without the highlighting, this is only left to our imagination.

Simple highlighting

Let's start by solving a simple problem of highlighting and once we are comfortable with it, head over for more challenging problems.

We have a list of items and a matching expression that checks whether the entered query exists in a given item as is (the one we've been using from the start of this tutorial).

Our task is to highlight the matching part of each suggestion by surrounding it with the HTML <b> tags.

The task itself is quite easy, it's just incorporating it that's a bit difficult. But with a careful sight, this also gets ruled out. Let's think on it...

What do we need in order to highlight a given suggestion?

We need the index of the matching substring.

For example, if the query is 'pi' and the suggestion is "Typing", we need to know the index of 'pi' in "Typing". In this case it is 2. With only this information in hand, we can change "Typing" into "Ty<b>pi</b>ng".

So first thing's first, let's save the index, where the query occurs in the current suggestion, inside a variable:

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index;
    for (var i = 0; i < list.length; i++) {
        index = nativeList[i].toLowerCase().indexOf(q.toLowerCase());
        if (index !== -1) {
            /* ... code here ... */
        }
    }

    /* ... code here ... */
}

With the index in hand, now we need to perform a bit of string slicing.

Given the string "Python", the query "th" and the index 2, write an expression that returns "Py<b>th</b>on".

You shall assume that "Python" is saved in the variable item, the query "th" in q, and the index 2 in index.

You shall use the string slice() method to accomplish this task.

Three slices need to be done here. First we need to slice from the start of item to index i.e item.slice(0, index). In this case, this slice would be equal to "Py".

Second we need to slice from index upto the position where the query ends i.e item.slice(index, index + q.length) and surrond it with '<b>' and '</b>' tags. This slice would be equal to "th"; and with the tags added, equal to "<b>th</b>".

Last we need to slice from the second argument of the previous slice()all upto the end of item i.e item.slice(index + q.length). This would be equal to "on".

All these three slices have to be concatenated together to yield the highlighted version of the string "Python".

This can be seen as follows:

item.slice(0, index) + '<b>' + item.slice(index, index + q.length) + '</b>' + item.slice(index + q.length);

The expression we've obtained in the task above will be used to assign a value to a local variable highlightedValue we create below, which will ultimately be passed onto processSuggestion() as we shall see next:

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
        var index = nativeList[i].toLowerCase().indexOf(q.toLowerCase());
        if (index !== -1) {
            /* ... code here ... */

            // get the original item
            item = itemPropName ? list[i][itemPropName] : list[i];

            // construct the highlighted value
highlightedValue = item.slice(0, index) + '<b>' + item.slice(index, index + q.length) + '</b>' + item.slice(index + q.length);
suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i]) + '</li>'); } } /* ... code here ... */ }

Note that a variable item is created to hold the actual version of the corresponding list item, which will be eventually highlighted.

In highlighting, we can't use the lowercase version of the item i.e by referring to nativeList[i] - we have to use the original one by referring to list.

Now there's one very important thing to understand here...

We can't just pass in the highlighted suggestion as the first argument of processSuggestion().

Recall that this argument is the corresponding item of list. One can use this argument to extract out any information from the given item, especially if it's an object. Likewise, replacing this with sending in the highlighted suggestion will interfere with this setup.

A very straightforward way out of this is to pass the highlighted suggestion as the second argument of processSuggestion(). In this way, one can work with the list item as well as the highlighted text.

In the code below, we save the highlighted item inside a variable highlightedValue and then pass this as the second argument into processSuggestion():

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
        var index = nativeList[i].toLowerCase().indexOf(q.toLowerCase());
        if (index !== -1) {
            /* ... code here ... */

            // get the original item
            item = itemPropName ? list[i][itemPropName] : list[i];

            // construct the highlighted value
            highlightedValue = item.slice(0, index) + '<b>' + item.slice(index, index + q.length) + '</b>' + item.slice(index + q.length);

            suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>');
        }
    }

    /* ... code here ... */
}
function processSuggestion(item, highlightedValue) {
    return highlightedValue;
}

For now, we've redefined processSuggestion() to return its highlightedValue argument.

However, when we restructure all this code we shall get the function to return highlightedValue only if highlighting is desired by means of checking a global variable.

Following we test this code:

Live Example

Highlighting in character search

As you would agree, highlighting the matching portion of each suggestion in the case of a simple indexOf() search wasn't difficult. What's the real deal is to implement this idea when our matching expression searches by characters, as we saw in the previous chapter.

Suppose that the query is "pt" and the suggestion is "Python". Our task is to transform "Python" into "<b>P</b>y<b>t</b>hon", by highlighting the characters 'P' and 't' individually.

As always, you should try to solve this problem on your own first and then continue on reading.

For the moment we assume that there is no other highlighting logic in place. Moreover, now we may consider our old matchingExp logic. For the time being let's suppose that it's equal to 2; that is, the following function is used to match given items with the query:

function characterSearch(q, item) {
    var index = 0;
    for (var i = 0, len = q.length; i < len; i++) {
        index = item.indexOf(q.charAt(i), index);
        if (index === -1) {
            return false;
        } else {
            index++;
        }
    }
    return true;
}

The whole boil down of highlighting characters comes down to this function - all the highlighting logic will go inside it. How exactly to lay out this logic, this is the concern.

Compared to the previous problem where we saved the index returned by the single indexOf() call and then performed slicing based on it, in this one, we can't just go on and do the same thing, because there simply isn't just one index. There might be an array of indexes to deal with. Therefore, we need to take another dimension.

That dimension is to return an array of indexes from the characterSearch() function above, and use this array to highlight the desired characters.

Let's first get the former done i.e return an array. It's fairly easy - create a local array, push indexes onto it, and finally return it instead of the value true:

function characterSearch(q, item) {
    var index = 0, indexArr = [];
    for (var i = 0, len = q.length; i < len; i++) {
        index = item.indexOf(q.charAt(i), index);
        if (index === -1) {
            return false;
        } else {
            indexArr.push(index);
            index++;
        }
    }
    return true;
}

As before, we'll save this return value inside a variable, bring on the variable inside the if condition, and then use it in the highlighting phase.

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
var index = matchingFunc(q.toLowerCase(), nativeList[i].toLowerCase());
if (index) { /* ... code here ... */ } } /* ... code here ... */ }

Now we need to highlight the list item and assign it to highlightedValue.

Since, the highlighting is of a different kind here, we'll create a separate function getHighlightedValue() for it.

As the name implies, the function will take in a suggestion and return its highlighted version.

The way the highlighting would be done is interesting.

  1. We split the string into an array of characters.
  2. Then we iterate over the list of indexes and for each index, go to the corresponding element in the array created in 1) and concatenate the strings <b> and </b> at its start and its end, respectively.
  3. Finally, we join the split array to obtain the highlighted string and sign off by returning it.

An example would be worth the discussion.

Let's say the query q is "pt" and item is "Python". After calling characterSearch(), the list of indexes obtained is equal to [0, 2]. We call getHighlightedValue() passing it item and the indexes array [0, 2] and it proceeds as follows.

  1. The string "Python" is split into an array of characters i.e ['P', 'y', 't', 'h', 'o', 'n'], and saved in charArr.
  2. Then for each of the index in the indexes array, the corresponding element is processed in charArr. That is, for 0, we go to charArr[0] and change it to <b> + charArr[0] + </b>. Then for 2, we go to charArr[2] and do the same thing.
  3. In the end charArr becomes equal to ['<b>P</b>', 'y', '<b>t</b>', 'h', 'o', 'n']. This is joined together into "<b>P</b>y<b>t</b>hon" and returned to the calling context.

The return value of getHighlightedValue() goes inside the highlightedValue variable.

Below shown is the definition of getHighlightedValue():

function getHighlightedValue(item, indexArr) {
    // split item into an array of characters
    var charArr = item.split("");

    // iterate over all indexes
    // process each corresponding character accordingly
    for (var i = 0; i < indexArr.length; i++) {
        charArr[indexArr[i]] = '<b>' + charArr[indexArr[i]] + '</b>';
    }

    // join the array elements into a string
    return charArr.join("");
}

The code inside the onkeyup handler will look as follows:

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
        var index = matchingFunc(q.toLowerCase(), nativeList[i].toLowerCase());
        if (index) {
            /* ... code here ... */

            item = itemPropName ? list[i][itemPropName] : list[i];

            highlightedValue = getHighlightedValue(item, index);

            suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>');
        }
    }

    /* ... code here ... */
}

Everything's the same as before except for the value assigned to highlightedValue.

Live Example

Combining everything

Akin to what we've been doing in all the previous subfeatures given to our autocompleter, we'll create some global identifier here to specify whether we want to highlight our suggestions or not.

We'll call it highlight.

If highlighting is desired, highlight will be set to true, and false otherwise.

The statement to check this variable will go inside the onkeyup handler, right where we invoked getHighlightedValue() previously.

Clearly, we should only be calling getHighlightedValue() if highlighting is actually desired i.e check whether highlight is true or not.

Uptil now this leads to the following set up:

var highlight = true;
inputArea.onkeyup = function() { /* ... code here ... */ var index, highlightedValue, item; for (var i = 0; i < list.length; i++) { var index = matchingFunc(q.toLowerCase(), nativeList[i].toLowerCase()); if (index !== -1) { /* ... code here ... */
if (highlight) { /* highlighting logic will go here */ }
suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>'); } } /* ... code here ... */ }

After this, we need to redefine all our three matching functions such that they return the index of the match if it's found or else some falsey value.

We'll take the falsey value to be -1. There's a good reason to use this instead of false, that will be explained below.

Let's understand what should each of three matching functions return:

Just keep in mind that if a match is found, the function should return its index, or else the value -1!
For the first function, the return value is pretty straightforward - simply return item.indexOf(q). If a match is found, the expression would return its index, or else the value -1.

For the second function, if a match is not found at index 0 it should return -1, or otherwise the value 0.

For the third function, if matches are found it should return an array of the indexes, or otherwise the value -1.

This translates to the following code:

function wordSearch(q, item) {
    return item.indexOf(q);
}
function wordSearchFromStart(q, item) {
    return (item.indexOf(q) === 0) ? 0 : -1;
}
function characterSearch(q, item) {
    var index = 0, indexArr = [];
    for (var i = 0, len = q.length; i < len; i++) {
        index = item.indexOf(q.charAt(i), index);
        if (index === -1) {
            return -1;
        } else {
            indexArr.push(index);
            index++;
        }
    }
    return indexArr;
}

Why use -1 as the falsey value?

The reason of using -1 as the falsey value is that it requires the least amount of coding in the first function.

If the falsey value had been false, we would've had to redefine the first matching function to return false if the index is equal to -1, or else the index itself, as shown below:

function wordSearch(q, item) {
    var index = item.indexOf(q);
    return (index === -1) ? false : index;
}

See how using false as the falsey value introduces a variable inside this first matching function. To prevent this, we use -1 as the falsey value and return the index right away.

With this done, we can invoke the respective matching function inside the for loop (in the onkeyup handler), check for its return value, and proceed only if it's NOT equal to -1.

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
        var index = matchingFunc(q.toLowerCase(), nativeList[i].toLowerCase());
        if (index !== -1) {
            /* ... code here ... */

            if (highlight) {
                /* highlighting logic will go here */
            }

            suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>');
        }
    }

    /* ... code here ... */
}

Now after this, to get everything back in action, we ought to redefine getHighlightedValue() such that it can return highlighted values for all three of the matching functions - not just any one of them.

How to define getHighlightedValue() such that it can return a highlighted string for any of the matching functions?

In the first two functions, the value returned is of type "number". In contrast, in the third function, the value returned is of type "object" - specifically an array.

This fact alone can be used to distinguish between the first (and second) and the third functions, and perform the desired action accordingly.

Following from the solution to the task above, below we define getHighlightedValue():

function getHighlightedValue(item, index, q) {
    // if index is a number
    // matchingFunc is one of the first two matching functions
    // likewise, we highlight the whole word
    if (typeof index === "number") {
        return item.slice(0, index) + '<b>' + item.slice(index, index + q.length) + '</b>' + item.slice(index + q.length)
    }

    // otherwise, it's characterSearch
    // likewise, we highlight each character
    else {
        var charArr = item.split("");
        for (var i = 0; i < indexArr.length; i++) {
            charArr[indexArr[i]] = '<b>' + charArr[indexArr[i]] + '</b>';
        }
        return charArr.join("");
    }
}

If the argument index is of type "number" we highlight the given string item using string slicing. For this we include another argument q into the function's invocation.

However, if this is not the case, then we know that index is an array and likewise highlight the given string using our old definition of getHighlightedValue(), as can be seen in the previous section.

With getHighlightedValue() defined, the last thing left is to invoke it and put its return value inside highlightedValue:

inputArea.onkeyup = function() {
    /* ... code here ... */

    var index, highlightedValue, item;
    for (var i = 0; i < list.length; i++) {
        var index = matchingFunc(q.toLowerCase(), nativeList[i].toLowerCase());
        if (index !== -1) {
            /* ... code here ... */

if (highlight) { item = itemPropName ? list[i][itemPropName] : list[i]; highlightedValue = getHighlightedValue(item, index, q); }
suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>'); } } /* ... code here ... */ }

If highlighting is desired i.e highlight is set to true, we extract out the current item's original value, highlight it, and pass it processSuggestion(). Similarly, if it's not desired, we simply do nothing.

In short, everything works perfectly in harmony.

If highlighting isn't desired, even then processSuggestion() is called with a second argument, but in this case it would be equal to undefined.

And we are done!

Below we play around with a couple of examples:

var list = ["Python", "JavaScript", "Platinum", "Typing"];
                                
var itemPropName = false,
     groupBy = false,
     sortSuggestions = "asc", sortGroups = false,
     matchingExp = 0,
     highlight = true;

Live Example

var list = ["JavaScript", "Dart", "HTML", "TypeScript", "Python", "Perl", "PHP", "CoffeeScript", "C#", "C++", "Objective-C", "Java", "CSS", "XML"];
                                
var itemPropName = false,
     groupBy = 0, // group by first letter
     sortSuggestions = "asc", sortGroups = "asc",
     matchingExp = 1,
     highlight = true;

Live Example

var list = [
    {chapter: 'Variables', unit: 'Basics'},
    {chapter: 'Data Types', unit: 'Basics'},
    {chapter: 'Input and Output', unit: 'Basics'},
    {chapter: 'Constants', unit: 'Basics'},
    {chapter: 'Comments', unit: 'Basics'},
    {chapter: 'Prototypes', unit: 'Objects'},
    {chapter: 'Constructors', unit: 'Objects'},
    {chapter: 'Getters and Setters', unit: 'Objects'},
    {chapter: 'Inheritance', unit: 'Objects'},
    {chapter: 'Offsets', unit: 'Dimensions'},
    {chapter: 'Bounding Boxes', unit: 'Dimensions'},
    {chapter: 'Viewport', unit: 'Dimensions'}
];
                                
var itemPropName = "chapter",
     groupBy = "unit", // group by first letter
     sortSuggestions = "asc", sortGroups = "asc",
     matchingExp = 2,
     highlight = true;

Live Example