Creating Custom Form Elements Using jQuery: Selects

In Web design, there are a number of guidelines to follow. Guidelines based on past experience, user data, and other various sources of information used to support good design practice. There are entire schools of thought surrounding certain guidelines, many of which undergo rounds of scrutiny and revision on a consistent basis. One particular sect of Web design that I have become increasingly interested in is that of conventions used in form design.

We all know what it’s like to use a terribly designed form; irritating. It’s difficult enough to get an average reader to take the time to fill out a form, and not following a basic ruleset can turn away any reader in the blink of an eye.

One particular aspect of form design that has been partially taboo is the revision and customization of input widgets. The trouble with form widgets is the uniqueness not only between browsers, but operating systems especially. Altering input elements your reader has become intimately accustomed to using is not a practice to be taken lightly.

A recent client project brought about rare circumstances under which my team felt comfortable experimenting with some custom form widgets. After the first rounds of design had been put together, the custom form elements did an extraordinary job bringing the entire concept together. We decided to run with it. By far the most unique element was the select.

I knew from the start that the selects would be progressively enhanced, defaulting to stock browser form elements out of the box. We’ve all seen custom form elements before, and I knew there were some pre-fabricated solutions out there. There are even plugins for jQuery already. I had a look, but ultimately decided I would roll my own. I’d like to walk through my thought process as I prototyped a visual example for the client.

Laying the foundation for a custom select

What we’ll first begin with is the markup I use when including a select in my forms:

<form id="frm_prototype" action="index.php" method="post">
  <fieldset>
    <legend>Custom Select Test</legend>
    <div class="select">
      <label for="misc">Misc</label>
      <select name="misc">
        <option value="1">One</option>
        <option value="2">Two</option>
        <option value="3">Three</option>
      </select>
    </div>
    <div class="buttons">
      <button type="submit" id="submit" name="submit">Submit</button>
    </div>
  </fieldset>
</form>

Your markup may very well differ, as my past experience shows that various designers are slightly particular about form markup. I also began with just a bit of simple CSS on top of that:

form { margin-bottom:25px; }
form fieldset { border:0; }
form div { clear:left; padding:10px 0; }
form legend { font-size:16px; font-weight:bold; }
form label { display:block; width:75px; float:left; }
form select { display:block; width:225px; float:left; }
form button { padding:5px; }

Making a plan for the customization

I wanted to keep the progressive enhancement as semantic as possible, so I took a step back and thought about what a select represented, save the functionality. To me, a select includes a representative item from a set of available alternatives. In essence, the chosen value defines the select. The defining relationship stuck, and I elected to base my markup variations around a definition list.

Next, I took a few minutes to think about how I planned on executing the progressive enhancement. I came up with the following list of steps I’d take to recreate the interaction of the original select while retaining the integrity of the form itself; we still needed the data to submit properly.

  1. After the DOM was ready, loop through all selects. For each select:
  2. Hide the element itself
  3. Append my new markup
  4. Loop through all options for the current select and
  5. Add my own list of options, mirroring those from the original select
  6. Bind click events where applicable, making sure to set the proper value on our newly hidden select

The process seemed straightforward enough, so I began by creating the images I’d need for the custom select. I began by working with a static version of the enhanced markup (note the addition of a new class, enhanced):

<form id="frm_prototype" action="index.php" method="post">
  <fieldset>
    <legend>Custom Select Test</legend>
    <div class="enhanced select">
      <label for="misc">Misc</label>
      <select name="misc" style="display:none;">
          <option value="1">One</option>
          <option value="2">Two</option>
          <option value="3">Three</option>
      </select>
      <dl class="dropdown">
        <dt><a class="dropdown_toggle" href="#"><span>One</span></a></dt>
        <dd>
          <div class="options">
            <ul>
              <li><a href="#">One</a></li>
              <li><a href="#">Two</a></li>
              <li><a href="#">Three</a></li>
            </ul>
          </div>
        </dd>
      </dl>
    </div>
    <div class="buttons">
      <button type="submit" id="submit" name="submit">Submit</button>
    </div>
  </fieldset>
</form>

Some revisions to the CSS, of course (reformatted as multi-line for readability in the article):

/* Used only when enhanced */

.enhanced a {
	font-size: 11px;
	text-decoration: none;
	color: #7e7e7e;
}

.dropdown {
	float: left;
	width: 200px;
	position: relative;
}

.dropdown .options {
	position: absolute;
	left: 5px;
	top: 23px;
	overflow: auto;
	background: #fff;
	width: 169px;
	height: 55px;
	border: 1px solid #c8c8c8;
	border-top: 0;
	padding: 7px 10px;
}

.dropdown .options ul {
	list-style: none;
}

.dropdown .options a {
	display: block;
	font-size: 12px;
	padding: 2px 0;
}

.dropdown .options a:hover {
	text-decoration: underline;
}

a.dropdown_toggle {
	display: block;
	height: 24px;
	background: url(enhanced-select-arrow.jpg) top right no-repeat;
	padding-right: 25px;
}

a.dropdown_toggle span {
	display: block;
	background: url(enhanced-select-bg.jpg) no-repeat;
	padding: 6px 0 0 8px;
	height: 18px;
	cursor: pointer !important;
}

Writing the JavaScript

Now that I had some functional markup and style to use as an endpoint, I began with the fun part, writing the JavaScript. There are a number of other areas on the site using jQuery, my library of choice, so I began by including the library (1.3.2 as of this writing).

I had my mental list of steps to begin taking when progressively enhancing the selects, so I started with step 1: wait for the DOM to load, and then loop through all selects:

$(document).ready(function()
{
  $('select').each(function()
  {
    if(!$(this).parent().hasClass('enhanced'))
    {
      targetselect = $(this);
      targetselect.hide();

      // set our target as the parent and mark as such
      var target = targetselect.parent();
      target.addClass('enhanced');
    }
  }
}

Straightforward enough. That snippet of code uses jQuery’s document ready event to fire the referenced function once the DOM is ready for us. At this stage, we’re simply adding our enhanced class if it doesn’t have the class already. While in the loop, we’ll go ahead and hide the stock select.

Whenever I work with the same element numerous times, I make an effort to declare it as a variable. It helps to not rely on jQuery looking up the element each and every time and instead finding it once and saving that reference as a variable for future use.

Our next goal is to append the markup we used in Step 2. We’re not able to include all the markup, as we’ve only traversed to the select itself thus far, so we’ll only inject the parent elements we know we’ll need for now.

// prep the target for our new markup
target.append('<dl class="dropdown"><dt><a class="dropdown_toggle" href="#"></a></dt><dd><div class="options"><ul></ul></div></dd></dl>');
target.find('.dropdown').css('zIndex',z);
z--;

// we don't want to see the options yet
target.find('.options').hide();

We’re also minding the z-index here, as with the creation of each new enhanced select, we’re going to need to make sure there’s no improper overlapping. z is a global variable set to 999 at the time of its declaration. With each progressive enhancement, we’re going to decrement z to ensure proper z-order stacking.

Now we’ll go ahead and duplicate the options from the original select:

// parse all options within the select and set indices
var i = 0;
var options = '';
targetselect.find('option').each(function() 
{
  // add the option
  options += '<li><a href="#"><span class="value">' + $(this).text() + '</span><span class="hidden index">' + i + '</span></a></li>';

  // check to see if this is what the default should be
  if($(this).attr('selected') == true)
  {
    targetselect.parent().find('a.dropdown_toggle').append('<span></span>').find('span').text($(this).text());
  }
  i++;
});
target.find('.options ul').append(options);

In this snippet, we’re using our reference variable to find all of the child option elements. For each of those elements, we’re going to append a list item to the markup we just injected. Through each iteration, we’re also checking to see which option is currently selected, in order to correctly populate our enhanced select. We also append a second span to each list item to allow the tracking of the option index, we’ll need this later in order to accurately populate the stock select.

Excellent, now we’ve got our progressively enhanced select ready to go. The last step will be binding click events in an effort to mimic functionality of the original select.

Binding click events on our custom select

The first event we’ll need to bind is the click event on what we’ll call the toggle. This is the link we first created, included as a child of the dt element.

// let's hook our links, ya?
$('a.dropdown_toggle').live('click', function() 
{
	var theseOptions = $(this).parent().parent().find('.options');
	if(theseOptions.css('display')=='block')
	{
		$('.activedropdown').removeClass('activedropdown');
		theseOptions.hide();
	}
	else
	{
		theseOptions.parent().parent().addClass('activedropdown');
		theseOptions.show();
	}
	return false;
});

The conditional included controls whether or not the associated options list we created earlier is visible or not.

I’m using a new feature included within jQuery 1.3, the live event. It’s a terrific addition that binds an event for all current and future matched elements. I’ve elected to use the live event here as there was a possibility for new selects to be created on the fly after the DOM had initially loaded. The live event prevents my having to continually monitor the click events on existing and new toggle links.

Now that the display toggle for our options is in place, we need to also bind to the click events for all of the anchors included in our list of options as well. If any of those anchors are clicked, we need to retrieve the proper value associated with the link, update the toggle link text to reflect the change, and most importantly we need to update the value of the original select that was hidden long ago.

$('.options a').live('click', function(e)
{
	$('.options').hide();
	
	var enhanced = $(this).parent().parent().parent().parent().parent().parent();
	var realselect = enhanced.find('select');
	
	// set the proper index
	realselect[0].selectedIndex = $(this).find('span.index').text();
	
	// update the pseudo selected element
	enhanced.find('.dropdown_toggle').empty().append('<span></span>').find('span').text($(this).find('span.value').text());
	
	return false;
});

Here, we bind to the click event of each child anchor of a parent with a class of ‘options’. Upon click, we’re going to hide the visible parent (only one will be visible), determine the parent-most element (to which we added the ‘enhanced’ class), and find the associated select. Once we’ve found the select, we can set the proper index to ensure accurate data translation using the hidden span we included in earlier steps. Finally, we’ll go ahead and update the copy within our enhanced select.

Done and done? Not quite…

The issue with z-index wasn’t something I initially thought of, it was something I only discovered as I was working. There were a number of additional speed bumps I ran into, which are terribly important as well. As it stood, interaction with the updated select was operating as expected, save for a passive action I originally overlooked. The trouble was with the toggle. In and of itself, the toggle was working as it should, but it was other interaction causing some trouble. For example, if your reader were to toggle open the options of your select, and then move elsewhere in the document without first closing the display of the options by again clicking the toggle, the list would remain in view.

This issue is easy enough to overcome. Prior to finding and looping through all of the selects in the document, you’ll need to bind to another event:

$(document).mousedown(checkExternalClick);

Binding to the mousedown event for the entire document will fire the checkExternalClick function:

checkExternalClick = function(event)
{
	if ($(event.target).parents('.activedropdown').length === 0)
	{
		$('.activedropdown').removeClass('activedropdown');
		$('.options').hide();
	}
};

What this function does is force any open option lists to hide should the reader click outside our new and improved select. It uses a conditional to check for the class we added during the original toggle, as to not interfere with any other functionality that may have been put in place.

All together, our JavaScript looks like this:

var z = 999;

checkExternalClick = function(event)
{
  if ($(event.target).parents('.activedropdown').length === 0)
  {
    $('.activedropdown').removeClass('activedropdown');
    $('.options').hide();
  }
};



$(document).ready(function()
{
  $(document).mousedown(checkExternalClick);
  
  $('select').each(function() 
  {
    if(!$(this).parent().hasClass('enhanced'))
    {
      targetselect = $(this);
      targetselect.hide();
      
      // set our target as the parent and mark as such
      var target = targetselect.parent();
      target.addClass('enhanced');
      
      // prep the target for our new markup
      target.append('<dl class="dropdown"><dt><a class="dropdown_toggle" href="#"></a></dt><dd><div class="options"><ul></ul></div></dd></dl>');
      target.find('.dropdown').css('zIndex',z);
      z--;
      
      // we don't want to see it yet
      target.find('.options').hide();
      
      // parse all options within the select and set indices
      var i = 0;
      targetselect.find('option').each(function() 
      {
        // add the option
        target.find('.options ul').append('<li><a href="#"><span class="value">' + $(this).text() + '</span><span class="hidden index">' + i + '</span></a></li>');
        
        // check to see if this is what the default should be
        if($(this).attr('selected') == true)
        {
          targetselect.parent().find('a.dropdown_toggle').append('<span></span>').find('span').text($(this).text());
        }
        i++;
      });
    }
  });


  // let's hook our links, ya?
  $('a.dropdown_toggle').live('click', function() 
  {
    var theseOptions = $(this).parent().parent().find('.options');
    if(theseOptions.css('display')=='block')
    {
      $('.activedropdown').removeClass('activedropdown');
      theseOptions.hide();
    }
    else
    {
      theseOptions.parent().parent().addClass('activedropdown');
      theseOptions.show();
    }
    return false;
  });


  // bind to clicking a new option value
  $('.options a').live('click', function(e)
  {
    $('.options').hide();

    var enhanced = $(this).parent().parent().parent().parent().parent().parent();
    var realselect = enhanced.find('select');

    // set the proper index
    realselect[0].selectedIndex = $(this).find('span.index').text();

    // update the pseudo selected element
    enhanced.find('.dropdown_toggle').empty().append('<span></span>').find('span').text($(this).find('span.value').text());

    return false;
  });
});

Almost perfect, but not quite

I post this example not to propose the best possible solution for progressively enhancing select elements. There are some issues which still need attention, that browser default form widgets better handle out of the box. Such things as rendering the list of options within the viewport should the select be positioned too close to the bottom. We didn’t cover that scenario, but it’s something that can be easily overcome using jQuery and a couple conditionals.

While it was great working on a small technique such as this, it really got me thinking about the viability of such a thing. Interface design is a sensitive subject for me, and messing with such standard conventions as browser & operating system interface elements felt wrong on a low level. On the contrary, the stock form widgets shipping with browsers today aren’t the most gorgeous thing to include in your designs. Would you consider drastically changing the appearance of your form elements?