Autocomplete Arrow Navigation

Chapter 3 30 mins

Learning outcomes:

  1. What is arrow navigation
  2. Implementing arrow navigation
  3. Solving programming issues
  4. Selecting a suggestion
  5. Keeping the suggestion in view

Introduction

In the previous chapter we covered the entire fundamental structure of an elementary autocompleter. We began with its markup, moving over to its styling and ending with its scripting.

In the scripting part, we gave it a couple of subfeatures such as selecting a suggestion by clicking on it, showing a 'Nothing found' message if the query had no matches in list, and so on.

One very native subfeature of an autocompleter, that this chapter is all about is allowing for navigation around the list of suggestions via arrow keys. That is, a user can go through all suggestions just by using the up and down arrow keys, and then select a given suggestion by pressing enter.

As easy as this might seem, it is exactly the opposite - not straightforward at all and once again requiring real intellectual and problem-solving skills at the developer's end.

What is arrow navigation?

As always, the first thing to understand in this subfeature is how exactly does it work.

Arrow navigation, as the name implies, is simply navigating across the list of suggestions using arrow keys. But this doesn't tell much about it - let's consider an example.

So suppose you have the following configuration of the autocompleter:

p
Pizza
Pasta

Now while the cursor is still inside inputArea, you press the down arrow key and consequently the first suggestion gets highlighted.

p
Pizza
Pasta

You again press the down key and the second suggestion gets highlighted, with the first one being unhighlighted:

p
Pizza
Pasta

After this, you press the up arrow key and the previous configuration resumes:

p
Pizza
Pasta

To end with you press the up key once more and the first suggestion gets unhighlighted, taking us back to the initial configuration:

p
Pizza
Pasta

This is what arrow navigation is - you use arrow keys to move across the list of suggestions.

Now it's time to understand how to code this...

Breaking it down

Once we understand a feature, the next logical step is to understand how to approach it.

It would be senseless and inefficient to go to the computer and start coding arrow navigation immediately. We would have no idea of where to start, how to start, what statements to use and so on.

A pen and a piece of paper are a developer's best friend. Our minds work better if we have a working plan in front of us. Go on and grab a pen and a paper and start exploring how would you programatically implement this navigation feature.

How should your code respond to the up key's click? How will you determine which navigation to highlight? How will you figure out whether arrow navigation should be given or not? Where in the code will all this logic go, and so on and so forth.

Ask all these questions to yourself, answer them and then try to connect them together to see how your code should really work.

Forget about everything right now - just try to figure out how to navigate past suggestions when the user presses the up or down arrow key.

Below we give an example using the up key:

When the up arrow key is pressed, the autocompleter can be in one of the following states:

  1. No suggestion is currently highlighted.
  2. The first suggestion is highlighted.
  3. Any other suggestion is highlighted (which is obvious if the above two cases fail).

For each of these three cases, the corresponding action to be taken is listed below:

  1. If no suggestion is currently highlighted, the last suggestion should be highlighted.
  2. If the first suggestion is highlighted, it should be unhighlighted.
  3. If any other suggestion is highlighted, then that suggestion should be unhighlighted and the one above it should be highlighted.

Similar to the example above, try to come up with a plan on how to tackle the down key's press.

For the down key's click:

  1. If no suggestion is currently highlighted, the first suggestion should be highlighted.
  2. If the last suggestion is highlighted, it should be unhighlighted.
  3. If any other suggestion is highlighted (which is obvious if the above two cases fail), that suggestion should be unhighlighted and the one below it should be highlighted.

Uptil this point we are half done with our plan - the rest half is discussed below.

Setting things up

The description given above regarding the configurations of the autocompleter when either of the up and down arrow keys is clicked, is sufficient to help one out in translating it into JavaScript.

If you didn't understand those case descriptions, you should go back and try your level best to get some intuition behind them.

Below we'll follow the descriptions as they are in constructing an algorithm for arrow navigation. But before that we need to set up certain things.

First of all, where should the arrow navigation logic go?

Should it go in the same old onkeyup handler, or in a new handler?

Well we'll go with a new handler - onkeydown. And with a good reason behind it...

If a user keeps a given arrow key pressed, keydown will continue firing, whereas keyup won't, unless and until they key is released.
Recall that keydown fires at regular intervals if a given key is held down, which means that if a user keeps an arrow key pressed, navigation through the list of suggestions will continue going on. Read more at JavaScript Key Events.

Some autocompleters do handle keyup in this case, however keydown is a bit more suitable than keyup, as it enables continuous navigation to be done.

Our arrow navigation code can also go in onkeyup, it's just that handling keydown is more fruitful.

The second thing is that how will a given suggestion be highlighted? Shall we give the suggestion a respective style property or a CSS class?

We'll definitely take the latter choice here, since using class toggles is the recommended way of applying styles to HTML elements from within JavaScript.

All styling concerns must be in CSS whereas all programming concerns must be in JavaScript - a practice commonly referred to as separation of concerns.

Let's create a class .hl (abbreviation of 'highlighted') with the following CSS:

.hl {
    background-color: orange;
}

Now to highlight a suggestion we have to only give it this class, and similarly to unhighlight it we have to remove the class.

These decisions being made, we are now ready to start coding the real logic.

Coding it

In the keydown handler, we'll start by laying two checks to see if the up or down arrow key is pressed. The property keyCode of the event object will be particularly useful in this case.

A keyCode equal to 40 means the down key is pressed whereas a keyCode equal to 38 means the up key is pressed.

Inside the first conditional block, we'll tackle with the down key whereas in the subsequent one we'll tackle with the up key.

Consider the following code:

inputArea.onkeydown = function(e) {
    if (e.keyCode === 40) {
        // down key is pressed
    }
    else if (e.keyCode === 38) {
        // up key is pressed
    }
}

For the sequence of steps to go into both these blocks, recall the case descriptions we gave in the first section on this page.

We'll use a global variable sIndex to hold the index of the suggestion element currently highlighted. If it's equal to -1 we know that no suggestion is highlighted.

Likewise following is the code for the down key's conditional block:

var sIndex = -1;

inputArea.onkeydown = function(e) {

    // down key is pressed
    if (e.keyCode === 40) {

        // if no suggestion is highlighted
        // highlight the first one
        if (sIndex === -1) {
            suggestionElements[++sIndex].classList.add("hl");
        }

        // if the last suggestion is highlighted
        // unhighlight it
        else if (sIndex === lastSuggestionIndex) {
            suggestionElements[sIndex].classList.remove("hl");
            sIndex = -1;
        }

        // otherwise, unhighlight the current suggestion
        // and highlight the one below it
        else {
            suggestionElements[sIndex].classList.remove("hl");
            suggestionElements[++sIndex].classList.add("hl");
        }

    }

    else if (e.keyCode === 38) {
        // up key is pressed
    }
}

Take note of the variable lastSuggestionIndex here - it's another global variable that serves to hold the index of the last suggestion in suggestionsBox.

It is assigned a value in the onkeyup handler for inputArea. Everytime the keyup event fires and a list of suggestions is found, this variable is set equal to the (number of suggestions) - 1, as can be seen below:

var lastSuggestionIndex = -1;

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

    if (suggestions.length === 0) {
        suggestionsBox.innerHTML = "No results found!";
    }
    else {
        suggestionsBox.innerHTML = '<ul>' + suggestions.join("") + '</ul>';
        lastSuggestionIndex = suggestions.length - 1;
    }

    suggestionsBox.style.display = "block";
}
We've used the comment /* ... */ above to represent a segment of the handler's code. This is to keep the snippet short and concise to the point.

In the code above, notice that we've left the definition of the up key's conditional block.

Complete this conditional block by following the case description given above for the up key.

inputArea.onkeydown = function(e) {

    // down key is pressed
    if (e.keyCode === 40) { /* ... */ }

    // up key is pressed
    else if (e.keyCode === 38) {
        // if no suggestion is highlighted
        // highlight the last one
        if (sIndex === -1) {
            sIndex = lastSuggestionIndex;
            suggestionElements[sIndex].classList.add("hl");
        }

        // if the first suggestion is highlighted
        // unhighlight it
        else if (sIndex === 0) {
            suggestionElements[sIndex].classList.remove("hl");
            sIndex = -1;
        }

        // otherwise, unhighlight the current suggestion
        // and highlight the one above it
        else {
            suggestionElements[sIndex].classList.remove("hl");
            suggestionElements[--sIndex].classList.add("hl");
        }
    }

}

Now all this does indeed lead to some sort of arrow navigation, but not one which is pitch perfect. Spot all the problems in the autocompleter below and see if you can come up with solutions. The subsequent sections all deal with these problems.

Live Example

Solving issues

The first and foremost issue visible in the example above is that the when a given suggestion is highlighted, the orange background is shown just for a split second - after that it returns back to its default background.

Can you answer why does this happen?

Well, it happens due to onkeyup.

Let's suppose that sIndex is equal to -1 and we press the down key. The first suggestion is highlighted from within the onkeydown handler, and consequently the handler exits.

After this, we release the down key and consequently the onkeyup handler takes over. Here, the suggestions list, i.e suggestionsBox.innerHTML, is constructed again which causes the old styles applied within it to be lost.

To prevent this from happening, we just need to stop the searching algorithm in onkeyup from executing if the previous keydown event triggered due to the up or down key.

Now the way we do it is by using a global Boolean variable isNavigating.

Within onkeydown, isNavigating is set to true if either of the up or down keys is pressed, to imply that navigation is being performed.

In the onkeyup handler, we check if isNavigating is true which implies that the previous keydown event fired due to an arrow key. If it's true, the handler is exited immediately.

This can be seen as follows:

var isNavigating = false;

inputArea.onkeydown = function(e) {
    isNavigating = false;

    if (e.keyCode === 40) {
        isNavigating = true;
        /* ... */
    }
    else if (e.keyCode === 38) {
        isNavigating = true;
        /* ... */
    }
}

Line 4 here serves to reset isNavigating to false whenever a key is pressed down. Only when the up or down key is pressed is isNavigating set equal to true.

In the onkeyup handler a check for isNavigating is laid right at the start to exit the handler if it's equal to true:

inputArea.onkeyup = function() {
    if (isNavigating) {
        return;
    }

    var q = this.value,
         suggestionsStr = "";

    /* ... */
}

Now, at least on the first sight, our autocompleter looks and works perfectly!

Live Example

Selecting a suggestion

Native to arrow navigation is the feature of selecting a suggestion by pressing enter while it's highlighted. In this section we shall incorporate this idea into our autocompleter.

To begin with, we need to go inside onkeydown and check if the event fired due to the enter key. If it did and a suggestion was highlighted, we just need to select that suggestion just like we did by clicking a suggestion, in the previous Autocomplete Basics chapter.

Just as easy as it sounds, it also is:

inputArea.onkeydown = function(e) {
    isNavigating = false;

    if (e.keyCode === 40) {
        /* ... */
    }
    else if (e.keyCode === 38) {
        /* ... */
    }
    else if (e.keyCode === 13) {
        // ENTER pressed
        isNavigating = true;
        if (sIndex !== -1) {
            inputArea.value = suggestionElements[sIndex].innerHTML;
            hideSuggestionsBox();
        }
    }
}

By setting isNavigating to true in line 12, we ensure that when keydown fires due to the enter key, the following keyup handler exits immediately.

Moving on, in line 13, the conditional checks whether a suggestion is currently highlighted and performs selection only if one is.

Without this check, there is a possibility that the enter key is pressed while no suggestion is selected, in which case the statement in line 14 would fail - since sIndex would be equal to -1.

To end with, as with selection via mouse, we hide suggestionsBox when a suggestion is selected via the enter key.

Live Example

Scrolling to the highlighted

At this point our arrow navigation algorithm is officially complete, yet there is one caveat in it. See if you can spot it.

What happens if you navigate to a suggestion that is not within the viewport of suggestionsBox?

If we navigate to a suggestion that's not visible in the available area of suggestionsBox, it's indeed highlighted, but not brought into view.

Ideally a user shall be shown the suggestion he is currently on. Without this, he would not know which suggestion is he currently on, and would have to rather do this on his own - by scrolling suggestionsBox manually to the position where the highlighted suggestion lies.

Not so cool, is it?

In this section we shall address this issue, and for that we'll need to have a firm grasp over JavaScript Element Offsets and JavaScript Scroll Event.

The whole idea is that the highlighted suggestion shall be shown to the user within the available area of suggestionsBox. To do this we just need to change the scroll offset of suggestionsBox to get the suggestion into view.

Let's first explore the two possible cases:

  1. If the suggestion to be highlighted is below the bottom edge of suggestionsBox, then the box needs to be scrolled such that its bottom edge coincides with the bottom edge of the suggestion.
  2. If the suggestion to be highlighted is above the top edge of suggestionsBox, then the box needs to be scrolled such that its top edge coincides with the top edge of the suggestion.

Since both these cases are a possibility in the up and down key's press, the respective code will be the same in both the conditionals (of the up and down keys inside onkeyup).

Likewise, we'll construct a function to tackle this problem that'll be invoked from within both the conditionals. But first we need to understand how to check for the cases mentioned above via JavaScript and how to perform the desired actions.

Construct an expression to check if a suggestion is below the bottom edge of suggestionsBox.

For example, in the autocompleter below the highlighted suggestion is below the bottom edge of suggestionsBox.

Pizza
Pasta
Pumpkin

You shall assume that the following variables are available:

  1. suggestionsBoxHeight: the height of suggestionsBox.
  2. suggestionHeight: the height of the suggestion element.
  3. suggestionOffsetTop: the offsetTop of the suggestion, relative to suggestionsBox.

If the distance of the bottom edge of the suggestion from the top of suggestionsBox is greater than suggestionsBox's height, it's apparent that the suggestion is below it.

However, we also need to consider the current scroll offset of suggestionsBox in this. Specifically we need to subtract the scroll offset from the calculation mentioned above so that we get the current offset of the suggestion (after the scrolling).

Altogether we get the expression below:

(suggestionOffsetTop + suggestionHeight - suggestionsBox.scrollTop) > suggestionHeight

If this returns true, we know that the corresponding suggestion is below suggestionsBox.

Construct an expression to check if a suggestion is above the top edge of suggestionsBox.

Following is an example:

Pizza
Pasta
Pumpkin

You shall assume that the same variables in Task 2 are available here too.

If suggestionsBox is scrolled by a value greater than the distance of the suggestion from the box's top edge, then the suggestion is above.

For example, consider the first suggestion in suggestionsBox. It's at an offsetTop of 0px, which means that the moment we even scroll suggestionsBox by 1px, the suggestion goes partially above it.

Similarly, say that a suggestion is 56px far away from the top of suggestionsBox. If we scroll the box by 57px, the suggestion will definitely go above it.

The expression is:

suggestionsBox.scrollTop > suggestionOffsetTop

If it returns true, the corresponding suggestion is above the top edge of suggestionsBox.

If you've performed Tasks 1 and 2, then you are ready with expressions to check for the two possible locations of a suggestion when it's about to be highlighted, as discussed above.

Now we shall unravel what action to take in either case.

In the first case, the bottom edges of both the suggestion and suggestionsBox shall coincide with one another, while in the second case, the top edges shall coincide with one another.

Solve the task below to get the two statements required for both these cases.

Construct a statement to get the bottom edges of a given suggestion and suggestionsBox touch one another.

You shall assume the same variables are available as in Task 1.

We just need to scroll suggestionsBox by a value equal to the distance of the given suggestion's bottom edge from the bottom edge of suggestionsBox.

This value will tell us how far is the suggestion's bottom from being completely visible in the available area of suggestionsBox. The statement is as follows:

suggestionsBox.scrollTop = suggestionOffsetTop + suggestionHeight - suggestionsBoxHeight

Construct a statement to get the top edges of a given suggestion and suggestionsBox touch one another.

You shall assume the same variables are available as in Task 1.

This case is a bit simpler than the previous one. Here we just have to scroll suggestionsBox to a value equal to the distance of the given suggestion's top edge from the top of suggestionsBox.

For example, if a suggestion is 30px far away from the top of suggestionsBox, we need to scroll the box to 30px in order to show it completely. The statement is as follows:

suggestionsBox.scrollTop = suggestionOffsetTop

Now that we also have the actionable statements in place, we can finally construct the whole code to synchronise suggestionsBox with the highlighted suggestion.

As stated earlier, we'll first create a function to handle all the hassle we've discussed so far in this section.

function synchroniseSuggestionsBox() {
    var sOffsetTop = suggestionElements[sIndex].offsetTop,
         sHeight = suggestionElements[sIndex].clientHeight;

    // check if suggestion is below suggestionsBox
    if (sOffsetTop + sHeight - suggestionsBox.scrollTop > sBoxHeight) {
        suggestionsBox.scrollTop = sOffsetTop + sHeight - sBoxHeight
    }

    // check if suggestion is above suggestionsBox
    else if (suggestionsBox.scrollTop > sOffsetTop) {
        suggestionsBox.scrollTop = sOffsetTop
    }
}
The variables sOffsetTop and sHeight defined here are the same variables suggestionOffsetTop and suggestionHeight that were available in the tasks above. It's just that we've shortened their names now to favor readability.

The value of sBoxHeight is set within the onkeyup handler, right when suggestionsBox is displayed after a query is entered into inputArea:

var sBoxHeight = 0;

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

    suggestionsBoxClicked = false;
    suggestionsBox.style.display = "block";
    suggestionHighlighted = -1;
    sBoxHeight = suggestionsBox.clientHeight;
    suggestionsBox.scrollTop = 0
}
The expression suggestionsBox.clientHeight must come after suggestionsBox.style.display = "block", otherwise clientHeight will return 0!

The synchroniseSuggestionsBox() function will be invoked from within the onkeydown handler as shown below:

inputArea.onkeydown = function(e) {

    if (e.keyCode === 40) {
        if (sIndex === -1) {
            suggestionElements[++sIndex].classList.add("hl");
        }
        else if (sIndex === lastSuggestionIndex) {
            suggestionElements[sIndex].classList.remove("hl");
            sIndex = -1;
        }
        else {
            suggestionElements[sIndex].classList.remove("hl");
            suggestionElements[++sIndex].classList.add("hl");
        }

        // if some suggestion is to be highlighted,
        // make sure it appears with suggestionsBox
        if (sIndex !== -1) synchroniseSuggestionsBox();
    }

    else if (e.keyCode === 38) {
        if (sIndex === -1) {
            sIndex = lastSuggestionIndex;
            suggestionElements[sIndex].classList.add("hl");
        }
        else if (sIndex === 0) {
            suggestionElements[sIndex].classList.remove("hl");
            sIndex = -1;
        }
        else {
            suggestionElements[sIndex].classList.remove("hl");
            suggestionElements[--sIndex].classList.add("hl");
        }

        // if some suggestion is to be highlighted,
        // make sure it appears with suggestionsBox
        if (sIndex !== -1) synchroniseSuggestionsBox();
    }

    else if (e.keyCode === 13) {
        /* ... */
    }
}

Whenever the up or down arrow key is pressed, and if after this sIndex isn't equal to -1 (which means that indeed some suggestion is to be highlighted), we call synchroniseSuggestionsBox().

Simple as cake! And this marks an end to this tiring, but fruitful, discussion.

Live Example