An HTML List Builder: A Study in Applying jQuery
Join the DZone community and get the full member experience.
Join For Free- 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)
<?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>
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;
}
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);
$('#rightList').width(selectWidth);Requirement 5 is now met:
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 });
and requirement 6 is met.
Returning State
<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);
}
updateListBuilderState();
var updateState = function(event)
{
updateListBuilderState();
};
leftSelect.click(updateState).keyup(updateState);
rightSelect.click(updateState).keyup(updateState);
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
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);
});
var moveUpHandler = function(selectId)
{
return function(event) {
var selected = $('#' + selectId + ' option:selected');
// logic as before
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();
};
}
var handler = moveUpHandler('leftSelect');Assigning the handler to the right button is similar.
leftUpButton.click(handler).keyup(handler);
Moving Down
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;
}
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;
}
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
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));
Opinions expressed by DZone contributors are their own.
Comments