An HTML List Builder: A Study in Applying jQuery
Join the DZone community and get the full member experience.
Join For Free Recently, a client requested a particular implementation for a
control in a web application. What they wanted looked like a standard
Windows widget, called by Microsoft a list builder.
You've undoubtedly seen a list builder: two lists next to one another,
the left one initially filled with options, the right one initially
empty, with buttons to move selected items from one list to the other.
I recognized the idiom and got to work, thinking I'd find a solution
without too much difficulty. Of course, I wasn't about to reinvent the
wheel, so the first thing I did was to check through toolkits I was
already using.
Nowadays I simply don't write JavaScript without
jQuery, so I checked out the jQuery UI project, which I knew had some
building blocks for making UI widgets. No such luck. There were bits I
could have used if I hadn't been dealing with a client who was only
interested in solutions that were useful to those needing assistive
technologies. No mouse-driven drag-and-drop solutions, then. Besides,
such solutions would have violated the principle of least surprise:
that's not how Windows list builders work.
In fact, as I looked around, I was bemused to discover that no matter
how widely I spread my seach net, I couldn't seem to catch a really
good overall solution for this problem. There were a few good,
incomplete suggestions, without a doubt, but it was increasingly clear
I would have to roll my own.
Requirements
Naturally, the first thing to put together about any solution is a set of requirements. The obvious ones were these:
- The solution had to contain two lists
- Items had to be able to be moved using buttons from each list to the other
- The user should be able to use the solution with the keyboard only
- The solution had to be useful within an HTML form
Requirement 4 meant that the solution had to return a state representation that could be interpreted on a server.
After some consideration, I added the following:
- The selects should have the same height and width regardless of their current contents
- The buttons should be vertically centered on the selects and horizontally centered on each other
- There should be buttons to move all of the contents of each list to the other
- There should be buttons to move any combination of items within each list up or down (in other words, to reorder the lists)
The Elements
The
first step was obviously to put the main elements into place, two
selects (obvious choices for the lists) and buttons (including a submit
button for the form the list builder would belong to):
<?xml version="1.0" encoding="UTF-8"?>At this point, I had the basic parts, but it didn't look like much:
<!DOCTYPE html
PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title>List Builder</title>
<link rel="stylesheet" type="text/css" href="css/list-builder.css"/>
<script type="text/javascript" src="js/jquery-1.2.6.min.js"></script>
<script type="text/javascript" src="js/list-builder.js"></script>
</head>
<body>
<form method="post" action="list-builder.html">
<fieldset>
<div>
<div id="leftButtons">
<button type="button" id="leftUpButton" class="button">Up</button>
<button type="button" id="leftDownButton" class="button">Down</button>
</div>
<div id="leftList">
<select id="leftSelect" size="10" multiple="multiple">
<option value="first">First</option>
<option value="second">Second</option>
<option value="third">Third</option>
<option value="fourth">Fourth</option>
<option value="fifth">Fifth</option>
<option value="sixth">Sixth</option>
<option value="seventh">Seventh</option>
<option value="eighth">Eighth</option>
<option value="ninth">Ninth</option>
<option value="tenth">Tenth</option>
<option value="eleventh">Eleventh</option>
<option value="twelfth">Twelfth</option>
<option value="thirteenth">Thirteenth</option>
<option value="fourteenth">Fourteenth</option>
<option value="fifteenth">Fifteenth</option>
</select>
</div>
<div id="middleButtons">
<div id="addRemoveButtons">
<button type="button" id="addButton" class="button">Add</button>
<button type="button" id="removeButton" class="button">Remove</button>
</div>
<div id="addAllRemoveAllButtons">
<button type="button" id="addAllButton" class="button">Add All</button>
<button type="button" id="removeAllButton" class="button">Remove All</button>
</div>
</div>
<div id="rightList">
<select id="rightSelect" size="10" multiple="multiple">
</select>
</div>
<div id="rightButtons">
<button type="button" id="rightUpButton" class="button">Up</button>
<button type="button" id="rightDownButton" class="button">Down</button>
</div>
</div>
<div style="clear: both; padding-top: 15px;">
<input type="submit" value="Submit"/>
</div>
</fieldset>
</form>
</body>
</html>
[img_assist|nid=6915|title=List Builder HTML only|desc=|link=none|align=center|width=375|height=490]
The next step was to try to style these parts in a minimalist way:
The next step was to try to style these parts in a minimalist way:
@charset "utf-8";This looked a little better:
fieldset {
border: 0;
margin: 0;
padding: 0;
}
#leftButtons, #leftList, #rightButtons, #middleButtons, #rightList {
float: left;
}
/* spacing between the add-remove group and the addAll-removeAll group */
#addAllRemoveAllButtons {
margin-top: 15px;
}
.button {
display: block;
margin-bottom: 3px;
}
[img_assist|nid=6916|title=List Builder HTML and CSS|desc=|link=none|align=center|width=330|height=220]
Far
from perfect, but at least requirement 1 had been met. Still, there was
the question of vertical centering (not to mention the actual
functionality, of course!). It occurred to me that while I could
probably figure out a CSS solution that would accomplish some or even
most of what I had in mind, the browser has already done most of the
computation I needed: all I needed to do was to build on what it had
already done.
This, of course, jQuery makes remarkably easy. The
jQuery JavaScript library is increasingly widely accepted, in part
precisely because it makes working with HTML DOM elements such a
tractable problem. In particular, its formulation of the document-ready function,
a function that is run after the DOM has been initialized by the
browser (and so the brower's computation of initial layout has been
completed) and before images have been loaded and the view has been
rendered. This enables modifications to be performed that do not cause
the flickering and visible redrawing that are among the hallmarks of
standard onload functions.
Sizes
Since I had provided most of the elements within the HTML with id attributes, sizing the lists was pretty easily accomplished with jQuery, which has excellent support for CSS selectors:
var leftSelect = $('#leftSelect');
var rightSelect = $('#rightSelect');
var selectWidth = leftSelect.width();
var selectHeight = leftSelect.height();
leftSelect.width(selectWidth).height(selectHeight);
rightSelect.width(selectWidth).height(selectHeight);
A
quick explanation for those unfamiliar with jQuery: the selector
"$('#leftSelect')" in jQuery returns the element with the id attribute
"leftSelect", namely the left select (you can see the imagination I
bring to my ids), wrapped in a jQuery object that acts pretty much like
an array. Of course, since I have used an id attribute, this had better
be a one-element array!, so I could have retrieved the actual select
using "$('#leftSelect')[0]". However, it was more useful to have the
wrapper, since it has a number methods that simplify and regularize
access to DOM properties. These can be seen in the remainder of the
code and are largely self-explanatory, with the proviso that methods
that take no parameters generally return the value of the property and
those that take parameters generally reset the value of the property.
The div
element that holds the right select must also be wide enough to push
the up and down buttons on its right to the right location:
$('#rightList').width(selectWidth);Requirement 5 is now met:
[img_assist|nid=6917|title=List Builder selects sized|desc=|link=none|align=center|width=400|height=210]
Centering
The browser already knows the sizes of everything and needs only to be asked to reveal its results. However, we first need to be able to interpret the string values of style settings as a number:
The browser already knows the sizes of everything and needs only to be asked to reveal its results. However, we first need to be able to interpret the string values of style settings as a number:
var numericValue = function(value)We also need the height of a div representing the row:
{
var digits = value.match(/[0-9]+/);
if (digits && digits.length > 0)
{
return Number(digits[0]);
}
return Number(0);
};
var divHeight = $('#leftList').height();Once we have these prerequisites, vertical centering of the up and down button div is straightforward:
var leftButtons = $('#leftButtons');Horizontal centering is a bit more complicated, but not terribly so. It requires knowing the expected height of the div:
var leftButtonsHeight = leftButtons.height() + numericValue(leftButtons.css('margin-top')) +
numericValue(leftButtons.css('margin-bottom')) + numericValue(leftButtons.css('padding-top')) +
numericValue(leftButtons.css('padding-bottom'));
var leftButtonsMargin = ((divHeight - leftButtonsHeight) / 2) + 'px';
leftButtons.css('margin-top', leftButtonsMargin);
var leftUpButton = $('#leftUpButton');Now the buttons and selects appear correctly:
var leftDownButton = $('#leftDownButton');
var divWidth = leftButtons.width();
var leftUpMargin = ((divWidth - leftUpButton.width()) / 2) + 'px';
leftUpButton.css({ 'margin-left': leftUpMargin, 'margin-right': leftUpMargin });
var leftDownMargin = ((divWidth - leftDownButton.width()) / 2) + 'px';
leftDownButton.css({ 'margin-left': leftDownMargin, 'margin-right': leftDownMargin });
The same applies, of course, to the right up and down buttons.
The principle is the same with the middle buttons; there are simply more of them:
// center the middle buttons vertically
var middleButtons = $('#middleButtons');
var middleButtonsHeight = middleButtons.height() + numericValue(middleButtons.css('margin-top')) +
numericValue(middleButtons.css('margin-bottom')) + numericValue(middleButtons.css('padding-top')) +
numericValue(middleButtons.css('padding-bottom'));
var middleButtonsMargin = ((divHeight - middleButtonsHeight) / 2) + 'px';
middleButtons.css('margin-top', middleButtonsMargin);
// center the middle buttons horizontally
var addButton = $('#addButton');
var removeButton = $('#removeButton');
var addAllButton = $('#addAllButton');
var removeAllButton = $('#removeAllButton');
divWidth = middleButtons.width();
var addMargin = ((divWidth - addButton.width()) / 2) + 'px';
addButton.css({ 'margin-left': addMargin, 'margin-right': addMargin });
var removeMargin = ((divWidth - removeButton.width()) / 2) + 'px';
removeButton.css({ 'margin-left': removeMargin, 'margin-right': removeMargin } );
var addAllMargin = ((divWidth - addAllButton.width()) / 2) + 'px';
addAllButton.css({ 'margin-left': addAllMargin, 'margin-right': addAllMargin });
var removeAllMargin = ((divWidth - removeAllButton.width()) / 2) + 'px';
removeAllButton.css({ 'margin-left': removeAllMargin, 'margin-right': removeAllMargin });
[img_assist|nid=6918|title=List Builder final appearance|desc=|link=none|align=center|width=435|height=205]
and requirement 6 is met.
and requirement 6 is met.
Returning State
Since
selects only return the values of selected options, a complete
representation of the state of the list builder is impossible to
guarantee without some JavaScript intervention. A hidden input in the
HTML:
<input type="hidden" name="listBuilderState" id="listBuilderState"/>and a method that can be called within event handlers:
var updateListBuilderState = function() {
var state = '<?xml version="1.0" encoding="UTF-8"?>\n';
state += '<state>\n';
state += ' <select instance="left">\n';
var selected = $('#leftSelect option');
for (var i = 0; i < selected.length; i++)
{
var option = selected[i];
state += ' <value';
if (option.selected)
{
state += ' selected="true"';
}
state += '>';
state += option.value;
state += '</value>\n';
}
state += ' </select>\n';
state += ' <select instance="right">\n';
selected = $('#rightSelect option');
for (var i = 0; i < selected.length; i++)
{
var option = selected[i];
state += ' <value';
if (option.selected)
{
state += ' selected="true"';
}
state += '>';
state += option.value;
state += '</value>\n';
}
state += ' </select>\n';
state += '</state>\n';
$('#listBuilderState').val(state);
}
allows
the list builder to return an XML document as part of the form
submission. This makes POST the preferred method for submitting forms
using the list builder, since GET URLs may be limited in length.
This
method will be called during our document-ready function in order to
initialize the value of the form hidden input, and updated each time
the left or right selects are used, either by mouse or keyboard:
updateListBuilderState();
var updateState = function(event)
{
updateListBuilderState();
};
leftSelect.click(updateState).keyup(updateState);
rightSelect.click(updateState).keyup(updateState);
This satisfies requirement 4.
Add-Remove Functionality
Thanks to jQuery's advanced selectors, the functionality to transfer list entries from one select to the other is uncomplicated:
handler = function(event)This meets requirements 2 and 7.
{
$('#leftSelect option:selected').appendTo('#rightSelect').attr({ selected: false });
updateListBuilderState();
this.blur();
};
addButton.click(handler).keyup(handler);
handler = function(event)
{
$('#rightSelect option:selected').appendTo('#leftSelect').attr({ selected: false });
updateListBuilderState();
this.blur();
};
removeButton.click(handler).keyup(handler);
handler = function(event)
{
$('#leftSelect option').appendTo('#rightSelect').attr({ selected: false });
updateListBuilderState();
this.blur();
};
addAllButton.click(handler).keyup(handler);
handler = function(event)
{
$('#rightSelect option').appendTo('#leftSelect').attr({ selected: false });
updateListBuilderState();
this.blur();
};
removeAllButton.click(handler).keyup(handler);
Movin' on Up
The
algorithm for moving an entry up within a select is not complicated;
there are two relevant methods in the HTML DOM Select object: remove and add.
The way they work is that the remove method takes an index number
(zero-based) and deletes the corresponding HTML DOM Option object from
the select and renumbers the index numbers for the remaining Option
objects. The add method takes the Option object to be added and an
Option object already in the list before which the supplied
option is to be inserted. If the second option is null, the first
option is appended to the end of the list (that is to say, before the
first empty entry).
For a single entry, when you remove an entry
from the list, it is certain that either that option is first (at index
0) or it has a neighbor with an index lower by 1. The logic is simple:
put a guardian to ensure that if the index of the option to be removed
is 0, do nothing; if it is greater than 0, find this neighbor, and use
it as the second parameter in the add method.
Again jQuery comes to
the rescue, simplifying the process. First, we find the selected
options to move (we'll return in a few paragraphs to exactly how to do
this):
var selected = ...;If there are any, we check the first one for index 0; if the first has the index 0, we simply exit:
if (selected.length > 0)The comment above shows where the main logic goes. This loops through the selected options, calling a function that gets the index of the option and gets the Select object containing the whole list (again, we'll return in a few paragraphs as to how this is done), finds the previous option in the list, and calls the add and remove methods as described above:
{
if (selected[0].index == 0)
{
return;
}
// main logic here
}
selected.each(function(index) {
var i = this.index;
var list = ...;
var previous = list.options[i - 1];
list.remove(i);
list.add(this, previous);
});
So
far, so good, but how to attach this to a button? jQuery provides easy
methods for binding functions as event handlers; the functions should
take an event as a parameter, which won't be needed in this
particular algorithm.
However, we don't want to cut and paste the
logic twice, once for the left and once for the right button. Instead,
we want to parameterize the function so as to be able to tell it which
select we want at runtime. What to do?
The answer depends upon the nature of JavaScript, which treats functions differently from many familiar languages by not
treating them differently from other objects. In other words, functions
are simply objects you can access with a particular syntax. In fact,
JavaScript functions are actually closures. A closure is a function that
comes with an environment of "magically" assigned external values upon
which it can operate. In the case of a JavaScript function, those magic
external values consist of any variable that was in scope at the moment
the function was created.
So we can use a function that returns the
handler function we want. The logic is identical for both lists save
for the id of the list; we will pass the id of the list into the outer
function as a parameter:
var moveUpHandler = function(selectId)
{
return function(event) {
var selected = $('#' + selectId + ' option:selected');
// logic as before
Now we can see how the list of selected options was retrieved: even though the moveUpHandler
function is long finished and out of scope by the time the actual
handler (the function it returns) fires, the value of the parameter selectId, since it was in scope when the handler was created, is still available for use in the jQuery selector.
Something
similar is done inside the function applied to each of the selected
options, since selectId is still in scope even there:
var list = $('#' + selectId)[0];Now the entire function returning the event handler can be seen together:
var moveUpHandler = function(selectId)
{
return function(event) {
var selected = $('#' + selectId + ' option:selected');
if (selected.length > 0)
{
if (selected[0].index == 0)
{
return;
}
selected.each(function(idx) {
var i = this.index;
var list = $('#' + selectId)[0];
var previous = list.options[i - 1];
list.remove(i);
list.add(this, previous);
});
}
updateListBuilderState();
this.blur();
};
}
Note
that at the end of the logic for moving values, we update the hidden
input with the current state of the list builder and blur (that is,
unfocus) the button. This last is a convenience, but the code reminds
us of the many meanings of this in JavaScript: in the function applied to each of the selected option, this is the option; in the handler function, this is the button pressed.
Now assigning the handler to events fired by the left button is simple:
var handler = moveUpHandler('leftSelect');Assigning the handler to the right button is similar.
leftUpButton.click(handler).keyup(handler);
Moving Down
More complicated is the functionality to move list entries down.
In particular, moving multiple entries down is complex. What, in
fact, does it mean?
The
question becomes interesting when you consider moving blocks of
entries. For example, consider a list of five entries (we'll call them
1, 2, 3, 4, and 5). Suppose you want to move all the entries but the
last down by one. There are at least two configurations with which you
might expect to wind up: 5, 1, 2 3, 4; and 1, 2, 3, 5, 4. The first
configuration takes the block of entries, maintains its integrity, and
allows the surrounding entries to flow around it. The second actually
reverses the problem: it considers the problem as taking the fifth
entry (the only one not originally selected to be moved) and moving
that entry up.
As posed so far, both definitions of what
constitutes moving multiple entries are reasonable. The problems with
the second definition are clarified, however, when the selections do
not constitute a block.
Suppose you select 1, 2, and 4 to move down.
According to the second definition, the result of the movement is 1, 3,
2, 5, 4. The first selection hasn't moved at all! If you try moving the
block up again using the logic above, nothing happens, since the first
element of the block has the index 0. This seems counter-intuitive.
Using the first definition results in the configuration 3, 1, 2, 5, 4,
which both seems more intuitive as a result and is easily reversed by
moving the block up according to the logic we have already seen.
Reversibility is an intuitively useful trait, so we will use the first
definition here.
To implement it, we will use the same methodology
described above to create a closure. First, we must ensure there is
something to be moved:
var selected = $('#' + selectId + ' option:selected');Then we must determine whether or not the last selected option is also last in the original list. If so, we will do nothing:
if (selected.length > 0)
{
// logic here
}
var options = $('#' + selectId + ' option');
if (selected[selected.length - 1].index == options.length - 1)
{
return;
}
Then we must determine what option each selection to be moved will eventually appear before (remember, this is how the add
function works). We'll simplify the problem to consider moving only one
selection at a time, and call the option before which this selection is
to be moved x. Our problem, as so often in high-school algebra, is to solve for x.
To
do this, we need a helper function that will allow us to distinguish
between options selected to be moved and those not selected. This is
not complicated:
var contains = function(list, value)
{
if (! value)
{
return false;
}
for (var i = 0; i < list.length; i++)
{
if (list[i] == value)
{
return true;
}
}
return false;
}
With this in place, we can write the actual logic. We need to hold on to two variables. The first is a candidate option x (remember, this is a candidate option for the second parameter to the add method). To initialize it, we simply count forward two options from the index of the selection to be moved, skipping the first.
The
second is the list of options having a higher index than the selection
to be moved. We loop through this list one option at a time: if the
option encountered is a member of the options selected to be moved,
skip the candidate x one further forward (this maintains the
relationships between the selected options) and go on to consider the
next option in the list. If we wind up past the end of the list, stop; x is null and we will append to the end of the list. Otherwise, stop when the index of the candidate x is greater than the index of the current option in the list.
var befores = new Array();Then, of course, the problem is a simple one:
for (var i = 0; i < selected.length; i++)
{
var currentIndex = selected[i].index;
var beforeIndex = currentIndex + 2;
var before = options[beforeIndex];
for (var j = 1; before; j++)
{
if (contains(selected, options[currentIndex + j]))
{
beforeIndex++;
before = options[beforeIndex];
}
else if (before.index > options[currentIndex + j].index)
{
break;
}
}
befores.push(before ? before : null);
}
selected.each(function(index) {Finally, we can see the whole thing together:
list = $('#' + selectId)[0];
list.remove(this.index);
list.add(this, befores[index]);
});
var moveDownHandler = function(selectId)Again, we update the hidden input with the current state of the list builder and blur the button after moving. Assigning the handler to events fired by the left button is simple:
{
return function(event) {
var selected = $('#' + selectId + ' option:selected');
if (selected.length > 0)
{
var options = $('#' + selectId + ' option');
if (selected[selected.length - 1].index == options.length - 1)
{
return;
}
var befores = new Array();
for (var i = 0; i < selected.length; i++)
{
var currentIndex = selected[i].index;
var beforeIndex = currentIndex + 2;
var before = options[beforeIndex];
for (var j = 1; before; j++)
{
if (contains(selected, options[currentIndex + j]))
{
beforeIndex++;
before = options[beforeIndex];
}
else if (before.index > options[currentIndex + j].index)
{
break;
}
}
befores.push(before);
}
selected.each(function(index) {
list = $('#' + selectId)[0];
list.remove(this.index);
list.add(this, befores[index] ? befores[index] : null);
});
}
updateListBuilderState();
this.blur();
};
};
handler = moveDownHandler('leftSelect');Again, assigning the handler to the right button is similar.
leftDownButton.click(handler).keyup(handler);
This meets requirements 8.
Accessibility
One more detail of implementation is needed to complete requirement 3. We have used a number of onkeyup
handlers, which are useful where accessibility via keyboard is an
issue. However, moving from one control to the other on an HTML page
uses the tab key (or shift-tab, for reversing the motion) and we don't
want the use of these keys to fire the onkeyup handler when all we are
trying to do is navigate from one control to another!
The solution
is a simple function that wraps another function, checking the event
key code to ensure that it isn't either the tab key (ASCII character 9)
or the shift key (16). If the key code passes the test, the inner
function will be called:
var doNotFireOnTab = function(handler) {Now, we can slightly change the assignment given above:
return function(event)
{
var keyCode = event.keyCode;
// ignore tab and shift-tab
if (keyCode == 9 || keyCode == 16)
{
return;
}
handler(event);
}
}
handler = moveDownHandler('leftSelect');
leftDownButton.click(handler).keyup(doNotFireOnTab(handler));
Similar changes will be applied to all the onkeyup handler assignments.
Conclusion
This was a not inconsiderable set of requirements.
The simplicity of this solution owes almost entirely to jQuery. Without
the underlying library, the code for the solution would be far more
complex. Thanks, jQuery!
Download
I have set up the
solution in a WAR file for anyone thinking of giving it a try. Your
local Tomcat should easily deploy it; similar solutions in other
languages should be relatively straightforward. The solution alternates
between an XHTML form containing the list builder (NB. this may confuse
older versions of IE) and a page showing the XML sent back to the
server with a link returning to the form page.
Have fun!
HTML
JQuery UI
Opinions expressed by DZone contributors are their own.
Comments