Saturday, September 18, 2010

Event Handling with the jQuery Autocomplete Combobox

Updates.
Tuesday 5 April 2011, 02:18:59 PM - added code that copies TITLE across from the original SELECT to the new INPUT.

First, the exciting bit. Have a look at the list of Star Trek characters below. See if you can guess which actor plays your favourite character - before you make a selection. Each time you make a selection, you will see the actor's name appear in the text box beside the combobox.


Now for the really cool bit: try typing TNG into the combobox. See what happens? The list is shortened to include only those options that begin with "TNG". Now try typing Spock into the combobox. The list is shortened to include only those options that contain "Spock" (not just begin with).

This is a demonstration of jQuery's combobox UI widget (links to demo page) - currently described as a prototype - which is built on top of the autocomplete UI widget.

The autocomplete feature has an overwhelming appeal, meaning that I couldn't bear to use any other jQuery combobox after I saw this one in action; but there is a cost. This widget replaces a SELECT control with a text field, a button and a floating list, thus it is a complicated task to make them all work as one; and it isn't done perfectly (yet).

  • Event handling is incomplete and not compliant to jQuery naming standards (though the latter is minor and will be easy to fix).
  • The floating list needs adjustment to be made scrollable (or large lists will be unmanageable).
  • The combobox doesn't adjust to fit the width of its contents like a HTML SELECT control should.
  • You need a whole jQuery theme just to get the autocomplete control (the floating list coming down from the text field) to render decently - plus extra CSS fixes specifically for the combobox, i.e. it's a lot of effort and CSS if all you want is one (very cool) combobox control. Granted, if you know your CSS well enough you don't need a whole jQuery theme; but after looking at what goes into a theme, I didn't want to spend the time to pick out what pieces are needed - plus I wanted to be ready for when I might use other jQuery themed controls.

Below is my adaptation of jQuery's combobox UI widget. I present the code in chunks and explain what I have changed from the original. There is also a standalone version of this example that shows the control without the chaff.

The first things you need are imports for the jQuery theme, jQuery API itself and then jQuery UI.

<link href='http://robertmarkbram.appspot.com/content/global/css/jQueryUi/customThemeGreen/jquery-ui-1.8.4.custom.css' rel='stylesheet' type='text/css'/>
<script type="text/javascript" src="http://www.google.com/jsapi?key=ABQIAAAAoDEIY_vXge_LQOEVgHyheBSvIISGg2D4cAMKlpvPZkPgQSL0sRRGyBerqkXeyllTDvkGdlqzeYPWKA" ></script>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.5/jquery-ui.min.js"></script>

What's with the jsapi?key and the imports from ajax.googleapis.com? Google (being the thoroughly non-evil company that they are) offer several well known Javascript APIs for public use on websites other than their own i.e. they host them so you don't have to. They ask that you register for (free) and use an API key specific to your site so they know ... whatever it is they want to know. Read more about this on the Google Libraries API - Developer's Guide. This is perfect for things like Blogger i.e. services for which you cannot upload your own scripts or other arbitrary files.

Next is the CSS specifically for the combobox.

<style type="text/css">
   .ui-button { margin-left: -1px; }
   .ui-button-icon-only .ui-button-text { padding: 0.35em; }
   .ui-autocomplete-input { margin: 0; padding: 0.48em 0 0.47em 0.45em; }
   .ui-autocomplete { height: 200px; overflow-y: scroll; overflow-x: hidden;}
</style>

I added the last line to make sure the autocomplete portion would scroll on long lists - which would otherwise be unmanageable. I copied the code from this jQuery forum post: Autocomplete with vertical scrollbar. I am still not entirely happy with some aspects of the above CSS. It renders nicely on Firefox 3.6.8, Internet Explorer 8 and Chrome 6 - but the edges didn't line up correctly in Chrome 7 (Dev build): the text field seemed just a pixel or two taller than the button. Worse, zooming in and out in all of those browsers could also make the heights mis-match by just a pixel or so.

Below is the actual code for the jQuery autocomplete combobox widget. Of course, this belongs in its own javascript file unless you plan to use it one page only.

<script type="text/javascript">
   (function( $ ) {
      $.widget( "ui.combobox", {
         _create: function() {
            var self = this;
            var select = this.element,
               theWidth = select.width(),
               selected = select.children( ":selected" ),
               theTitle = select.attr("title"),
               value = selected.val() ? selected.text() : "";
            select.hide();
            var input = $( "<input style=\"width:" + theWidth + "px\">" )
               .val( value )
               .attr('title', '' + theTitle + '')
               .autocomplete({
                  delay: 0,
                  minLength: 0,
                  source: function( request, response ) {
                     var matcher = new RegExp( $.ui.autocomplete.escapeRegex(request.term), "i" );
                     response( select.children( "option" ).map(function() {
                        var text = $( this ).text();
                        if ( this.value && ( !request.term || matcher.test(text) ) )
                           return {
                              label: text.replace(
                                 new RegExp(
                                    "(?![^&;]+;)(?!<[^<>]*)(" +
                                    $.ui.autocomplete.escapeRegex(request.term) +
                                    ")(?![^<>]*>)(?![^&;]+;)", "gi"
                                 ), "<strong>$1</strong>" ),
                              value: text,
                              option: this
                           };
                     }) );
                  },
                  select: function( event, ui ) {
                     ui.item.option.selected = true;
                     //select.val( ui.item.option.value );
                     self._trigger( "selected", event, {
                        item: ui.item.option
                     });
                  },
                  change: function( event, ui ) {
                     if ( !ui.item ) {
                        var matcher = new RegExp( "^" + $.ui.autocomplete.escapeRegex( $(this).val() ) + "$", "i" ),
                           valid = false;
                        select.children( "option" ).each(function() {
                           if ( this.value.match( matcher ) ) {
                              this.selected = valid = true;
                              return false;
                           }
                        });
                        if ( !valid ) {
                           // remove invalid value, as it didn't match anything
                           $( this ).val( "" );
                           select.val( "" );
                           return false;
                        }
                     }
                  }
               })
               .addClass( "ui-widget ui-widget-content ui-corner-left" );
            var span = $("<span style=\" white-space: nowrap;\"></span>")
                  .append(input).insertAfter( select );
            input.data( "autocomplete" )._renderItem = function( ul, item ) {
               return $( "<li></li>" )
                  .data( "item.autocomplete", item )
                  .append( "<a>" + item.label + "</a>" )
                  .appendTo( ul );
            };

            $( "<button> </button>" )
               .attr( "tabIndex", -1 )
               .attr( "title", "Show All Items" )
               .insertAfter( input )
               .button({
                  icons: {
                     primary: "ui-icon-triangle-1-s"
                  },
                  text: false
               })
               .removeClass( "ui-corner-all" )
               .addClass( "ui-corner-right ui-button-icon" )
               .click(function() {
                  // close if already visible
                  if ( input.autocomplete( "widget" ).is( ":visible" ) ) {
                     input.autocomplete( "close" );
                     return;
                  }

                  // pass empty string as value to search for, displaying all results
                  input.autocomplete( "search", "" );
                  input.focus();
               });
         }
      });
   })(jQuery);
</script>

I changed parts of the original to make the combobox re-adjust to match the size of the underlying SELECT control. The original and modified versions are shown below for comparison.

// Original
var self = this;
var select = this.element.hide(),
 selected = select.children( ":selected" ),
 theTitle = select.attr("title"),
 value = selected.val() ? selected.text() : "";
var input = $( "" )
.attr('title', '' + theTitle + '')

// Modified. Can you see Wally?
var self = this;
var select = this.element,
   theWidth = select.width(),
   selected = select.children( ":selected" ),
   value = selected.val() ? selected.text() : "";
select.hide();
var input = $( "<input style=\"width:" + theWidth +
.val( value )
.attr('title', '' + theTitle + '')

Although the width of the combobox should now match the width of the underlying SELECT, the font used in the jQuery theme can still screw up the effect. E.g. a comparatively large (or small) font will still make the combobox appear to be the wrong width (the HTML SELECT would just re-size itself). I also made sure to copy over TITLE from the SELECT over to the INPUT that is being made here.

Another change I made from the original was to ensure that the two elements (an INPUT and a BUTTON) are enclosed by a SPAN with white-space: nowrap to ensure that the controls always stay together and never wrap

// Original
var input = $( "<input>" )
.insertAfter( select )
.val( value )

// Modified, Part 1. Removed insertAfter.
var input = $( "<input>" )
.val( value )

// Modified, Part 2, added two lines after .addClass( "ui-widget...
.addClass( "ui-widget ui-widget-content ui-corner-left" );
var span = $("<span style=\" white-space: nowrap;\"></span>")
      .append(input).insertAfter( select );

That's the end of the "infrastructure" code. Below is an abridged version of the underlying SELECT, an input that will display the actor's name when a selection is made (to demonstrate event handling), and a button that will toggle display of the underlying SELECT control.

<select id="starTrekCharacters">
   <option value="William Shatner">TOS - Captain James T. Kirk</option>
   <!-- et etc -->
 </select>
<input type="text" id="starTrekActor" length="30"/>
<button id="toggle">Show underlying select</button>

For your own combobox, you only need the SELECT. The INPUT and BUTTON are just there to help demonstrate event handling and to toggle showing the underlying SELECT control.

Also note that the jQuery combobox will drop any OPTIONs that don't have a value; so if you have a blank OTPION at the start (like my -- Choose a Star Trek character. --) you must give it a value and make sure that any back-end system knows how to deal with that value too.

The jQuery code that turns a SELECT into a combobox is also very easy, as seen below.

<script language="javascript" type="text/javascript">
   // This is run when the document has loaded.
   $(document).ready(function() {
      // Create a combobox
      $("#starTrekCharacters").combobox({
         // And supply the "selected" event handler at the same time.
         selected: function(event, ui) {
            $("#starTrekActor").val($("#starTrekCharacters").val());
         }
      });
      // NOT NEEDED for combobox; included just to let you see underlying SELECT.
      $("#toggle").click(function() {
         $("#starTrekCharacters").toggle();
      });
   });
</script>

As you can see, the code to create a combobox from a SELECT and provide an event handler is almost trivial.

$("#starTrekCharacters").combobox({
   selected: function(event, ui) {
      // ...
   }
});

Unfortunately, the documentation for the base autocomplete widget doesn't mention combobox and I had some difficulty working out how I was meant to adapt the SELECT event handler that is documented (see below).

$( ".selector" ).autocomplete({
   select: function(event, ui) { ... }
});

The main problem is that the jQuery standard event name for something being selected is select, but combobox calls it selected. Also, the documentation for autocomplete mentions six events - but combobox only handles one. This is all understandable since combobox is still a prototype. It's just hard to work out if you don't know what to look for - as described in this jQuery forum post Using Events in Combobox, in this StackOverflow post, Hooking event handlers to jQuery Autocomplete Combobox, in this comment on Jörn Zaefferer's blog post A jQuery UI Combobox: Under the hood and this rejected bug report: Autocomplete fails to call event handler for combobox.

So that's it for my long winded introduction to jQuery's autocomplete combobox widget. Despite the time I have spent working out how to use this - and the fact that it is still a prototype - the autocomplete functionality has me hooked and I will continue to use it wherever I can.