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:
Now while the cursor is still inside inputArea
, you press the down arrow key and consequently the first suggestion gets highlighted.
You again press the down key and the second suggestion gets highlighted, with the first one being unhighlighted:
After this, you press the up arrow key and the previous configuration resumes:
To end with you press the up key once more and the first suggestion gets unhighlighted, taking us back to the initial configuration:
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.
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:
- No suggestion is currently highlighted.
- The first suggestion is highlighted.
- 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:
- If no suggestion is currently highlighted, the last suggestion should be highlighted.
- If the first suggestion is highlighted, it should be unhighlighted.
- 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:
- If no suggestion is currently highlighted, the first suggestion should be highlighted.
- If the last suggestion is highlighted, it should be unhighlighted.
- 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...
keydown
will continue firing, whereas keyup
won't, unless and until they key is released.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.
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.
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";
}
/* ... */
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.
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
.
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!
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.
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.
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.
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:
- 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. - 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
.
You shall assume that the following variables are available:
suggestionsBoxHeight
: the height ofsuggestionsBox
.suggestionHeight
: the height of the suggestion element.suggestionOffsetTop
: theoffsetTop
of the suggestion, relative tosuggestionsBox
.
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:
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
}
}
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
}
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.