Creating an Autosuggest Textbox with JavaScript, Part 2

By Nicholas C. Zakas.

In the first part of this series, you learned how to create type ahead functionality in a textbox, which presents the user with a single suggestion for what they've already typed. This article builds upon that functionality by adding a dropdown list of multiple suggestions. To do so, you'll extend the autosuggest control and suggestion provider class definitions.

Creating the Dropdown List

The dropdown suggestion list is nothing more than an absolutely-positioned <div/> containing several other <div/>s. It's placed directly under the textbox to create the appearance of a dropdown menu (see the image below).

The HTML for the suggestion list in the image above looks like this:

<div class="suggestions">
    <div class="current">Maine</div>
    <div>Maryland</div>
    <div>Massachusetts</div>
    <div>Michigan</div>
    <div>Minnesota</div>
    <div>Mississippi</div>
    <div>Missouri</div>
    <div>Montana</div>
</div>

Of course, some CSS is needed to make the dropdown list function properly. The outermost <div/> has a class of "suggestions," which is defined as:

div.suggestions {
    -moz-box-sizing: border-box;
    box-sizing: border-box;
    border: 1px solid black;
    position: absolute;
}

The first two lines of this CSS class are for browsers that support two forms of box sizing: content box and border box (for more information, read http://www.quirksmode.org/css/box.html). In quirks mode, Internet Explorer defaults to border box; in standards mode, IE defaults to content box. Most other DOM-compliant browsers (Mozilla, Opera, and Safari) default to content box, meaning that there is a difference in how the <div/> will be rendered among browsers. To provide for this, the first two lines of the CSS class set specific properties to border box. The first line, -moz-box-sizing, is Mozilla-specific and used for older Mozilla browsers; the second line is for browsers that support the official CSS3 box-sizing property. Assuming that you use quirks mode in your page, this class will work just fine (if you use standards mode, simply remove these first two lines).

For the other two lines of the suggestions CSS class, simply add a border and specify that the <div/> is absolutely positioned.

Next, a little bit of formatting is need for the dropdown list items:

div.suggestions div {
    cursor: default;
    padding: 0px 3px;
}

The first line specifies the default cursor (the arrow) to be displayed when the mouse is over an item in the dropdown list. Without this, the cursor would display as the caret, which is the normal cursor for textboxes and Web pages in general. The user needs to realize that the dropdown item is not a part of the regular page flow and changing the cursor helps define that. The second line simply applies some padding to the item (which you can modify as you wish).

Lastly, some CSS is needed to format the currently selected item in the dropdown list. When an item is selected, the background will be changed to blue and the color will be changed to white. This provides a basic highlight that is typically used in dropdown menus. Here's the code:

div.suggestions div.current {
    background-color: #3366cc;
    color: white;
}

Now that you understand how the dropdown list will be built, it's time to begin scripting it.

Scripting the Dropdown List

Creating the DOM representation for the dropdown list is a multi-stage process. First, a property is needed to store the <div/> because various methods of the AutoSuggestControl need access to it. This property is called layer and is initially set to null:

function AutoSuggestControl(oTextbox, oProvider) {
    this.layer = null;

    this.provider = oProvider;
    this.textbox = oTextbox;
    this.init();
}

The layer and the other parts of the dropdown list DOM will be created after you define a few simple methods to help control its behavior. The simplest method is hideSuggestions(), which hides the dropdown list after it has been shown:

AutoSuggestControl.prototype.hideSuggestions = function () {
    this.layer.style.visibility = "hidden";
};

Next, a method is needed for highlighting the current suggestion in the dropdown list. The highlightSuggestion() method accepts a single argument, which is the <div/> element containing the current suggestion. This method's purpose is to set the <div/>'s class attribute to "current" on the current suggestion and clear the class attribute on all others in the list. Doing so provides a highlighting effect on the dropdown list similar to the regular form controls. The algorithm is quite simple: iterate through the child nodes of the layer. If the child node is equal to the node that was passed in, set the class to "current," otherwise clear the class attribute by setting it to an empty string:

AutoSuggestControl.prototype.highlightSuggestion = function (oSuggestionNode) {

    for (var i=0; i < this.layer.childNodes.length; i++) {
        var oNode = this.layer.childNodes[i];
        if (oNode == oSuggestionNode) {
            oNode.className = "current"
        } else if (oNode.className == "current") {
            oNode.className = "";
        }
    }
};

With these two methods defined, it's time to create the dropdown list <div/>. The createDropDown() method creates the outermost <div/> and defines the event handlers for the dropdown list. To create the <div/>, use the createElement() method and then assign the various styling properties:

AutoSuggestControl.prototype.createDropDown = function () {

    this.layer = document.createElement("div");
    this.layer.className = "suggestions";
    this.layer.style.visibility = "hidden";
    this.layer.style.width = this.textbox.offsetWidth;
    document.body.appendChild(this.layer);


    //more code to come
};

The code above first creates the <div/> and assigns it to the layer property. From there, the className (equivalent to the class attribute) is set to "suggestions", as is needed for the CSS to work properly. The next line hides the layer, since it should be invisible initially. Then, the width of the layer is set equal to the width of the textbox by using the textbox's offsetWidth property (this is optional dependent on your individual needs). The very last line adds the layer to the document. The next step is to assign the event handlers.

Assigning Event Handlers

There are three event handlers that need to be assigned: onmouseover, onmousedown and onmouseup. The onmouseover event handler is simply used to highlight the current suggestion, the onmousedown is used to select the given suggestion (place the suggestion in the textbox and hide the dropdown list) and onmouseup is used to just to set focus back to the textbox after a selection has been made. Since all of these event handlers occur on the textbox, it's best just to use a single function for all of them, as seen below:

AutoSuggestControl.prototype.createDropDown = function () {

    this.layer = document.createElement("div");
    this.layer.className = "suggestions";
    this.layer.style.visibility = "hidden";
    this.layer.style.width = this.textbox.offsetWidth;
    document.body.appendChild(this.layer);

    var oThis = this;

    this.layer.onmousedown = this.layer.onmouseup =
    this.layer.onmouseover = function (oEvent) {
        oEvent = oEvent || window.event;
        oTarget = oEvent.target || oEvent.srcElement;

        if (oEvent.type == "mousedown") {
            oThis.textbox.value = oTarget.firstChild.nodeValue;
            oThis.hideSuggestions();
        } else if (oEvent.type == "mouseover") {
            oThis.highlightSuggestion(oTarget);
        } else {
            oThis.textbox.focus();
        }
    };


};

The first part of this section is the assignment of oThis equal to the this object. This is necessary so that a reference to the AutoSuggestControl object is accessible from within the event handler. Next, a compound assignment occurs, assigning the same function as an event handler for onmousedown, onmouseup, and onmouseover. Inside of the function, the first two lines are used to account for the different event models (DOM and IE). The event object is stored in oEvent and the target of the event is stored in oTarget (the target will always be a <div/> containing a suggestion).

If the event being handled is mousedown, then you set the value of the textbox equal to the text inside of the event target. The text is a text node, retrieved by the firstNode property, and the actual text is stored in the nodeValue of the text node. After the suggestion is placed into the textbox, the dropdown list is hidden.

When the event being handled is mouseover, the event target is passed into the highlightSuggestion() method to provide the hover effect. If the event is mouseup, then the focus is set back to the textbox (this fires immediately after mousedown).

Positioning the Dropdown List

In order to get the full effect of the dropdown list it's imperative that it appear immediately below the textbox. If the textbox were absolutely positioned, this wouldn't be much of an issue. In actual practice, textboxes are rarely absolutely positioned and more often are placed inline, which presents a problem in aligning the dropdown list. To do so accurately, you must calculate the location of the textbox using the offsetLeft, offsetTop, and offsetParent properties.

The offsetLeft and offsetTop properties tell you how many pixels away from the left and top of the offsetParent an element is placed. The offsetParent is usually, but not always, the parent node of the element, so to get the left position of the textbox, you need to add up the offsetLeft properties of the textbox and all of its ancestor elements (stopping at <body/>), as seen below:

AutoSuggestControl.prototype.getLeft = function () {

    var oNode = this.textbox;
    var iLeft = 0;

    while(oNode.tagName != "BODY") {
        iLeft += oNode.offsetLeft;
        oNode = oNode.offsetParent;
    }

    return iLeft;
};

The getLeft() method begins by pointing oNode at the textbox and defining iLeft with an initial value of 0. The while loop will continue to add oNode.offsetLeft to iLeft as it traverses up the DOM structure to the <body/> element. The same algorithm can be used to get the top of the textbox:

AutoSuggestControl.prototype.getTop = function () {

    var oNode = this.textbox;
    var iTop = 0;

    while(oNode.tagName != "BODY") {
        iTop += oNode.offsetTop;
        oNode = oNode.offsetParent;
    }

    return iTop;
};

These two methods will be used to place the dropdown list in the correct location.

Adding and Display Suggestions

The next step in this process is to create a method that adds the suggestions into the dropdown list and then displays it. The showSuggestions() method accepts an array of suggestions as an argument and then builds up the DOM necessary to display them. From there, the method positions the dropdown list and displays it to the user:

AutoSuggestControl.prototype.showSuggestions = function (aSuggestions) {

    var oDiv = null;
    this.layer.innerHTML = "";

    for (var i=0; i < aSuggestions.length; i++) {
        oDiv = document.createElement("div");
        oDiv.appendChild(document.createTextNode(aSuggestions[i]));
        this.layer.appendChild(oDiv);
    }

    this.layer.style.left = this.getLeft() + "px";
    this.layer.style.top = (this.getTop()+this.textbox.offsetHeight) + "px";
    this.layer.style.visibility = "visible";
};

The first line simply defines the variable oDiv for later use. The second line clears the contents of the dropdown list by setting the innerHTML property to an empty string. Then, the for loop creates a <div/> and a text node for each suggestion before adding it to the dropdown list layer.

The next section of code starts by setting the left position of the layer using the getLeft() method. To set the top position, you need to add the value from getTop() to the height of the textbox (retrieved by using the offsetHeight property). Lastly, the layer's visibility is set to "visible" to show it.

Updating the Autosuggest Functionality

Remember the autosuggest() method from the last article? To implement the dropdown list of suggestions it's necessary to update this method.

The first update is the addition of a second argument which indicates whether or not the type ahead functionality should be used (the reason why will be explained shortly). Naturally, the typeAhead() method should only be called if this argument is true. If there's at least one suggestion, type ahead should be used and the dropdown list of suggestion should be displayed by calling showSuggestions() and passing in the array of suggestions; if there's no suggestions, the dropdown list should be hidden by calling hideSuggestions():

AutoSuggestControl.prototype.autosuggest = function (aSuggestions,
bTypeAhead
) {

    if (aSuggestions.length > 0) {
        if (bTypeAhead) {
            this.typeAhead(aSuggestions[0]);
        }
        this.showSuggestions(aSuggestions);
    } else {
        this.hideSuggestions();

    }
};

You will also remember that this method is called from the suggestion provider's requestionSuggestions() method, which means it too must be updated. This is a fairly easy update; you need only add a second argument and then pass it back into the autosuggest() method when it's called:

StateSuggestions.prototype.requestSuggestions = function (oAutoSuggestControl,
bTypeAhead
) {
    var aSuggestions = [];
    var sTextboxValue = oAutoSuggestControl.textbox.value;

    if (sTextboxValue.length > 0){

        for (var i=0; i < this.states.length; i++) {
            if (this.states[i].indexOf(sTextboxValue) == 0) {
                aSuggestions.push(this.states[i]);
            }
        }
    }

    oAutoSuggestControl.autosuggest(aSuggestions, bTypeAhead);
};

With both of these methods updated, it's now necessary to update the handleKeyUp() method. First, just add the second argument (true) when calling requestSuggestions():

AutoSuggestControl.prototype.handleKeyUp = function (oEvent) {

    var iKeyCode = oEvent.keyCode;

    if (iKeyCode < 32 || (iKeyCode >= 33 && iKeyCode <= 46) || (iKeyCode >= 112 && iKeyCode <= 123)) {
        //ignore
    } else {
        this.provider.requestSuggestions(this, true);
    }
};

This functionality now works exactly as it did previously, but there are a couple of other keys that require special attention: Backspace and Delete. When either of these keys are pressed, you don't want to activate the type ahead functionality because it will disrupt the process of removing characters from the textbox, but there's no reason not to show the dropdown list of suggestions. For the Backspace (key code of 8) and Delete (key code of 46) keys, you can also call requestSuggestions(), but this time, pass in false to indicate that type ahead should not occur:

AutoSuggestControl.prototype.handleKeyUp = function (oEvent) {

    var iKeyCode = oEvent.keyCode;

    if (iKeyCode == 8 || iKeyCode == 46) {
        this.provider.requestSuggestions(this, false);

    } else
if (iKeyCode < 32 || (iKeyCode >= 33 && iKeyCode <= 46) || (iKeyCode >= 112 && iKeyCode <= 123)) {
        //ignore
    } else {
        this.provider.requestSuggestions(this, true);
    }
};

Now when the user is removing characters, suggestions will still be provided and the user can click on one of them to select the value for the textbox. This is acceptable, but to really be usable the autosuggest control needs to respond to keyboard controls.

Adding Key Support

The desired keyboard functionality revolves around three keys: the up arrow, the down arrow and Enter (or Return). When the dropdown suggestion list is displayed, you should be able to press the down arrow to highlight to the first suggestion, then press it again to move to the second, and so on. The up arrow should then be used to move back up the list of suggestions. As each suggestion is highlighted, the value must be placed in the textbox. When the Enter key is pressed, the suggestions should be hidden, leaving the last suggestion to be highlighted in the textbox.

When the user scrolls through the suggestions in the list, you must know which suggestion is current. To do this, a property must be added to the AutoSuggestControl definition as follows:

function AutoSuggestControl(oTextbox, oProvider) {
    this.cur = -1;
    
this.layer = null;

    this.provider = oProvider;
    this.textbox = oTextbox;
    this.init();
}

The cur property stores the index of the current suggestion in the suggestions array. By default, this is set to -1 because this there are no suggestions initially.

When the down arrow key is pressed, the next suggestion in the dropdown list should be highlighted. To encapsulate this functionality, a method named nextSuggestion() will be added. Here's the code:

AutoSuggestControl.prototype.nextSuggestion = function () {
    var cSuggestionNodes = this.layer.childNodes;

    if (cSuggestionNodes.length > 0 && this.cur < cSuggestionNodes.length-1) {
        var oNode = cSuggestionNodes[++this.cur];
        this.highlightSuggestion(oNode);
        this.textbox.value = oNode.firstChild.nodeValue;
    }
};

This method obtains the collection of child nodes in the dropdown layer. Since only the <div/> elements containing the suggestions are child nodes of the layer, the number of child nodes accurately matches the number of suggestions. This number can be used to determine if there are any suggestions (in which case it will be greater than 0) and also if there is a next suggestion (which means that it's greater than cur). To ensure that cur never points to an empty node, it must never be allowed to be larger than the number of child nodes minus 1 (because the last element in a collection with n elements is n-1).

If it these two tests are passed, then cur is incremented and the child node in that position is retrieved and stored in oNode. Next, the node is passed in to highlightSuggestion(), which highlights it and unhighlights the previously highlighted suggestion. From there, the value of the textbox is once again set to the text contained inside of the <div/>.

As you may have suspected, another method to highlight the previous suggestion is also necessary. Here it is:

AutoSuggestControl.prototype.previousSuggestion = function () {
    var cSuggestionNodes = this.layer.childNodes;

    if (cSuggestionNodes.length > 0 && this.cur > 0) {
        var oNode = cSuggestionNodes[--this.cur];
        this.highlightSuggestion(oNode);
        this.textbox.value = oNode.firstChild.nodeValue;
    }
};

The previousSuggestion() method is similar to nextSuggestion(). The main differences are that you need to ensure cur is greater than 0 to proceed (you still must make sure that there are suggestions by checking the number of child nodes in the dropdown layer) and that cur must be decremented instead of incremented. Other than these two changes, the algorithm is the same. Now back to the three keys.

To handle the up arrow, down arrow, and Enter keys, a handleKeyDown() method is necessary. Similar to handleKeyUp(), this method also requires the event object to be passed in. Once again, you'll need to rely on the key code to tell which key was pressed. The key codes for the up arrow, down arrow, and Enter keys are 38, 40, and 13, respectively. The handleKeyDown() method is defined as follows:

AutoSuggestControl.prototype.handleKeyDown = function (oEvent) {
    switch(oEvent.keyCode) {
        case 38: //up arrow
            this.previousSuggestion();
            break;
        case 40: //down arrow
            this.nextSuggestion();
            break;
        case 13: //enter
            this.hideSuggestions();
            break;
    }
};

Remember, when the user presses the up or down arrows, the suggestion is automatically placed into the textbox. This means that when the Enter key is pressed, you need only hide the dropdown list of suggestions.

Updating init()

Now that all of this new functionality has been added, it must be initialized. Previously, the init() method was used to set up the onkeyup event handler, now it must be extended to also set up the onkeydown and onblur event handlers, as well as create the dropdown suggestion list. The onkeydown event handler is set up in a similar manner as onkeyup:

AutoSuggestControl.prototype.init = function () {

     var oThis = this;

    this.textbox.onkeyup = function (oEvent) {
        if (!oEvent) {
            oEvent = window.event;
        }

        oThis.handleKeyUp(oEvent);
    };

    this.textbox.onkeydown = function (oEvent) {

        if (!oEvent) {
            oEvent = window.event;
        }

        oThis.handleKeyDown(oEvent);
    };


    //more code to come
};

As you can see, the same algorithm is used with the onkeydown event handler: first determine the location of the event object, then pass it into the handleKeyDown() method.

Up to this point, the only way the dropdown list is hidden is when the user hits the Enter key. But what if the user clicks elsewhere on the screen or uses the Tab key to switch to a new form field? To prepare for this, you must set up an onblur event handler that hides the suggestions whenever the textbox loses focus:

AutoSuggestControl.prototype.init = function () {

    var oThis = this;

    this.textbox.onkeyup = function (oEvent) {
        if (!oEvent) {
            oEvent = window.event;
        }

        oThis.handleKeyUp(oEvent);
    };

    this.textbox.onkeydown = function (oEvent) {

        if (!oEvent) {
            oEvent = window.event;
        }

        oThis.handleKeyDown(oEvent);
    };

    this.textbox.onblur = function () {
        oThis.hideSuggestions();
    };


    this.createDropDown();
};

You'll also notice that the createDropDown() method is called to create the initial dropdown list structure. With the initializations complete, it's time to test out your creation.

Example

The example for the updates is set up in the same way as Part 1. The only difference is in the code that is used and the inclusion of the style sheet:

<html>
    <head>
        <title>Autosuggest Example 2</title>
        <script type="text/javascript" src="autosuggest2.js"></script>
        <script type="text/javascript" src="suggestions2.js"></script>
        <link rel="stylesheet" type="text/css" src="autosuggest.css" />

        <script type="text/javascript">
            window.onload = function () {
                var oTextbox = new AutoSuggestControl(document.getElementById("txt1"), new StateSuggestions());
            }
        </script>
    </head>
    <body>
        <p><input type="text" id="txt1" /></p>
    </body>
</html>

As before, the autosuggest2.js file contains the AutoSuggestControl definition and suggestions2.js contains the StateSuggestions definition. The creation of the AutoSuggestControl is handled in exactly the same way.

You can view the example here (it should work in Internet Explorer 5.5+ and Mozilla 1.0+, including Firefox) or download it.

With the dropdown list and keyboard controls all working properly, there's only one part left to implement: going back to the server to get the suggestions. We'll examine this in the third and final part of this series.

About the Author

Nicholas C. Zakas is a user interface designer for Web applications and is the author of Professional JavaScript for Web Developers (Wiley Press, ISBN 0764579088). Nicholas can be contacted through his Web site, http://www.nczonline.net/, where he provides open source JavaScript libraries and tools.