ajax suggest

advanced ajax, css, js, mysql, php 3385 misterhaan

using ajax to suggest possible values when a user starts typing in a field can help get more consistent values as well as saving time for users. i wanted to add an ajax suggest feature to track7 and couldn’t find all the information i needed in one place, so i decided to write up what i learned. this guide walks through the steps of creating an ajax suggest feature that supports selection with keyboard or mouse from scratch using javascript, css, php, and mysql. usernames are used as an example since that’s the first place track7 used ajax suggest. this guide will make more sense to those with intermediate knowledge of javascript and basic knowledge of ajax and css.

track7 no longer uses this method. an updated guide, ajax user suggest explains how to reach a similar result using jquery and knockout.

generate list of suggestions on the server

suggestions are going to be based off what’s been entered into the field, so the script on the server needs to take that as input. for username suggestions i decided to look for names that start with what was entered and then add names that contain it but don’t start with it. i also chose to limit the number of suggestions to 8 — if what you’re looking for isn’t on the list, type another character and maybe it will be. it’s sometimes more helpful to use more complicated logic — for example, track7 users can mark other users as their friends, so i could have put friends whose names contain the value first on the list before non-friends whose names start with the value, assuming users are more likely to choose their friends.

here’s the actual php code track7 uses to return matching usernames:

define('MAX_SUGGEST', 8);
header('Content-Type: text/plain; charset=utf-8');
$count = 0;
$us = 'select login from users where login like \'' . addslashes($_GET['match']) . '%\' order by login';
if($us = $db->Get($us, '', '', true))
  while($u = $us->NextRecord()) {
    echo "\n" . $u->login;
    if(++$count >= MAX_SUGGEST)
      die("\n<more>");
  }
$us = 'select login from users where not login like \'' . addslashes($_GET['match']) . '%\' and login like \'%' . addslashes($_GET['match']) . '%\' order by login';
if($us = $db->Get($us, '', '', true))
  while($u = $us->NextRecord()) {
    echo "\n" . $u->login;
    if(++$count >= MAX_SUGGEST)
      die("\n<more>");
  }
if(!$count)
  die('<no matches>');

the $db->Get and $us->NextRecord are from auDB — if you aren’t familiar with that, just know that’s how i’m running my queries and looping over the results. the first query finds usernames that start with the value (in $_GET['match']) and the second finds those that don’t start with the value but contain it somewhere else. both loops output a username, increment the count, quit if the count is over the max adding <more>. if it’s still going after both loops, put out <no matches> if nothing was found. the format is plain text with one match per line. there will actually be a blank line first which should be ignored, and if a line is in angle brackets (like <more>) then it is a message and not a match.

if i had included the users who are friends at the top, i may have also included an indicator that those users are friends. that could be done by using some sort of delimiter between username and friend status (still with one line per match, this would be like csv), or go with a structure like xml or json. test the script by visiting its url in a browser with various ?match= values at the end to make sure it’s showing the expected results. since the content type was set to text/plain (php usually defaults to text/html) the browser should display it correctly with one result per line.

prepare the field

now that there is a php script capable of returning matching usernames, we need to set up the field to use it. i did this in a generic way for any sort of suggestion that can come back in the same format as the usernames, which means i wrote a javascript function that takes the field id and the url to the php script as parameters:

/**
 * Enable a form text field for suggestions.
 * @param field element or ID to enable.
 */
function enableSuggest(field, suggestUrl) {
  if(!field.nodeName)  // if not an element, then it's a string so find the element
    field = document.getElementById(field);
  if(field) {
    field.setAttribute("autocomplete", "off");
    field.toreq = false;
    field.ajaxreq = false;
    field.suggestUrl = suggestUrl;
    field.onkeydown = suggestKeyDown;
    field.onkeypress = suggestKeyPress;
    field.onblur = hideSuggestions;
  }
}

the first two lines check if field is actually the field element and look up the element based on field being the id if not. the second if statement makes sure we have an element. next autocomplete is set to off. this tells browsers that remember what was typed into form fields and do their own suggestions that they shouldn’t do that here (because the ajax suggest script will do it better). you could specify this as an attribute in the html, but it will cause your html not to validate and would remove the browser autocomplete for people who have javascript off without replacing it with ajax suggest.

next i initialize two new values on the field to false and set a third value to the url of the php suggestions script. the first two values are to hold timeout and ajax objects in case they need to be canceled (false means there’s nothing to cancel). the third is just so it’s easy to get the url needed later on when it’s time to make the ajax request. last, three events get hooked up to functions so the field will actually do something. we will look at those functions later.

this function is what starts it all — once you have a field that should use ajax suggest and a php script to build a list of suggestions, call this function with the field’s id attribute value and the url to the php script ending with match= so the javascript can add what should be matched to the end. for example, the following line enables the field with id usernameField for ajax suggest using the script usersuggest.php:

enableSuggest("usernameField", "/usersuggest.php?match=");

request suggestions

i handle two keyboard events to make sure the suggestions get updated appropriately: keydown and keypress. i found a lot of differences between internet explorer and firefox in terms of what values on the event object are set and which events get triggered for which keys. the keypress event is the simpler of the two. it needs to ignore keys that don’t actually change the value in the field and then schedules an ajax suggest for keys that do. the scheduling is so it doesn’t ask the server for a list of suggestions for every key press — if the user types three characters quickly enough, it will only send ask once for suggestions based on all 3 characters. here’s the code:

/**
 * Schedule suggestion retrieval for keys that change the field value.  Called
 * by KeyPress event.
 * @param e W3C style event object.
 */
function suggestKeyPress(e) {
  if(!e) {  // get event from ie model (do these together so we don't get firefox's e.keyCode when e.which is 0)
    e = window.event;
    e.which = e.keyCode;
  }
  if(e.which == 0 || e.which == 13 || e.which == 27)  // firefox sends 0 for arrow keys, etc.  ie sends 27 for escape
    return;
  scheduleSuggest(this);
}

the first few lines make sure we have the correct event. non-ie browsers send the event object as the first parameter, but ie sets the event object as a property on the window object. ie only sets the keyCode property on the event, but firefox uses the which property for the character code. sometimes it’s 0 with the keyCode property set to the scan code for the key. checking if there’s a which property to decide whether to look at keyCode won’t work when it’s zero. keys that don’t have a character code as well as return (13) and escape (27) are ignored, but everything else schedules a server request for suggestions.

the request for suggestions is scheduled using setTimeout() so that it runs after a certain amount of time instead of right away. that function returns a reference which can be used to cancel later. the toreq property defined on the field earlier stores that reference. the first thing to do when scheduling a suggestions request is to cancel the previously-scheduled request if there is one. then a new request is scheduled.

/**
 * Schedules an ajax request to retrieve suggestions for the field.  Called by
 * suggestKeyPress or suggestKeyDown.
 * @param field Text field to suggest for.
 */
function scheduleSuggest(field) {
  if(field.toreq) {
    clearTimeout(field.toreq);
    field.toreq = false;
  }
  field.toreq = setTimeout(function() { submitSuggest(field); }, 250);
}

since field.toreq is false when there’s not a request scheduled and is an object when there is a request scheduled, the if statement allows canceling the scheduled request if necessary. when scheduling the request in the last line, it’s necessary to define a function wrapper so that field can be passed to submitSuggest(). the 250 is how many milliseconds to wait — this value should be short enough that the user doesn’t feel like they’re waiting for it but long enough that it’s not making a request for each character typed. 250 seemed like a good fit to me.

once 250 milliseconds has been reached without another key being pressed, it’s time to submit the ajax request for suggestions. if the connection to the server is slow and a previous request is still in progress, that needs to be canceled first. then if there’s a value in the field, send that value with the ajax request. if the field is blank, make sure there’s no suggestions showing.

/**
 * Sends an ajax request for suggestions for a field.
 * @param field Text field to suggest for.
 */
function submitSuggest(field) {
  field.toreq = false;
  if(field.ajaxreq) {
    field.ajaxreq.abort();
    field.ajaxreq = false;
  }
  if(field.value)  // only request if field isn't blank
    field.ajaxreq = getAsync(field.suggestUrl + encodeURIComponent(field.value), suggestFinished, field);
  else if(field.dropdown) {  // if field is blank but a dropdown was showing, get rid of it
    field.dropdown.parentNode.removeChild(field.dropdown);
    field.dropdown = false;
  }
}

since the timeout has reached its time limit, the first thing to do is clear field.toreq so we won’t try to cancel it the next time a key is pressed. then field.ajaxreq is aborted if necessary, similar to how toreq was used in the previous function. the ajax request is then sent using the getAsync function described in my getting started with ajax guide. field.dropdown was defined as false in the field initialization. it gets set to the element containing the suggestions when suggestions are displayed. this code removes it from the document and sets the property to false to show it’s been removed. we will see more of this happening later.

display the suggestions dropdown

once the ajax request completes, there should be a list of suggestions in the response that needs to be turned into html and displayed to the user. since the suggestions are sorted by whether the beginning matches and then alphabetically and only have one piece of information, i use an ordered list and will hide the numbers with css. the response needs to be split into lines and each line added to the list as a list item element. each list element not starting with “<” needs a click event handler so that clicking it will set the field to that value. here’s what the function looks like:

/**
 * Ajax suggest completion handler.  Displays suggestions as a list after the
 * field.
 * @param req XMLHttpRequest object of the ajax request.
 * @param field Text field the suggestions are for.
 */
function suggestFinished(req, field) {
  field.ajaxreq = false;
  var drop = document.createElement("ol");
  drop.className = "suggestdrop";
  var names = req.responseText.split("\n");
  for(var name in names)
    if(names[name]) {
      var li = document.createElement("li");
      li.appendChild(document.createTextNode(names[name]));
      drop.appendChild(li);
      if(names[name].charAt(0) == '<')
        li.className = "message";
      else {
        li.field = field;
        li.onclick = chooseSuggestion;
        li.onmouseover = clearSuggestion;
      }
    }
  if(field.dropdown)
    field.dropdown.parentNode.removeChild(field.dropdown);
  field.dropdown = drop;
  if(field.nextSibling)
    field.parentNode.insertBefore(drop, field.nextSibling);
  else
    field.parentNode.appendChild(drop);
}

the first thing to do is set field.ajaxreq to false so this request does’t get aborted (since it’s finished) if another request gets made. then the dropdown list elements are created. since the response may have blank lines, each line gets checked and skipped if blank. lines starting with “<” are messages not suggestions, so they’re assigned the message class while other lines are given a reference to the field and some event handlers. the mouseover event handler is related to using the arrow keys to choose from the list. lastly, remove the previous suggestions dropdown if there was one, and add the new one immediately after the field.

while this will successfully display a list of suggestions, it’s going to shift the rest of the page down and not look like a list of suggestions the user can choose from. some css rules take care of that:

ol.suggestdrop {
  background-color: #ffffff;
  border: 1px solid #bb9988;
  border-top: none;
  margin: 0;
  padding: 0;
  list-style-type: none;
  position: absolute;
}
ol.suggestdrop li {
  padding: .2em .5em;
  cursor: pointer;
}
ol.suggestdrop li.current,
ol.suggestdrop li:hover {
  color: #000000;
  background-color: #ddeeff;
}
ol.suggestdrop li.message,
ol.suggestdrop li.message:hover {
  color: #bbbbbb;
  background-color: transparent;
  cursor: auto;
  border-top: 1px solid #bbbbbb;
}

since ol.suggestdrop contains all the suggestions, it gets the positioning styles. the background color makes it cover whatever ends up underneath it so we don't have overlapping text. border is to set it off from the rest of the page. margin, padding, and list type are removed. absolute position pulls it out of the page flow and places it on top of anything that comes after it in the html, which all works together to make it look like a dropdown. next the items in the list need some padding to keep the text away from the border, and the cursor is set to pointer like it is for links to help indicate to the user that clicking them will do something. items should change color when the mouse is over them (or when selected using the keyboard — we’ll see how that works next). lastly, override anything that’s a message and not an option so that it doesn’t look like something to click on. this means remove the hover behavior, set the mouse cursor to normal, and set the text color to grey.

support mouse selection

now that the dropdown list of suggestions appears and looks right, it needs to handle clicks on the items. the function for doing that is pretty short:

/**
 * Choose a suggestion, place its value in the field, and hide the results.
 * Called by Click event handler of a suggestion item.
 */
function chooseSuggestion() {
  var field = this.field;
  if(field) {
    field.value = this.firstChild.data;
    if(field.dropdown) {
      field.dropdown.parentNode.removeChild(field.dropdown);
      field.dropdown = false;
    }
  }
}

each item had a field property set to the field element, so the first step is to make sure that’s set. next set the field value to the value of this item (the click event is on the li element, so this.firstChild is the text node the item contains, and this.firtChild.data is the actual text. lastly, remove the field’s dropdown if it has one (it almost assuredly does since how else would there have been a click on one of the dropdown’s items).

in case the user starts using the arrow keys and has something highlighted but then switches to the mouse, we need to clear what was highlighted using the arrow keys to avoid showing two items highlighted at the same time. this is done through the clearSuggestion function, which handles mouseover events:

/**
 * Clears the suggestion chosen by arrow keys.  Called by MouseOver event
 * handler of a suggestion item.
 */
function clearSuggestion() {
  if(this.field.dropdown.current) {
    this.field.dropdown.current.className = "";
    this.field.dropdown.current = false;
  }
}

each item has the field property set, and when a dropdown is visible the field has a dropdown property set. when an item has been highlighted using the keyboard, the dropdown has a current property set. we don’t need to verify that the field and dropdown properties are defined since they always will be if there’s an item to move the mouse over. the current item is highlighted based on a class name, so that gets cleared and then the current property is set to false to indicate nothing is selected by the keyboard.

one last thing for mouse support is to get rid of the suggestions dropdown when the user clicks somewhere else in the page. this is done through the hideSuggestions function that was assigned to the field’s blur event earlier. it clears the timeout, cancels the ajax request, and removes the dropdown from the page:

/**
 * Cancels any waiting suggestion requests and hides any visible suggestions.
 * Called by Blur event handler of the field.
 * @param field Text field to hide suggestions for.
 */
function hideSuggestions(field) {
  field = field || this;
  if(field.toreq) {
    clearTimeout(field.toreq);
    field.toreq = false;
  }
  if(field.ajaxreq) {
    field.ajaxreq.abort();
    field.ajaxreq = false;
  }
  if(field.dropdown) {
    field.dropdown.parentNode.removeChild(field.dropdown);
    field.dropdown = false;
  }
}

the suggestions dropdown is now working for use with the mouse! well, not quite since one event has been hooked up to a function we haven’t yet looked at. it deals with selecting a suggestion using the arrow keys on the keyboard, which is what we will look at next.

support keyboard selection

to support the keyboard for choosing a suggestion, the up and down arrow keys should highlight the previous and next item in the list. pressing enter or tab should set the field value to the highlighted item and hide the dropdown. escape should hide the dropdown.

most of those things are handled through the keydown event. it also handles a couple keys that need to do the same thing as the letter keys yet don’t trigger a keypress event (namely backspace in internet explorer, and delete). this is a much longer function since it handles 7 different keys and each one needs a different action:

/**
 * Handle keys that don't change the field value for suggestion-enabled fields.
 * Called by KeyDown event handler.
 * @param e W3C style event object.
 * @return False if enter was pressed to select from the list, so the event should be canceled.
 */
function suggestKeyDown(e) {
  e = e || window.event;  // get event from ie model
  switch(e.which || e.keyCode) {
    case 8:  // backspace:  send keypress event for ie since it doesn't do that itself
      if(window.event)
        scheduleSuggest(this);
      break;
    case 9:  // tab:  accept selection if there is one
      if(this.dropdown && this.dropdown.current) {
        this.value = this.dropdown.current.firstChild.data;
        this.dropdown.parentNode.removeChild(this.dropdown);
        this.dropdown = false;
      }
      break;
    case 13:  // return:  accept selection if there is one (and cancel)
      if(this.dropdown && this.dropdown.current) {
        this.value = this.dropdown.current.firstChild.data;
        this.dropdown.parentNode.removeChild(this.dropdown);
        this.dropdown = false;
        return false;  // this should be in the if block so the form can be submitted using a second enter press
      }
    case 27:  // escape:  hide selection
      if(this.dropdown) {
        this.dropdown.parentNode.removeChild(this.dropdown);
        this.dropdown = false;
      }
      break;
    case 38:  // up arrow:  select previous
      if(this.dropdown)
        if(this.dropdown.current) {
          if(this.dropdown.current.previousSibling && this.dropdown.current.previousSibling.className != "message") {
            this.dropdown.current.className = "";
            this.dropdown.current = this.dropdown.current.previousSibling;
            this.dropdown.current.className = "current";
          }
        } else if(this.dropdown.lastChild.className != "message") {  // nothing currently selected; select last item
          this.dropdown.current = this.dropdown.lastChild;
          this.dropdown.current.className = "current";
        }
      break;
    case 40:  // down arrow:  select next
      if(this.dropdown)
        if(this.dropdown.current) {
          if(this.dropdown.current.nextSibling && this.dropdown.current.nextSibling.className != "message") {
            this.dropdown.current.className = "";
            this.dropdown.current = this.dropdown.current.nextSibling;
            this.dropdown.current.className = "current";
          }
        } else if(this.dropdown.firstChild.className != "message") {  // nothing currently selected; select first item
          this.dropdown.current = this.dropdown.firstChild;
          this.dropdown.current.className = "current";
        }
      break;
    case 46:  // delete key:  update suggestions
      scheduleSuggest(this);
      break;
  }
}

i figured out the scan codes for each key by having this event pop up an alert() and then pressing the key. the first two lines of the function make sure to get the code from either the non-ie event model or the ie model. there’s a case for each code i need to do something with. first is backspace, where i check if it’s internet explorer by seeing if window.event is defined, then run the same scheduleSuggest function that the keypress event uses. next is tab, which checks if there’s a dropdown for the field and if anything’s been highlighted using the keyboard. if so, it sets the field value to the highlighted item and hides the dropdown. return does the same thing but needs to return false since normally return will submit a form and we want to stop after choosing the value. note if there’s no dropdown or no selected item we don’t return false and the default behavior of submitting the form continues. escape simply hides the dropdown.

up arrow and down arrow work similarly but opposite. each makes sure these is a dropdown first (nothing to do if there isn’t). if something is selected, there’s a previous / next item, and that item isn’t a message, set that one as the current item. if nothing’s selected, find the last / first item (that isn’t a message) and select that. note the class is set to current when selected and cleared when deselected. also the dropdown has a current property that is set to the currently highlighted item. finally, the delete key needs to get the latest suggestions because the delete key probably changed what’s in the field.

we now have a fully-functional ajax suggest script that can be applied to any text input field using the enableSuggest function! the javascript and css don’t even need to change to suggest other types of values — just write a new php script.

how was it?

comments

there are no comments on this guide so far. you could be the first!