trovster.com

The personal website of Trevor Morris

Simple Google Suggest

With the recent publicity of XMLHTTPRequest (XHR) and JavaScript (or AJAX if you talk to Jesse James Garrett or read the hype) everyone wants some snazzy serverside action on their website or web application.

There are many benefits to the method of asynchronously requesting data from the server and dynamically updating portions of a website. However, like with any hyped technology, many of the implementations of XHR are unjustified. This article isn’t going to discuss what consitutes good use of XHR, instead, it provides an example of how it can be used to add functionality, as well as usability, to a website.

XHR scripts require two parts — JavaScript to request and process data on the client-side and a serverside script. The serverside script for this example is fairly straight-forward, but the JavaScript makes use of some heavy DOM scripting.

The example discussed and developed in this article is based on Google Suggest, which suggests search terms based upon what you’ve already typed.

Setting up the Connection

Because XHR isn’t a standard DOM method, implementation varies between browsers. Because of this it may seem a little daunting in beginning even the simplest of XHR–scripts. However, there are quite a few easy to use XHR interface scripts which will do most of the hard work of the connection for you.

This example uses a script called XHConn to set up the connection. This script needs to be included before the script described in this article. The code used is described later.

Starting the Script

The complete script is fairly long and contains a total of three different functions as well as a set of global variables. If you are confident with JavaScript then feel free to change any of the script as you see fit, however, if you don’t know JavaScript then all you need to change are the variables defined at the top.

Global Variables

The script starts by defining a few variables. These variables are used throughout the script and are the only ones which need to be changed for your own purposes. These variables are:

var inputID = 'id';
var scriptPath = '/path/to/'
var scriptName = 'filename.php';
var scriptMethod = 'GET';
var scriptVariable = 'var';
var valueName = 'matches';
  • The inputID is the id of the <input> you wish the script to run on.
  • scriptPath and scriptName make up the file of the serverside script you wish to run.
  • There are two possible parsing methods, GET and POST, these are set in scriptMethod.
  • scriptVariable is the key parsed using the method above.
  • valueName is text outputted with the supplementary result.

The Setup Function

The first part of the script is the function called simpleSuggestion. This is the only function which needs to be called when the page is loaded. It is recommended to use an addEvent handler to include this script (amongst any others you have) on load.

function simpleSuggestion() {
  var varInput = document.getElementById(inputID);
  if(!varInput || varInput.tagName.toLowerCase()!='input') return false;
  varInput.autoComplete = false;
  varInput.setAttribute('autocomplete','off');
}

The script checks whether the id you provided matches an element on the current page and checks the element is an input box. If either of these checks fail then the script halts and nothing more happens. The last two lines remove the auto–complete selection which is usually activated when typing. Usually removing the auto–completion should be avoided, but we’re replacing the functionality with something more appropriate.

Remember, JavaScript should only be used to provide extra functionality and should never be relied upon.

Catching user interaction

The basis of this script is to guess what the user wants. For this we need to know what the user is typing. Javascript has quite a few predefined methods of catching user interaction. The event we want for this particular script is onkeyup — a list of events can be found on the W3C site.

The onkeyup event is added to the <input>. When the user releases a key this part of the script is activated. It is useful for capturing what key was pressed, this information is parsed into the function under the variable e.

Next the script sets up the XHR connection (XHConn). this.value contains the text which is in the <input> box, this is set to a new variable which is used throughout the script. Finally, the script gets the keyCode of the key pressed, which returns a specific number. You can check all the numbers in a keyCodes reference chart.

varInput.onkeyup = function(e) {
  xhrConnection = XHRfunction();
  var varSearch = this.value;
  var e = e || window.event;
  var key = window.event ? e.keyCode : e.which;

Below is the final part of the connection script. The connection doesn’t need to be run if the search value is empty, nor if the user presses the escape key. If either of these are true, and the container box exists, it is simply removed.

  if(varSearch=='' || key=='27') {
        var feedback = document.getElementById('xhr-feedback');
        if(feedback) feedback.parentNode.removeChild(feedback);
      }
      else {
        xhrConnection.connect(
          scriptPath+scriptName,
          scriptMethod,
          scriptVariable+'='+varSearch,
          simpleSuggestionFeedback
        );
      }
    }

If the input contains text and the last keypress wasn’t the escape key, then the connection is created. The connection is made up of the following:

  • a filename (including path),
  • the method (POST or GET),
  • the script variable and the input text value,
  • a completion function.

The Serverside Script

Now would be a good time to explain the serverside processing script. You can use any serverside language you like, but this example is going to use PHP.

The HTML output requires two pieces of information — a keyword and a value.

This information is going to be returned as an XML file. Below is an example file which could be used with this script.

<?xml version="1.0" encoding="UTF-8"?>
<content>
  <tag>
    <name>General</name>
    <value>25 matches</value>
  </tag>
  <tag>
    <name>Gmail</name>
    <value>13 matches</value>
  </tag>
  <tag>
    <name>Google</name>
    <value>2 matches</value>
  </tag>
</content>

The XML file starts with the version and encoding prolog. Then all the content is surrounded by the tag <content>. The information is then grouped by the tag element, which contains the name and value. These are the values that are translated into a clickable list with JavaScript.

First of all it’s good practice to see whether the scriptVariable has been sent to the script. If it is set, then we continue to process the information. If not, we simply return false, and the script finishes there.

<?php
if(isset($_GET['scriptVariable'])) {
  // process the information and return as XML
}
else {
  return FALSE;
}?>

Note: This script is using GET as the scriptMethod. If you’re using POST you will need to modify your script accordingly.

The script first sets up an empty variable called $currentTags which is added to later. Secondly, it takes the parsed information, escapes it and separates the string into an array whenever it encounters a comma. These are all stored in the $tagArray variable.

$currentTags = '';
$tagArray = explode(',',mysql_real_escape_string($_GET['scriptVariable']));

The script now checks whether the $tagArray is set, if it’s an array and that it has at least one entry. If none of these conditions are met, then there is only one tag to be matched, so this is set as the $tagSearch variable.

The script wants the very last piece of text in the string. The piece of text after the final comma. By taking one off the length of the array key we can get the appropriate piece of text. This is what is searched for in the database.

We also don’t want information that has already been put into the input box. So, for each of the commas (except for the last one) we want to get the string. For each of these pieces we create a small portion of SQL which tells the database not to return a match on this string.

if(isset($tagArray) && is_array($tagArray) && count($tagArray)>0) {
  $tagSearch = $tagArray[count($tagArray)-1];
  if(count($tagArray)>1) {
    $currentTags .= 'AND TagName NOT IN (';
    for($i=0; $i<(count($tagArray)-1); $i++) {
      $currentTags .= "'".trim($tagArray[$i])."'";
      if((count($tagArray)-2)>$i) $currentTags .= ',';
    }
    $currentTags .= ')';
  }
}
else {
  $tagSearch = $tagArray;
}

This is all the information we require to make the final SQL query. We check whether the $tagSearch is empty, which it shouldn’t be by this point. Remove any encoded spaces (%20) and remove and white-space from the string.

if(!empty($tagSearch)) {
  $tagSearch = trim(trim($tagSearch,'%20'));
  $sql = "SELECT DISTINCT t.ID, t.TagName, COUNT(t.TagName) as Value
          FROM blog_tags AS t
          LEFT JOIN blog_tags_glue AS g ON t.ID=g.TagID
          WHERE t.TagName LIKE '".$tagSearch."%'
          ".$currentTags."
          GROUP BY t.TagName
          ORDER BY Value DESC, t.TagName ASC";
  $query = mysql_query($sql);
}

If the the user had already chosen ‘General’ and ‘Gmail’, and pressed ‘g’ — the generated SQL would be as follows:

SELECT DISTINCT t.ID, t.TagName, COUNT(t.TagName) as Value
FROM blog_tags AS t
LEFT JOIN blog_tags_glue AS g ON t.ID=g.TagID
WHERE t.TagName LIKE 'g%'
TagName NOT IN ('General', 'Gmail')
GROUP BY t.TagName
ORDER BY Value DESC, t.TagName ASC

At this point we set the content–type of the file, this should be text/xml and is achieved with:

header('Content-type: text/xml');

The entire XML we create is contained with the content element. Each of the returned results are surrounded in the tag element. The name contains the main information, for example the search term. The value is the supplementary result, for example the number of results.

If there are no results, then an error message can be displayed. This isn’t used in the output.

echo '<content>';
if(isset($sql) && mysql_num_rows($query)!==0) {
  while($array = mysql_fetch_array($query)) {
    echo '<tag>'."\n";
    echo '<name>'.$array['TagName'].'</name>'."\n";
    echo '<value>'.$array['Value'].'</value>'."\n";
    echo '</tag>'."\n";
  }
}
else {
  echo '<error>No matches, you can add a new one.</error>';
}
echo '</content>';

That is the PHP script finished. The final hurdle is manipulating that information with the DOM using JavaScript which updates the HTML.

Manipulating the Results

The XHConn script now returns our XML document to another function. This function was defined in the initial setup script — called simpleSuggestionFeedback. This function passes a variable called oXML, which contains the information from the serverside script.

function simpleSuggestionFeedback(oXML) {}

The variable oXML contains the response which can either be in plain text form or XML; this script uses the XML version.

Using the getElementsByTagName function, the script checks the response for an element called ‘tag’ — if any are found they are returned in a collection. Using the global variable inputID, the script gets the <input> box of the correct id.

The script creates a variable called varFeedbackContainer which is looking for an element with the id of xhr-feedback. The first time the script is run this element won’t exist, so we create it. Subsequent calls will find the element, so it will skip creating it. The element we create is a dd — definition list description — as the form is made from a definition list; <dl>.

var XMLContainer = oXML.responseXML.getElementsByTagName('tag');
var varInput = document.getElementById(inputID);
var varFeedbackContainer = document.getElementById('xhr-feedback');
if(!varFeedbackContainer) {
  var varFeedbackContainer = document.createElement('dd');
  varFeedbackContainer.setAttribute('id','xhr-feedback');
  insertAfter(varFeedbackContainer,varInput.parentNode);
}

Note: insertAfter is not a native DOM function and must be included before this script is loaded.

The code was written by Jeremy Keith and is featured in his DOM Scripting book. The code is as follows:

function insertAfter(newElement,targetElement) {
  var parent = targetElement.parentNode;
  if(parent.lastChild == targetElement) {
  parent.appendChild(newElement);
  } else {
    parent.insertBefore(newElement,targetElement.nextSibling);
  }
}

A similar technique is used to check and create (where appropriate) the list inside the feedback container. The code is as follows:

var varList = document.getElementById('xhr-feedback').getElementsByTagName('ul')[0];
if(!varList) var list = document.createElement('ul');

If there is the variable called XMLContainer and it contains elements, then we want to loop through the elements and create our list. If there are no elements, then we want to remove the list, but only if it exists.

if(XMLContainer && XMLContainer.length>0) {
  // loop through the response and attach to the list
}
else {
  if(varList) varList.parentNode.removeChild(varList);
}

Up to this point the script still hasn’t generated anything visable on the page. The next part is the meat of the script which adds all the information to the page. It loops through all the tag elements from the XML response, creating all the element and text nodes and finally attaching them to the document.

Appending the Information

The script loops through all of the tag elements in the XML — each of which contain a name and value. The value is placed inside a span element. The name and resulting span are both added to an anchor. The anchor is then placed inside a list item — <li> — and attached to the list.

for(var i=0; i<XMLContainer.length; i++) {}

Inside the loop the script creates all the elements required, the <li>, <span> and <a>. Because the link is only used for cross-browser hovering the href attribute is simply a hash (internal link).

var li = document.createElement('li');
var span = document.createElement('span');
var a = document.createElement('a'); a.setAttribute('href','#');

Two text nodes are created from the XML repsonse elements name and value. Each tag should contain one element of each type, so we simply get the first one. To get the text the nodeValue is looked up. For the value, the global variable valueName, defined at the start of the script, is appended to the end of the string. These text nodes are both set to variables, so they can be attached to the correct element.

var tag = document.createTextNode(
    XMLContainer[i].getElementsByTagName('name')[0].firstChild.nodeValue
); 
var value = document.createTextNode(
    XMLContainer[i].getElementsByTagName('value')[0].firstChild.nodeValue + ' ' + valueName
);

Finally, the text nodes are attached to their elements, all the created elements are appended to each other, in the appropriate order, and added to the containing list, <ul>.

a.appendChild(tag);
span.appendChild(value);
a.appendChild(span);
li.appendChild(a);
list.appendChild(li);

The last thing left to do with the output is to attach it to the container on the page. This is simply achieved with:

varFeedbackContainer.appendChild(list);

Typing into the input will give the correct results, but at the moment they don’t actually do anything apart from showing the matching. Clicking on a result should add it to the input box. This is achieved by adding event listeners to each of the anchors and calling a function to add the information.

Clickable Links

First of all we need to get all of the anchors in the list we created — this is done with getElementsByTagName. We then loop through all of the anchors and attach the onclick event. Within the onclick event we call a function to insert the word into the <input>.

var an = list.getElementsByTagName('a');
for(var i=0; i<an.length; i++) {
  an[i].onclick = function() {}
}

Within the function we want to call another function to insert the text into the <input>. I have called this function insertWord which requires two variables — input and text. Finally, we stop the anchor from following the link.

insertWord(varInput,this.childNodes[0].nodeValue);
return false;

As previously mentioned the insertWord function requires two inputs. The target element, either an <input> or a <fieldset>. If the target isn’t one of these elements, then the function stops. This is done by checking the tagName of the element.

function insertWord(target,tag) {
  if(!target.tagName.toLowerCase()=='input' || !target.tagName.toLowerCase()=='fieldset') return false;
}

The script checks the value of the input for the last delimiter of a comma. If non are found, minus one is returned. If there are no commas, the script adds the new text to the input value, followed by a comma and a space.

If a comma is found, then we want to add the next text after the existing text. Using the comma position, the script disregards everything after the last comma then appends the clicked text.

commaPos = target.value.lastIndexOf(',');
if(commaPos == -1) {
  target.value = tag + ', ';
}
else {
  target.value = target.value.substr(0, commaPos) + ', ' + tag + ', ';
}

The script checks whether there is the feedback container, and whether it contains a list. If so, then the list is removed.

if(document.getElementById('xhr-feedback')) {
  var varList = document.getElementById('xhr-feedback').getElementsByTagName('ul')[0];
  if(varList) varList.parentNode.removeChild(varList);
}

Finally, the script gives the input box focus, so you can continue to type.

target.focus();

That’s all the main JavaScript code, all that’s left is to make it look like the Google version — that’s the job of CSS.

Styling the Output

At the moment the script doesn’t look much like the Google Suggest example. With the addition of a little bit of CSS the whole illusion is complete.

input#id,
#xhr-feedback ul {width: 400px;}
#xhr-feedback,
#xhr-feedback ul li a {position: relative;}
#xhr-feedback * {margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; font-size: 10px;}
#xhr-feedback ul {
    position: absolute; top: -3px; padding: 1px; z-index: 50;
    background-color: #fff; border: 1px solid #ccc; list-style: none;
}
#xhr-feedback ul li a {
    display: block; padding: 1px 5px;
    color: #000; background-color: inherit; text-decoration: none;
}
#xhr-feedback ul li a span {position: absolute; right: 5px; color: #008000; background-color: inherit;}
#xhr-feedback ul li a:hover,
#xhr-feedback ul li a:focus,
#xhr-feedback ul li a:hover span,
#xhr-feedback ul li a:focus span {background-color: #36c; color: #fff;}

About

My name is Trevor Morris & I am a movie-loving, mountain bike-riding web developer from the UK. Currently, freelancing at Surface.

Latest tweet

Had a blast around the new XC track at Sandwell Valley. Rode the circuit a few times as it’s less than a mile — http://t.co/WWUNkPFbi1

Subscribe

You can keep up to date with my blog or movie ratings by following the feeds: