Digital Web Magazine

The web professional's online magazine of choice.

More usability frosting for your accessibility cake

Got something to say?

Share your comments on this topic with other web professionals

In: Articles

By Aaron Gustafson

Published on April 20, 2005

Mac users are spoiled. It’s a fact we Windows folks have to live with—when it comes to usable interfaces, Mac applications are far ahead. Even Microsoft makes better products for OS X than for Windows.

A perfect example of this is the truly elegant interface for <select> boxes using <optgroup> tags. Take this simple form, for example. In Internet Explorer for Mac, the <optgroup>s create a list, through which you can navigate their associated <option>s:

A screen capture of Internet Explorer (version 5.2) for the Mac, showing how elegantly it displays <select> boxes organized by <optgroup>s.

Compare that to the same <select> in Firefox for Windows:

A screen capture of Firefox (version 0.9.2) for PC, showing its very unimpressive user interface.

The difference is striking.

It goes without saying that the Mac IE implementation of <select> boxes is the most usable way to display the information. You're not forced to digest a long list of options at one time and can easily navigate your way through the choices.

Designers and developers looking for an easier way for users to digest this information have turned to JavaScript, but the problem is that scripts like these (see Category Form Script, Auto Drop Down, and Country Chooser) are not as accessible, don’t explicitly indicate the relationship between the groupings and their associated options and don’t work for users with JavaScript disabled.

So how do we make the interface for this form element more useable without falling into the trappings of the scripts above? The idea is this: leave the XHTML as it is and run some JavaScript when the page loads to break this up into two related <select> boxes, one for the <optgroup>s and one for the associated <option>s for that group. The key features of this interface should be:

In essence, we want the flexibility of JavaScript without the risk of leaving any user in the lurch. How do we do it? Get out your oven mitts, we’re going to…

Bake a cake

Some of you may remember my last article for A List Apart, in which I discussed separation of JavaScript into self-limiting functions that leave pages usable and accessible whether they run or not. This is another take on that concept, applied to a form.

We start with our nice, clean XHTML file. As with CSS, we can use hooks for JavaScript, so if we apply an id to the <fieldset>, we can address it via the DOM and reach the content within. Let’s give the <fieldset> a solid semantic id: “locationsSet”. The reason we are applying an id to the <fieldset> and aren’t just working with the <select> (which already has an id) will become clear shortly. Patience!

Now we have a hook, so we can start our JavaScript function (which we will call splitOptgroupSelect() so it’s generic), by finding the <fieldset> and the <select> we will be working with:

function splitOptgroupSelect()
{
  // get the fieldset 
  // and the original select
  var fieldsetObj = 
      document.getElementById("locationsSet");
  var originalSelect = 
      document.getElementById("location");
}

Since we are using the built-in functionality of document.getElementById(), we should test to see if that will work in the browser before we execute it:

function splitOptgroupSelect()
{
// make sure document.getElementById 
  // is understood first
  if (!document.getElementById) {
    return null;
  }
  // get the fieldset 
  // and the original select
  var fieldsetObj = 
      document.getElementById("locationsSet");
  var originalSelect = 
      document.getElementById("location");
}

OK, now we have the <fieldset> and the <select> as objects and we can go about collecting the <optgroup>s and their associated <option>s. This is accomplished by first breaking out the <optgroup>s into an array and then iterating through that array, collecting each element’s associated <option>s into a separate multi-dimensional array ( select2Options). We will be using getElementsByTagName(), so we will need to add that to our initial test as well:

function splitOptgroupSelect()
{
  // make sure document.getElementById & 
  // document.getElementByTagName 
  // are understood first
  if (!document.getElementsByTagName &&
      !document.getElementById) {
    return null;
  }
  // get the fieldset 
  // and the original select
  var fieldsetObj = 
      document.getElementById("locationsSet");
  var originalSelect = 
      document.getElementById("location");
  var select2Options = new Array;

  // break out the optgroups into an array
  var optgroups = 
originalSelect.getElementsByTagName("optgroup");
 
  // loop through the optgroup array
  for(var i = 0; i < optgroups.length; i++)
  {
    // take 1 optgroup at a time
    var optgroup = optgroups[i];
    
    // extract the label
    var label = optgroup.label;
    // make an array to hold its options
    select2Options[label] = new Array;
    // collect the options into an array
    var options = 
        optgroup.getElementsByTagName("option");
    // iterate through, collecting the info
    // for each into a multi-dimensional
    // array
    for (j = 0; j < options.length; j++)
    {
      select2Options[label][j] = new Array;
      select2Options[label][j]["value"] = 
        options[j]["value"];
      select2Options[label][j]["text"] = 
        options[j].innerHTML;
    }
  }
}

We actually aren’t doing anything with the <optgroup> info we’re collecting, so we need to take it and build the first of our two new <select>s.

function splitOptgroupSelect()
{
  …
  // break out the optgroups into an array
  var optgroups = 
fieldsetObj.getElementsByTagName("optgroup");
  // create the state select dropdown
  var select1 = 
      document.createElement("select");
  // set the name
  select1.setAttribute("name","locState");
  // set the id
  select1.setAttribute("id","locState");
  // create an empty first option
  var emptyOption = 
      document.createElement("option");
  // set value = ""
  emptyOption.setAttribute("value","");
  // make it selected
  emptyOption.setAttribute("selected",
                           "selected");
  // set the text node value
  emptyOption.innerHTML = "Select a State";
  // attach option to select
  select1.appendChild(emptyOption);
  // loop through the optgroup array
  for(var i = 0; i < optgroups.length; i++)
  {

    …

  }
    
    // create an option of this optgroup
    var select1Option = 
        document.createElement("option");
    select1Option.setAttribute("value",label);
    select1Option.innerHTML = label;
        
    // connect option to select
    select1.appendChild(select1Option);
  }
}

Now we actually need to create the second <select> and append both of them to the <fieldset> (along with a helpful <legend> and <label>s for each field). To do this, we’ll build the tags and then append them to our <fieldset>. In order to run the script, however, we’ll need to add splitOptgroupSelect() to the onload event of the page.

function splitOptgroupSelect()
{
  …
 
  // create a select for the locations
  var select2 = 
      document.createElement("select");
  select2.setAttribute("name","location");
  select2.setAttribute("id","location");
  // add a placeholder option & attach
  var select2Option = 
      document.createElement("option");
  select2Option.setAttribute("value",label);
  select2Option.setAttribute("selected",
                             "selected");
  select2Option.innerHTML = "<--";
  select2.appendChild(select2Option);
  // create the new legend
  var newLegend = 
      document.createElement("legend");
  newLegend.innerHTML = 
"Restaurant Location <em>(if applicable)</em>";
  // assign some state and location labels
  var select1Label = 
      document.createElement("label");
  select1Label.innerHTML = "State";
  var select2Label = 
      document.createElement("label")
  select2Label.innerHTML = "Location";
  // attach the selects to the labels
  select1Label.appendChild(select1);
  select2Label.appendChild(select2);
  // now append the fieldset 
  // with our new content
  fieldsetObj.appendChild(newLegend);
  fieldsetObj.appendChild(select1Label);
  fieldsetObj.appendChild(select2Label);
}window.onload = function()
{
  splitOptgroupSelect();
}

Of course, we don’t want this script to run in IE on the Mac because that is the browser whose elegance we are trying to recreate. By including a very light (and reasonably trouble-free) browser detect script called browserdetect_lite, we can limit this functionality to only the less fortunate browsers, leaving IE on the Mac to work it’s magic in peace:

window.onload = function()
{
  if (!browser.isIEMac)
  {
    splitOptgroupSelect();
  }
}

Whoa, that’s not right. We need to dump the old content before adding the new stuff. To make it easy to remove the content of an object, let’s write a generic function to do just that. We’ll call it removeContents

function removeContents(thisObj)
{
  while (thisObj.firstChild)
  {
    thisObj.removeChild(thisObj.firstChild);
  }
}

…and apply it to the end of splitOptgroupSelect():

  …
 
  // now replace the existing content of 
  // the fieldset with our new content
  removeContents(fieldsetObj);
  fieldsetObj.appendChild(newLegend);
  fieldsetObj.appendChild(select1Label);
  fieldsetObj.appendChild(select2Label);

  …

That’s much better. For the beauty’s sake, let’s apply a little style to get those two <select>s on the same line. We’ll apply a class of "small" to the <label> and define it and its child <select> as such:

label.small
{
  float: left;
  width: 200px;
}
label.small select
{
  margin-top: 3pm;
  width: 190px;
}

Now we add two little lines to our function…

  …
 
  // assign some state and location labels
  var select1Label = 
      document.createElement("label");
  select1Label.innerHTML = "State";
  select1Label.className = "small";
  var select2Label = 
      document.createElement("label")
  select2Label.innerHTML = "Location";
  select2Label.className = "small";

…and it looks even better.

Now we need to make the <option>s populating the second <select> (“Location”) dependent on the selected <option> from the first <select>, but before we get to that, a little housekeeping is in order.

Mix batter until smooth

Not all browsers are equal, so there are a few lines we should add to our script to make sure it does not cause problems (as did when we tested for support of document.getElementById). When dealing with a similar idea, Peter Paul Koch pointed out that some older browsers may not be able to add and remove <option>s from a <select>. In order to make sure we can do what we want to, we run the following (slightly modified) test:

  …
 
  // get the fieldset 
  // and the original select
  var fieldsetObj = 
      document.getElementById("locationSet");
  var originalSelect = 
      document.getElementById("location");
 
  // make sure we can add/remove 
  //options - thanks PPK!
  lgth = originalSelect.options.length - 1;
  originalSelect.options[0] = null;
  if (originalSelect.options[lgth])
  {
    return null;
  }
 
  // break out the optgroups into an array
  var optgroups = 
originalSelect.getElementsByTagName("optgroup");
 
  …

In our original select, you may have noticed a “Not Applicable” option. This serves dual purposes. Apart from being a good option to offer, in the code added above, we are testing our ability to remove an <option> from a <select> via JavaScript. The <option> we are removing is the “Not Applicable” one. A successful run of this test will remove that <option>, so we need a way to put it back. We do this further on in the script:

  …
 
  // create an empty first option
  var emptyOption = 
      document.createElement("option");
  // set value = ""
  emptyOption.setAttribute("value","");
  // make it selected
  emptyOption.setAttribute("selected",
                           "selected");
  // set the text node value
  emptyOption.innerHTML = "Select A State";
  // attach text to option & option to select
  select1.appendChild(emptyOption);  // make not applicable option
  var naOption = 
      document.createElement("option");
  // set value = "N/A"
  naOption.setAttribute("value","N/A");
  // set the text node value
  naOption.innerHTML = "Not Applicable";
  // attach text to option & option to select
  select1.appendChild(naOption);

One more problem we run into is that IE 5.x on Windows has problems (like I need to tell you). Basically, it balks on collecting <option>s within an <optgroup>. To deal with this bit of ridiculousness, we add the following lines into splitOptgroupSelect and the function will just end prematurely, leaving the original <select> with its lovely, accessible <optgroup>s intact:

  …
 
    // extract the label
    var label = optgroup.label;
 
    // IE 5.x does not allow us to collect these 
    // options so we need to stop
    if
(optgroup.getElementsByTagName("option").length 
== 0)
    {
      return null;
    }
        
    // make an array of its options
    select2Options[label] = new Array;
 
  …

Now that that’s taken care of, we can get back on track and add in some behavior.

Apply frosting between the layers for added richness

We want to make the second <select> dependent on the first one. We’ve already collected the <option>s for each <optgroup> into an array, but there’s one problem—we need that information not to be limited to the splitOptgroupSelect function. So let’s move our variable declaration for select2Options outside the function:

var select2Options = new Array;
 
function splitOptgroupSelect()
{
  …

Now that the array of <option>s is being stored in a variable which can be accessed by every function we write, we can create a function to write out a list of <option>s for the second <select> based on the value of the selected <option> from the first:

function changeOptions()
{
  // find the first select

var select1Obj = document.getElementById("locState"); // find the second select var select2Obj = document.getElementById("location"); // collect the value of the selected state var select1SelectedOption = select1Obj.options[select1Obj.selectedIndex].value; // get the array of options for it var newOptions = new Array; newOptions = select2Options[select1SelectedOption]; // remove existing options from the select removeContents(select2Obj); if (select1SelectedOption == "N/A") { // make not applicable option var newOption = document.createElement("option"); newOption.setAttribute("value","N/A"); newOption.setAttribute("selected", "selected"); newOption.innerHTML = "Not Applicable"; select2Obj.appendChild(newOption); } else { // add a blank 1st option var emptyOption = document.createElement("option"); emptyOption.setAttribute("value",""); // if the selected option is empty, // revert to the <-- if (select1SelectedOption == "") { emptyOption.innerHTML = "<--"; } else { emptyOption.innerHTML = "Select a Location"; } select2Obj.appendChild(emptyOption); } if ((select1SelectedOption == "") || (select1SelectedOption == "N/A")) // there’s nothing else to do, stop now { return null; } // attach the new options to the select for (i = 0; i < newOptions.length; i++) { var newOption = document.createElement("option"); newOption.setAttribute("value", newOptions[i]["value"]); newOption.innerHTML = newOptions[i]["text"]; select2Obj.appendChild(newOption); } }

Finally, we just add changeOptions() as a behavior to the onchange event for the first <select> and we’re almost there. Adding the behavior at the end of the splitOptgroupSelect function is muy importante because if a browser bails on the function because it doesn’t know how to perform a necessary task ( e.g., document.getElementById), you don’t have to worry about the behavior being applied anyway and wreaking havoc on your page.

Cleaning up

The final step in our journey (at least in this article) is to make the script a little more generic. Currently, we have a lot of hard-coded ids, text values, etc. scattered throughout our functions. By moving those values into global variables, we can use them anywhere and make this script set even more useful. Furthermore, we can add in an additional global variable which lets you decide whether or not you want to add back in that “Not Applicable” <option>. After all, you may not.

We now have a page which is not only usable and accessible, but uses functions generic enough that they can be moved to an external JavaScript file so we only need to add a few lines of JavaScript to the head of our document to define the global variables for the page, further separating structure from behavior. Imagine that. Tastes pretty good to me.

Got something to say?

Share your comments  with other professionals (15 comments)

Related Topics: Scripting, Usability, Accessibility, Navigation

 

Aaron Gustafson works as a part of Easy Designs, writes and speaks often on the various layers of the web standards cake, and works dilligently to improve the usability and accessibility of the web.

Media Temple

via Ad Packs