Creating Custom Form Elements Using jQuery: Selects

Posted: February 23, 2009 Comments(19)

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?

Get my newsletter

Receive periodic updates right in the mail!

  • This field is for validation purposes and should be left unchanged.

Comments

  1. Hot.

    But, while I think this is totally awesome, I can’t imagine ever doing it. The uniformity, albeit ugly uniformity is so essential to the usability of the form in my opinion. As ugly as they are, they work, and honestly we can make them look halfway decent anyways.

  2. I completely agree with you; stock form widgets are pretty ugly, and you can only get so far with CSS stylings on them. The one thing your control is missing is keyboard support. I like to tab through forms and get everything without touching the mouse, which can’t be done in your current iteration. It’s a great start, though, and quite a bit lighter than using Ext JS widgets.

  3. Thanks for the feedback, everyone!

    @Daniel Laughland: You’re absolutely correct. In fact, that’s what I’m currently working on as this project progresses. I’ll be sure to include an outline of keyboard support as that becomes available. I’m also considering releasing the example as a plugin once other form elements are taken care of. Thanks very much for bringing that up, as I’m surprised to have left it out of the article itself!

  4. Hi there!
    I found your solution really helpfull, especially since its also enabled for IE6!

    The only 2 problems / remarks i have are :

    – on page load, – the options div is shown by default.
    i can hide this y setting it CSS to display none – but im not sure if thats okay?

    – also, when selecting an option in the dropdown, it is not shown into the box… i can see the dropdown with the options, but when i click one, the upper field just stays empty…

    any help on these???

    thanks alot!

  5. good
    >> $(this).parent().parent().parent().parent().parent().parent();
    better ?
    >> $(this).parents(“.classname”) .eq(0)

  6. Hi Jonathan,

    First of all, I’m a big fan of MBN. I really enjoy reading everything you have to say.

    I do have one question, though… I was going to email it, but I figured others were probably wondering the same thing.

    Is there a way to make it so if the user selected a certain option, let’s say option two, to have an input box slide out from under it, asking them for one more piece of information? Essentially, a conditional input field, but with options instead of radio or checkboxes? I haven’t found anything that deals with them, and I would really appreciate it if you could help me out!

    Thanks so much!

  7. Hi Connor, first, thanks for the compliment! Great idea posting here instead of an email, I’m sure other people will find the answer useful.

    To achieve the result you’re looking for, you’ll need to play a bit with selectedIndex. You’ll need to set up a conditional for each index you’d like to hook with an appearing input box. The way you’d hook it would be via the change event in jQuery. The pseudocode would look something like this:

    On mySelect.change
    Check to see if the new selectedIndex equals x (where x is the index you’re looking for)
    If selectedIndex does equal x, show the input, if not, hide it

    Does that help a bit to get started?

  8. First of all: great job!! Saved me alot of time 😉
    But there is one bug when selecting/clicking an option. I think Jonathan Christopher already said it but in the code you let jquery search for classname “index” and get the text() from it, problem is that there is no text() there and the search should go for class “value” so it can match. The selectboxes work fine, but once submitted you get empty values.

    here is my solution to it (a little sloppy code sorry):

    // set the proper index
    var t = $(this).find(‘span.value’).text();
    for (var i = 0; i < realselect[0].options.length; i++) {
    if (realselect[0].options[i].innerHTML == t) {
    realselect[0].selectedIndex = i;
    }
    }
    //the above code is to replace this line:
    //realselect[0].selectedIndex = $(this).find('span.value').text();

  9. Hi,

    Thanks for such great work, This custom dropdown not supported the change event.
    $(‘.enhanced select’).change(function() {
    alert(‘Handler for .change() called.’);
    });
    Please help me out..

    Thanks
    Rajiv

  10. Johnathan (or anyone),

    I’m looking for working demo of this kind of form to DOM replacement process. The idea is solid but a simple demo would make figuring it out way easier.

    thanks
    .jeff

  11. Excellent, very good contribution. The only problem is direferenciacion between selects, I had to modify a little js.

    thank you very much.

    (Sory for my english)

Leave a Reply

Your email address will not be published. Required fields are marked *