Autocomplete Multiple Selections

Chapter 9 22 mins

Learning outcomes:

  1. What are multiple selections
  2. Creating checkboxes
  3. Handling suggestion selections
  4. Solving problems
  5. Making tags interactive

Introduction

Suppose that you are filling an online form for an internship in your area, and have to list all the programming languages that you know.

The input where you'll be entering the names of the languages autocompletes queries. Each of the suggestions is the label of a checkbox, which upon click, gets checked. We have to make selections from this autocompleter, which will line up one after another, as tags, right above the input element.

In this chapter we shall see how to program an autocompleter to treat suggestions as checkboxes, and allow the selection of multiple of them.

Benefits of autocompletion

Without an autocompleter, selecting multiple choices from a given list of items can be a tiring procedure.

For example, suppose that we have to select the programming languages we have expertise on, from a list of over a hundred languages. Without an autocompleter, the list would look something as follows:

We would have to go to through all the options one by one and see if the option we're looking for exists; then select it; and maybe repeat this procedure to select another option, and so on. And once we've selected all the desired options, to review them we would have to, once again, go through the entire list.

At least, in the example above, this may not seem tiring - as there are just a hundred options to go through. However, when the list's length goes over just a thousand, for sure this work starts to become strenous.

As we learnt in the very first Autocomplete Introduction chapter, an autocompleter serves to reduce the amount of time required to reach to an end value, in a given list of values.

In this case, we can clearly see that using an autocompleter can keep us from having to go through the tiring manual work of scrutinizing the whole list of options and selecting the ones we desire.

With an autocompleter in action, we merely need to type the initial characters of our desired option, select it from the list of a few suggestions that pops up, and finally move on to the next option.

A definite time-saver!

Understanding the flow

Before we start coding this autocompleter, we need to understand its flow - how does every suggestion become a checkbox, how do we check/uncheck given items, and so on.

Without this knowledge we wouldn't know what we have to actually code for.

Always remember one thing: coding should never begin on a computer - it should begin in your brain, then to your notebook, and finally to your computer!

So let's understand the flow..

Say we have an input field, where we could type in a query, that gets autocompleted. Furthermore, suppose that list is equal to ["Pizza", "Pasta", "Coffee"] and the query entered is "p".

The suggestions shown in suggestionsBox for this query would be the following:

Pizza
Pasta

If we click on a given suggestion, it will get selected (that is, it <input type="checkbox"> element will get checked) and put as a tag above inputArea.

Pizza
Pizza
Pasta

This is done so that we know of the items that we've checked.

Without this, we would have to, as before, go through the whole list of options manually (assuming that upon focus of inputArea, suggestionsBox is displayed with all items) and see the items that we've checked.

Moreover, for easing the task of unchecking an item, we make its displayed tag interactive. That is, we could uncheck it just by pressing the remove button (represented by x) within it.

With all this information in hand we can now finally start gathering the tools required to code an autocompleter that allows for selecting multiple items.

Preplanning

We'll start by defining a global variable multipleSelections that specifies whether multiple choices can be selected or not.

var multipleSelections = true;

Now let's see where will a check need to be laid out for this variable.

Firstly, when we're constructing each suggestion in the for loop inside the onkeyup handler, if multiple selections are desired, we need to additionally add an input element of type="checkbox" before each suggestion.

This is crucial for a functional form.

Remember that when we are enabling multiple selections in an autocompleter, we are in effect, just providing a simpler interface to the user to check a number of items from a list of checkboxes. Checkboxes are input elements and therefore part of a form, which means that their value is sent to the server when a form is submitted.

Ignoring them would simply mean that when we submit the form, since all the options that we've checked in our autocompleter aren't true checkbox input elements, nothing would be sent to the server. So it follows that we do need to create a checkbox for each suggestion.

Secondly, when we select a given suggestion in suggestionsBox, if multiple selections are desired, we don't need to put the value of the suggestion inside inputArea - rather we need to create an element, put the value of the suggestions inside it, and append it outside inputArea.

As emphasized before, this is done so that the user knows of his/her selections.

So far, we see that these are the only two places where multipleSelections ought to be checked for a truthy value.

It's now time to hop onto coding.

Creating a checkbox

First thing's first, in this section we shall see how to give a checkbox next to each suggestion, if multipleSelections is true.

We start by laying out a check for multipleSelections right where we create each suggestion inside our onkeyup handler:

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

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

if (multipleSelections) { /* multiple selection logic will go here */ }
suggestions.push('<li class="suggestion" data-index="' + i + '">' + processSuggestion(list[i], highlightedValue) + '</li>'); } } /* ... code here ... */ }

The code for attaching a checkbox next to a suggestion will go inside this conditional block. Let's see how it would be done.

If multipleSelections is true, this means that we need to add an <input type="checkbox"> element right inside the <li> element, before the suggestion's text. Otherwise, we just need to proceed as normal.

What we can do over here is to save the return value of processSuggestion() inside a variable suggestionStr.

If multipleSelections is true, we simply concatenate '<input type="checkbox">' with suggestionStr and put the result back into suggestionStr. Otherwise, we do nothing!

Finally, we put suggestionStr between the string '<li>' and '</li>', so that depending on the value of multipleSelections, the corresponding string is put within the <li> tags.

Consider the code below:

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

    var index, highlightedValue, item, suggestionStr;
    for (var i = 0; i < list.length; i++) {
        /* code here */
        if (index !== -1) {
            /* ... code here ... */
suggestionStr = processSuggestion(list[i], highlightedValue); if (multipleSelections) { suggestionStr = '<input type="checkbox"> ' + suggestionStr; }
suggestions.push('<li class="suggestion" data-index="' + i + '">' + suggestionStr + '</li>'); } } /* ... code here ... */ }

In short, if multiple selections are desired, only then do we add a checkbox before each suggestion's text; otherwise, everything goes as normal.

Handling suggestion selection

Previously, when we used to select a suggestion, by either clicking on it or pressing the enter key while it was highlighted, the value of the suggestion obtained via getSuggestionText() was put right inside inputArea.

Uptil then this idea worked well. However, now that we have to not just select one item, but possibly a couple of items, this won't work.

We can't just put the value of the selected suggestion into inputArea.

Rather what we need to do is that, upon selection, create a tag containing the text of the suggestion (as returned by getSuggestionText()) and put this tag somewhere outside inputArea, such as in a <div> element inside the autocompleter's main container.

All these tags essentially represent the items that we've selected in an autocompleter. Without them, it'll be quite tedious for the us to go through the whole list of items in the autocompleter and see the ones that we've ticked.

For the moment we shall assume that our autocompleter has the following markup, where #ac-tags is the container where all tags would be lined up.

<div id="autocomplete">
    <div id="ac-tags"></div>
    <input type="text" id="ac-input">
    <div id="ac-suggestions"></div>
</div>

Furthermore, we shall also assume that the element #ac-tags is saved in our autocomplete script in the variable tagCont, as shown below:

var tagCont = document.getElementById("ac-tags");

With the place settled where all the tags would be dumped in, we can now focus on coding.

Remember that if multipleSelections is false, then we need to stick to our old statement that puts the value of the selected suggestion in inputArea.

When a suggestion is selected, we first of all check whether multiple selections are desired. If they are, then we start by checking the checkbox inside the suggestion and then we work on creating a tag for the suggestion by the following sequence of statements:

  1. Create a <span> element of class "ac-tag".
  2. Set the element's innerHTML property to the return value of getSuggestionText() as invoked on the given suggestion element.
  3. Append the element inside a container.

This is illustrated in the following code:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    // if multiple selections are desired
    // create a tag for this suggestion
    if (multipleSelections) {
        // check the checkbox of this suggestion
        suggestion.childNodes[0].checked = true

        // create a <span> element and append it to tagCont
        var tag = document.createElement("span");
        tag.className = "ac-tag";
        tag.innerHTML = getSuggestionText(list[i]);
        tagCont.appendChild(tag);
    }

    // otherwise, follow the old setup
    else { 
        inputArea.value = getSuggestionText(list[i]);
        hideSuggestionsBox();
    }
}

Notice the local variable i defined in line 2. It holds the value of the data-index attribute of the suggestion argument. Since this value is used in two places i.e in line 12 and line 18, we save it in a variable for ease of access.

If we didn't create the variable i here, we would've had to use the ugly expression Number(suggestion.getAttribute("data-index")) in both the lines 12 and 18, which doesn't sound or seem good!

Let's also style these tags so that they look like tags, and not like some sort of gibberish text lying here and there:

.ac-tag {
    /* make the next padding rule applicable */
    display: inline-block;

    /* give breathing space to each tag */
    padding: 5px 10px;

    /* give a visible background to each tag */
    background-color: #ddd;

    /* optional styles */
    margin: 3px;
}

Now let's test this simple build up and see any flaws in it, if they exist.

Live Example

Solving issues

Surely, just this much code puts forward a couple of flaws to deal with as highlighted below:

  1. When we select a suggestion its checkbox gets checked right away. So far so good. However, when we further type in inputArea and the checked suggestion shows up once again its checkbox becomes unchecked.
  2. Secondly, when a checked suggestion is selected, it doesn't get unchecked.
  3. Moreover, when a checked suggestion is selected, its corresponding tag doesn't also get cleared up.

As you shall see in the section below, all these three flaws can be addressed very easily using one single identifier.

Let's see how to solve each of these issues.

Retain selections

In solving the first issue, we straightaway need to check the checkbox of a given suggestion if it had been selected previously. And before this we need to come up with some way of determining whether a given suggestion had been checked previously.

Can you come up with one?

Well we can create a global array selectedRegistry that keeps a track of which suggestions have been selected and which suggestions have not.

If the nth element of selectedRegistry is true, this means that the nth element of list is selected.

Below we define the global variable selectedRegistry:

var selectedRegistry = [];

When we select an item, that has not been selected previously, we right away set its corresponding selectedRegistry element to true, as can be seen below:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    if (multipleSelections) {
        suggestion.childNodes[0].checked = true

        var tag = document.createElement("span");
        tag.className = "ac-tag";
        tag.innerHTML = getSuggestionText(list[i]);
        tagCont.appendChild(tag);

        selectedRegistry[i] = true;
    }

    else { 
        inputArea.value = getSuggestionText(list[i]);
        hideSuggestionsBox();
    }
}

Now, while processing a suggestion item in the onkeyup handler for multiple selections, we could further check if its corresponding element in selectedRegistry is true and if it is, we could check its checkbox.

This is illustrated below:

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

    var index, highlightedValue, item, suggestionStr;
    for (var i = 0; i < list.length; i++) {
        /* code here */
        if (index !== -1) {
            /* ... code here ... */
            suggestionStr = processSuggestion(list[i], highlightedValue);
            if (multipleSelections) {
suggestionStr = '<input type="checkbox"' + (selectedRegistry[i] ? ' checked' : '') + '> ' + suggestionStr;
} suggestions.push('<li class="suggestion" data-index="' + i + '">' + suggestionStr + '</li>'); } } /* ... code here ... */ }
To learn more about the conditional operator ?: used in line 11, head over to JavaScript Ternary Operator.

In this way, if any suggestion is detected to have been selected before i.e selectedRegistry[i] is true, its checkbox gets checked.

Live Example

And this solves the first issue.

Uncheck selected suggestions

Moving on to the second issue, selectedRegistry can also solve this one. Here's how:

When a suggestion is selected, we could check whether the corresponding element in selectedRegistry is true.

If it is, we know that the suggestion is currently selected, and so we unselect it and set the corresponding element in selectedRegistry to false.

Programmatically, this is extremely easy to implement:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    if (multipleSelections) {
        // if the current suggestion is unselected, select it
        if (!selectedRegistry[i]) {

            // set the corresponding element in selectedRegistry to true
            selectedRegistry[i] = true;

            suggestion.childNodes[0].checked = true;

            var tag = document.createElement("span");
            tag.className = "ac-tag";
            tag.innerHTML = getSuggestionText(list[i]);
            tagCont.appendChild(tag);
        }

        // otherwise, unselect it
        else {
            suggestion.childNodes[0].checked = false;
            selectedRegistry[i] = false;
        }
    }

    else { 
        inputArea.value = getSuggestionText(list[i]);
        hideSuggestionsBox();
    }
}

Note that it's very important to set selectedRegistry[i] equal to true when we select a suggestion and similarly set it equal to falde when we unselect it.

Live Example

Two issues successfully solved, we are now just one step behind from making this multiple selection subfeature functional.

Clear up tags

The third issue is a bit challenging, but can be once again solved using the same selectedRegistry array. Let's review the issue.

When we select a selected suggestion, its corresponding tag doesn't clear up.

When we select a selected suggestion, we simply need to remove its corresponding tag lined up in tagCont. And for this we need to have a reference of the tag of the suggestion.

Without having this reference, we can't remove the tag - since we don't know which tag to remove.
A clever solution is to save the reference of the tag of a selected suggestion inside selectedRegistry, instead of the value true. In this way, we know the tag corresponding to each selected suggestion.

Now how do we save the reference of the tag in the first place?

Well, when we select a suggestion that has not been selected yet, we create a tag for it, and then assign the tag element to the corresponding element in selectedRegistry; instead of assigning the value true.

Now, on the subsequent occasion of selecting this very suggestion, since our code detects that it is selected already and so it must be unselected, it removes the tag element saved in selectedRegistry[i].

All this is summed up as follows:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    if (multipleSelections) {
        // if the current suggestion is unselected, select it
        if (selectedRegistry[i]) {
            suggestion.childNodes[0].checked = true;

            var tag = document.createElement("span");
            tag.className = "ac-tag";
            tag.innerHTML = getSuggestionText(list[i]);
            tagCont.appendChild(tag);

            // save the tag's reference in the corresponding index in selectedRegistry
            selectedRegistry[i] = tag;
        }

        else {
            suggestion.childNodes[0].checked = false;
            selectedRegistry[i].parentNode.removeChild(selectedRegistry[i]);
            selectedRegistry[i] = false;
        }
    }

    else { 
        inputArea.value = getSuggestionText(list[i]);
        hideSuggestionsBox();
    }
}

Live Example

Just one array solved an array of problems. Amazing!

Making tags interactive

Say we select "Python" and "JavaScript" from a list of suggestions, where we could select multiple choices. Now suppose we want to remove our selection "Python".

With the above code in place, what we'll need to do is type 'p' into inputArea, then from the list of suggestions go to "Python" and uncheck it.

Don't you think this is too much long-winded? When we have selected "Python", why not make its tag interactive, like by giving it a delete button, so that we can remove it just one click.

This is what we may accomplish in this section - make each tag interactive.

When creating a tag element for a given suggestion, besides putting the suggestion's text inside it, we shall now append a button element as well.

This button will serve to remove the tag, and uncheck the corresponding suggestion, upon its click.

The code below appends a button within each tag created:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    if (multipleSelections) {

        if (selectedRegistry[i]) {
            suggestion.childNodes[0].checked = true;

            var tag = document.createElement("span");
            tag.className = "ac-tag";
tag.innerHTML = getSuggestionText(list[i]) + '<button class="ac-remove-btn">x</button>';
tagCont.appendChild(tag); selectedRegistry[i] = tag; } /* code here */ } /* code here */ }

After creating a button, next we need to handle its click.

Giving a click handler to each button would otherwise be quite inefficient and time-consuming. What we would rather do is give a click handler to the tagCont element, and check if the target of the click is a button element.

Consider the code below:

tagCont.onclick = function(e) {
    var t = e.target;
    while (t !== tagCont) {
        if (t.className === "ac-remove-btn") {
            t = t.parentNode;
            t.parentNode.removeChild(t);
            return;
        }
        t = t.parentNode;
    }
}

When a click is made in tagCont, we go over all the elements starting from the click's target upto tagCont and see if any one of these is .ac-remove-btn. If there is an .ac-remove-btn element in this chain, we remove it's parent element.

We have to remove the parent of the .ac-remove-btn button, which is the whole .ac-tag element, not the button itself. This is the purpose of line 5 in the code above - it assigns to t the parent of the button .ac-remove-btn.

This solves half of our issue.

One thing is still missing over here. When we remove a tag, we also need to unselect the corresponding suggestion.

How to do this?

Well, for this we need to know which suggestion does a given tag correspond to. And this can only be achieved if in each tag we add some property that holds the index of the corresponding item in list

For this we'll need to go back to the place where we create each tag. Consider the code below:

function selectSuggestion(suggestion) {
    var i = Number(suggestion.getAttribute("data-index"));

    if (multipleSelections) {
        if (selectedRegistry[i]) {
            suggestion.childNodes[0].checked = true;

            var tag = document.createElement("span");
            tag.className = "ac-tag";

            // save the suggestion's index in the tag
            tag.acSuggestionIndex = i;

            tag.innerHTML = getSuggestionText(list[i]) + '<button class="ac-remove-btn">x</button>';
            tagCont.appendChild(tag);

            selectedRegistry[i] = tag;
        }

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

When we create a tag for a given suggestion, we set a property acSuggestionIndex on it and assign it the value i i.e the index of the suggestion element in list.

Now when a tag's remove button is clicked, we remove the whole tag, and set selectedRegistry[acSuggestionIndex] to false to indicate that the corresponding suggestion has been unselected.

tagCont.onclick = function(e) {
    var t = e.target;
    while (t !== tagCont) {
        if (t.className === "ac-remove-btn") {
            t = t.parentNode;
            selectedRegistry[t.acSuggestionIndex] = false;            
            t.parentNode.removeChild(t);
            return;
        }
        t = t.parentNode;
    }
}

In this way, the next time the same suggestion is processed inside the onkeyup handler, the respective code would determine it to be unselected and so won't check it checkbox.

Live Example

One word to describe this - perfect!