A JQuery UI Dropdown List

In a recent iteration of find.ly that I have been working on, I was asked to create a form that is far removed from the look of standard browser form controls. In particular, the dropdown lists look nothing like the native control and could not be reproduced using only css.

This led me to the jQuery UI combox which uses the jQuery UI autocomplete control. This worked very nicely except that it is not a dropdown list that users are most used to seeing. As soon as the new form entered QA, I was pinged with a message saying that it was a bit awkward to use and would probably be difficult for users. I couldn’t disagree with that and this motivated me to try and see if I could make it more like a dropdown list.

After a few hours of head scratching, I am ready to present the first version of my control, which I would say is about 90% faithful to the feel of native control. There are a few things it does not do but I feel I am close enough to release it to the world for criticism.

Try the demo

Here is the code:

(function ($) {
    $.widget("ui.combobox", {
        _create: function () {
            var self = this,
				select = this.element.hide(),
				selected = select.children(":selected"),
				value = selected.val() ? selected.text() : "",
				regSearch = /^[^a-zA-Z0-9]*([a-zA-Z0-9])/i,
				comboData = select.children("option").map(function () {
					if (this.value ) {
						var text = $(this).text(), 
							labelHtml = self.options.label ? self.options.label(this) : text; //allows list customization
 
						return {
							label: labelHtml,
							value: text,
							option: this
						};
					}
				});
 
            var input = this.input = $("<input type='text' />")
					.insertAfter(select)
					.val(value)
					.keydown( function( event ) {
							var keyCode = $.ui.keyCode;
							switch( event.keyCode ) {
								case keyCode.PAGE_UP:
								case keyCode.PAGE_DOWN:
								case keyCode.UP:
								case keyCode.DOWN:
								case keyCode.ENTER:
								case keyCode.NUMPAD_ENTER:
								case keyCode.TAB:
								case keyCode.ESCAPE:
									//let autocomplete handle these
									break;
								default:
									//prevent autocomplete doing anything
									event.stopImmediatePropagation();
									//only react to [a-zA-Z0-9]
									if ((event.keyCode < 91 && event.keyCode > 59)
										|| (event.keyCode < 58 && event.keyCode > 47)) {
 
										var str = String.fromCharCode(event.keyCode).toLowerCase(), currVal = input.val(), opt;
 
										//find all options whose first alpha character matches that pressed
										var matchOpt = select.children().filter(function() {
											var test = regSearch.exec(this.text);
											return (test && test.length == 2 && test[1].toLowerCase() == str);
										});
 
										if (!matchOpt.length ) return false;
 
										//if there is something selected we need to find the next in the list
										if (currVal.length) {
											var test = regSearch.exec(currVal);
											if (test && test.length == 2 && test[1].toLowerCase() == str) {
												//the next one that begins with that letter
												matchOpt.each(function(ix, el) {
													if (el.selected) {
														if ((ix + 1) <= matchOpt.length-1) {
															opt = matchOpt[ix + 1];
														}
														return false;
													}
												});
											}
										} 
 
										//fallback to the first one that begins with that character
										if (!opt)
											opt = matchOpt[0];
 
										//select that item
										opt.selected = true;
										input.val(opt.text);
 
										//if the dropdown is open, find it in the list
										if (input.autocomplete("widget").is(":visible")) {
											input.data("autocomplete").widget().children('li').each(function() {		
												var $li = $(this);
												if ($li.data("item.autocomplete").option == opt) {
													input.data("autocomplete").menu.activate(event,$li);
													return false;
												}
											});
										}
									}
									//ignore all other keystrokes
									return false;
									break;
								}
					  })
					.autocomplete({
					    delay: 0,
					    minLength: 0,
					    source: function (request, response) { response(comboData); },
					    select: function (event, ui) {
					        ui.item.option.selected = true;
					        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).text().match(matcher)) {
										this.selected = valid = true;
										return false;
									}
								});
								if (!valid) {
									// remove invalid value, as it didn't match anything
									$(this).val("");
									select.val("");
									input.data("autocomplete").term = "";
									return false;
								}
							}
					    }
					})
					.addClass("ui-widget ui-widget-content ui-corner-left")
					.click(function() { self.button.click(); })
					.bind("autocompleteopen", function(event, ui){
						//find the currently selected item and highlight it in the list
						var opt = select.children(":selected")[0];
						input.data("autocomplete").widget().children('li').each(function() {		
							var $li = $(this);
							if ($li.data("item.autocomplete").option == opt) {
								input.data("autocomplete").menu.activate(event,$li);
								return false;
							}
						});
					});
 
            input.data("autocomplete")._renderItem = function (ul, item) {
                return $("<li></li>")
					.data("item.autocomplete", item)
					.append("<a href='#'>" + item.label + "</a>")
					.appendTo(ul);
            };
 
            this.button = $("<button type='button'>&nbsp;</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();
					});
        },
 
		//allows programmatic selection of combo using the option value
        setValue: function (value) {
            var $input = this.input;
            $("option", this.element).each(function () {
                if ($(this).val() == value) {
                    this.selected = true;
                    $input.val(this.text);
					return false;
                }
            });
        },
 
        destroy: function () {
            this.input.remove();
            this.button.remove();
            this.element.show();
            $.Widget.prototype.destroy.call(this);
        }
    });
})(jQuery);

The particular functions I tried to add are:

  • Removal of any standard textbox typing characteristics
  • Typing in the textbox jumps to the item begining with that letter
  • Hitting the same letter continuously cycles through the items beginning with that letter
  • Programatically select an item using the value
  • Retain navigation through list via arrow keys/paging
  • Opening the list jumps to the selected item

Feel free to take it and use it but I am very keen on any improvements that can be made, so please let me know what you think.

I know that performance is a bit of a problem with large lists but stopping autocomplete from rebuilding the list each time it is displayed is a little tricky and would mean changing more of it’s private functions – something I was hoping to avoid. Suggestions welcome.

*Edit: I totally had my terminology messed up. I was calling my control a combobox which it absolutely isnt. It is a dropdown list, so I have updated the post to reflect that. *places dunce hat on*

 

19 thoughts on “A JQuery UI Dropdown List

  1. Hi,

    I have two combobox’s with in the same page so how to switch between two sources dynamically? Please helpme out.

    Regards,
    Rakesh

  2. This was very helpfull
    I some question about onchange event.
    You can use insted “selected” event
    like this

    (‘#combolist’).combobox(
    {
    selected: function () { alert(“something”); }
    }
    );

    the event is within the code
    look for this lines:
    self._trigger(“selected”, event, {
    item: ui.item.option
    });

    hope this help someone

  3. @glthomas thanks for your comment. Im afraid I am not working on this control at the moment but feel free to look into making the improvements yourself

  4. Regarding keyboard selection. Suppose you have 30 items in a list and only 5 are showing at any one time. Imagine you have a list of cities. The first five shown in the dropdown are “Atlanta”, “Baltimore”, “Boston”, “Chicago”, and “Detroit”. Other cities are in the list like “Milwaukee”, which you can get to by scrolling down in the dropdown or by using the down arrow keyboard entry. The problem is that if you type the letter “M” it should jump you to the first city starting with the letter “M” but it doesn’t. Keyboard character entry only works if it is in the list of items that is currently being displayed. This is a bug that should definitely get fixed. Also, I feel that the keyboard entry of multiple letters needs to absolutely be added if this box is giong to be adopted for professional use. This is a far more common use of a combobox than you are giving credit for. Please make these fixes. Overall I really like the combobox you’ve created. However usability will always trump style.

    Are you still working on this and when can we anticipate these improvements?

    Thanks

  5. Hey Dan,

    Thanks for the improved control. I like it.

    Here are a couple of additions I have made on mine that seem to make it work better for me.

    I was having a problem in that my original select had a tabindex that this was removing.

    I added this line for the var input. I put it after the .insertAfter(select) line, but I don’t suppose it matters too much.

    .attr(“tabindex”, select.attr(“tabindex”))

    I suppose I could have captured the select.attr(“tabindex”) in a var above.

    Also, for my purposes, it seemed better to include the button within the control at the end to help me line things up. I’m sure for some people this won’t make sense as the button will cover up text that is too wide. Anyway, I have added the following right after the area where the title is set on the button:

    .height(input.height())
    .width(input.height())
    .css(“position”, “absolute”)
    .css(“left”, String(input.position().left + input.width() – input.height()) + “px”)
    .css(“top”, String(input.position().top) + “px”)

  6. @Tarek You would need to trigger a custom jquery event as any selection of the combobox is done programmatically. Remember there are a few places where an item is marked as selected

  7. Thank you dan for your prompt response. Regarding question 1, I mean “caption” the text that humans can read. However I think I will be able to read it by using your hint.

    Thank you again!

  8. @Claudi Thanks.

    1) I am not quite sure what you mean by caption. You can access the generated textbox with $(‘#myselect’).data(‘combobox’).input – not sure if that will help. There are also the standard jquery ui autocomplete events that you can bind to and access values.

    2) You can use the setValue functions with: $(‘#myselect’).combobox(‘setValue’,’thevalue’);

  9. Hi! First, congrats for this widget. I had to tune it for my purpose but I have it working now. However, could you clear me some questions up?
    1) I’ve seen that .val() retrieves an option’s value attribute. That’s fine but, how can I retrieve an option’s caption? I tried to use .text() but it retrieves the whole text within the tag. How could I do something like $(‘#sel’).caption()?

    2) How can I use the setValue() function? I’ve tried to do, for instance, $(‘#sel’).val(1) and $(‘#sel’).setValue(1) but none of them works.

    Sorry. I’m a newbie and I’m just getting into jQuery’s world. Thanks!

  10. @jason suddenly it all makes sense. How stupid of me, you are absolutely right, I have my terminology totally messed up. I apologise to @tristanyer – I now see where the confusing was coming from. I have now updated the terminology in the post to reflect what it really is.

  11. I think the confusion here is coming from the use of the term “combo box”. An actual combo box combines a drop-down list with free text input, this is really a stylized drop-down list with some better interaction. Not a bad thing, just not a combo box. It does look nice, and if it’s what you were asked to produce then I say you did a good job.

  12. Sorry, I assumed when you said ‘better combo box’ you meant something that would provide the base requirement. My bad. Perhaps you should have called it ‘Stylized inferior select element’. Just a thought.

  13. @tristandyer Currently the control only reacts to the first letter. If you keep hitting that letter it cycles through them like the native control. A native control will react to more if you type really fast but I figured that it’s a small percentage of users that know and use that facility. I do however intent to investigate how I can do this. Saying that just because it does not react to more than one letter it does not solve the problem is a sweeping statement. It’s the one aspect I am yet to reproduce and I would argue that’s it’s not the most important.

  14. A better example might have different options so that you could really tell how it works. Also if you hit 3 for option 3 nothing happens.

    as far as I can tell it doesn’t solve the problem.

    In all of my experience the combobox should take more than one letter so that you can cut down the options shown. This solution doesn’t really do that.

  15. @David As I mention in the blog post, I was asked to create a combo that had a style that could not be created by using CSS on a native control. Have a look at the demo and try and you will see

  16. Why would you create a widget that mimics a native browser element? Wouldn’t it be better just to style the select?

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.