Sneaky Abstractions

Subscribe to my Feed, follow me on , recommend me on Working With Rails or see my code on GitHub

Unobtrusive Ajax patterns: the updatable selectbox

Posted on August 08, 2008 10:16 Tagged with unobtrusive, javascript, select, ajax patterns.

This is something I see people ask about all the time: How to make one select dropdown update another based on what the user selects in the first. In fact, I wrote a script to do that and a couple of other things, but sometimes it’s good to be able to know how something like this works and customise it when it doesn’t fit your needs.

The ideal scenario for this solution to work unobtrusively is when the selects have a clear one-to-many, tree-like relationship. Not having thousands of options also helps, because it means you don’t have to retrieve them asynchronously (i.e. no ajax) from the server to prevent it from becoming too slow. This is the scenario I’ll go through in this article.

<form>
  <select name="foo" id="foo" class="updates:bar">
    <option value="1">1</option>
    <option value="2">2</option>
  </select>
  <select name="bar" id="bar">
    <optgroup label="1" class="foo:1">
      <option value="1">1.1</option>
      <option value="2">1.2</option>
    </optgroup>
    <optgroup label="2" class="foo:2">
      <option value="3">2.1</option>
      <option value="4">2.2</option>
    </optgroup>
  </select>
</form>

Here, the “foo” select will limit the options in the “bar” select based on what is selected in “foo”. Another way to put it is that the “bar” select observes the “foo” select and changes itself based on which option is selected in “foo”.

The unobtrusive part is that it will still work without JavaScript. The options are logically grouped in optgroup elements named in such a way that the used can see how it fits together. Of course, you’ll still need to validate the choice on the server to make sure the option selected in “bar” logically belongs to the option selected in “foo”.

So, what do we actually need to do here to make it work the way we want? First, we need to find all select elements with a class name matching “updates:<something>”. This tells us that the select, when changed, is going to update another selectbox with the ID “<something>”.

document.observe('dom:loaded', function(){

  $$('select').select(function(source){
    var className = source.readAttribute('class');
    var m = className && className.match(/updates:([^ ]+)/);
    if (m) {
      //We have a match
    }
  });

});

Now we have the ID of the target select and we can extract the optgroup elements inside it:

$$('select').select(function(source){
    var className = source.readAttribute('class');
    var m = className && className.match(/updates:([^ ]+)/);
    if (m) {//We have a match
      var target  = $(m[1]), //The target select
          name = source.readAttribute('id');// "foo" (in the example, can be anything)
      //Remove the optgroups from the target element and store them for later
      var groups = target.select('optgroup').invoke('remove');
    }
  });

Note that we remove the optgroups from the target element and store them in a variable that can be used later.

Now that we have both the source and the target elements, we can define what is going to happen when the source element is changed. Let’s extract this into a function of its own:

function updateTarget(){
  var selectedValue = source.getValue();//"1" or "2" in the example
  var re = new RegExp(name+':'+selectedValue); // ex: /foo:1/
  var associatedGroup = groups.find(function(g){
    return g.readAttribute('class').match(re);
  });
  target.update();//Empty the target first
  //Insert the option elements (not the optgroup itself)
  associatedGroup.select('option').each(function(o){
    target.insert({bottom:o});
  });
};

This function does a number of things in order to replace the contents of the target select. First, it gets the value of the selected option element in the source element. This value tells us which optgroup from the target it’s associated with. Then it loops through the saved groups and saves the associatedGroup. The associated group is the one which has a class name which matches the ID of the source element plus a colon and then the selected value. So, if the value “2” is selected in the “foo” element, the optgroup that has a class name of “foo:2” will be the associatedGroup. When this group has been found, it empties the target, and inserts the groups option elements (not the entire optgroup, because there’s no need for it since it’ll be the only one).

Now that we have the basic functionality in place, all we need to do is attach an event listener to the source element that runs it onchange. We’ll also run the function the first time on page load since no change has taken place yet.

source.observe('change', function(){
  updateTarget();
});
updateTarget();//On page load

The entire script now looks like this:

$$('select').select(function(source){
    var className = source.readAttribute('class');
    var m = className && className.match(/updates:([^ ]+)/);
    if (m) {//We have a match
      var target  = $(m[1]), //The target select
          name = source.readAttribute('id');// "foo" (in the example, can be anything)
      //Remove the optgroups from the target element and store them for later
      var groups = target.select('optgroup').invoke('remove');
      //The function that performs the update
      function updateTarget(){
        var selectedValue = source.getValue();//"1" or "2" in the example
        var re = new RegExp(name+':'+selectedValue); // ex: /foo:1/
        var associatedGroup = groups.find(function(g){
          return g.readAttribute('class').match(re);
        });
        target.update();//Empty the target first
        //Insert the option elements (not the optgroup)
        associatedGroup.select('option').each(function(o){
          target.insert({bottom:o});
        });
      };
      source.observe('change', function(){
        updateTarget();
      });
      updateTarget();//On page load
    }
  });

And here’s a (hopefully) working example:

I haven’t really checked to see if it works in browsers other than those based on Gecko, but there shouldn’t be any blocking problems with this technique. Also, the heavy use of closures in these examples might be a recipe for memory leaks in some browsers (IE, I’m looking at you), I don’t really know and I think Prototype actually takes care of cleaning up my mess in that case. I don’t think I’d do it exactly like this in real life, I just chose to in the example for simplicity. The technique would still be the same though.

♥ is in the air