Two of the most common tasks performed with tabular data are sorting and paging.
In a large table, being able to rearrange the information that we're
looking for is invaluable. Unfortunately, these helpful operations can
be some of the trickiest to put into action.
First, we'll look at what it takes to perform table sorting, reordering data into a sequence that is most helpful to the user.
Server-side sorting
A common solution for data sorting is to perform it on the server side. Data in tables often comes from a database, which means that the code that pulls it out of the database can request it in a given sort order (using, for example, the SQL language's ORDER BY clause). If we have server-side code at our disposal, it is straightforward to begin with a reasonable default sort order.
Sorting is most useful, though, when the user can determine the sort order. A common method is to make the table headers (<th>) of sortable columns into links. These links can go to the current page, but with a query string appended indicating the column to sort by:
<table id="my-data">
<thead>
<tr>
<th class="name">
<a href="index.php?sort=name">Name</a>
</th>
<th class="date">
<a href="index.php?sort=date">Date</a>
</th>
</tr>
</thead>
<tbody>
...
</tbody>
</table>
The server can react to the query string parameter by returning the database contents in a different order.
Preventing page refreshes
This setup is simple, but
requires a page refresh for each sort operation. As we have seen, jQuery
allows us to eliminate such page refreshes by using AJAX methods. If we
have the column headers set up as links as before, we can add jQuery
code to change those links into AJAX requests:
$(document).ready(function() {
$('#my-data th a').click(function() {
$('#my-data tbody').load($(this).attr('href'));
return false;
});
});
Now when the anchors are
clicked, jQuery sends an AJAX request to the server for the same page.
When jQuery is used to make a page request using AJAX, it sets the X-Requested-With HTTP header to XMLHttpRequest
so that the server can determine that an AJAX request is being made.
The server code can be written to send back only the content of the<tbody>
element itself, and not the surrounding page, when this parameter is
present. This way we can take the response and insert it in place of the
existing<tbody> element.
This is an example of
progressive enhancement. The page works perfectly well without any
JavaScript at all, as the links for server-side sorting are still
present. When JavaScript is available, however, the AJAX hijacks the
page request and allows the sort to occur without a full page load.
JavaScript sorting
There are times, though,
when we either don't want to wait for server responses when sorting, or
don't have a server-side scripting language available to us. A viable
alternative in this case is to perform the sorting entirely on the
browser using JavaScript client-side scripting.
For example, suppose we have a table listing books, the author names, release dates, and prices:
<table class="sortable">
<thead>
<tr>
<th></th>
<th>Title</th>
<th>Author(s)</th>
<th>Publish Date</th>
<th>Price</th>
</tr>
</thead>
<tbody>
<tr>
<td><img src="../images/covers/small/1847192386.png"
width="49" height="61" alt="Building Websites with
Joomla! 1.5 Beta 1" />
</td>
<td>Building Websites with Joomla! 1.5 Beta 1</td>
<td>Hagen Graf</td>
<td>Feb 2007</td>
<td>$40.49</td>
</tr>
<tr>
JavaScript sorting, sortingexample<td><img src="../images/covers/small/1904811620.png"
width="49" height="61" alt="Learning Mambo: A
Step-by-Step Tutorial to Building Your Website" />
</td>
<td>Learning Mambo: A Step-by-Step Tutorial to Building
Your Website
</td>
<td>Douglas Paterson</td>
<td>Dec 2006</td>
<td>$40.49</td>
</tr>
</tbody>
</table>
We'd like to turn the table
headers into buttons that sort the data by their respective columns.
Let's explore some ways of doing this.
Row grouping tags
Note our use of the<thead> and<tbody> tags to segment the data into row groupings.
Many HTML authors omit these implied tags, but they can prove useful in
supplying us with more convenient CSS selectors to use. For example,
suppose we wish to apply typical even/odd row striping to this table,
but only to the body of the table:
$(document).ready(function() {
$('table.sortable tbody tr:odd').addClass('odd');
$('table.sortable tbody tr:even').addClass('even');
});
This will add alternating colors to the table, but leave the header untouched:
Using these row grouping tags, we will be able to easily select and manipulate the data rows without affecting the header.
Basic alphabetical sorting
Now let's perform a sort on the Title column of the table. We'll need a class on the table header cell so that we can select it properly:
<thead>
<tr>
<th></th>
<th class="sort-alpha">Title</th>
<th>Author(s)</th>
<th>Publish Date</th>
<th>Price</th>
</tr>
</thead>
Using JavaScript to sort arrays
To perform the actual sort, we can use JavaScript's built in .sort() method. It does an in-place sort on an array, and can take a comparator function
as an argument. This function compares two items in the array and
should return a positive or negative number depending on which item
should come first in the sorted array.
For example, take a simple array of numbers:
var arr = [52, 97, 3, 62, 10, 63, 64, 1, 9, 3, 4];
We can sort this array by calling arr.sort(). After this, the items are in the order:
[1, 10, 3, 3, 4, 52, 62, 63, 64, 9, 97]
By default, as we see here, the items are sorted lexicographically (in alphabetical order). In this case it might make more sense to sort the items numerically. To do this, we can supply a comparator function to the .sort() method:
arr.sort(function(a,b) {
if (a < b)
return -1;
if (a > b)
return 1;
return 0;
});
This function returns a negative number if a should come first in the sorted array, a positive number if b should come first, and zero if the order of the items does not matter. With this information in hand, the .sort() method can sequence the items appropriately:
[1, 3, 3, 4, 9, 10, 52, 62, 63, 64, 97]
Using a comparator to sort table rows
Our initial sort routine looks like this:
$(document).ready(function() {
$('table.sortable').each(function() {
var $table = $(this);
$('th', $table).each(function(column) {
var $header = $(this);
if ($header.is('.sort-alpha')) {
$header.addClass('clickable').hover(function() {
$header.addClass('hover');
}, function() {
$header.removeClass('hover');
}).click(function() {
var rows = $table.find('tbody > tr').get();
rows.sort(function(a, b) {
var keyA = $(a).children('td').eq(column).text()
.toUpperCase();
var keyB = $(b).children('td').eq(column).text()
.toUpperCase();
if (keyA < keyB) return -1;
if (keyA > keyB) return 1;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
});
});
}
});
});
});
The first thing to note is our use of the .each() method to make the iteration explicit. Even though we could bind a click handler to all headers that have the sort-alpha $('table.sortable th.sort-alpha').click(), this wouldn't allow us to easily capture a crucial bit of information: the column index of the clicked header. Because .each() passes the iteration index into its callback function, we can use it to find the relevant cell in each row of the data later. class just by calling
Once we have found the header cell, we retrieve an array of all of the data rows. This is a great example of how .get()
is useful in transforming a jQuery object into an array of DOM nodes;
even though jQuery objects act like arrays in many respects, they don't
have any of the native array methods available, such as .sort().
Now that we have an array of
DOM nodes, we can sort them, but to do this we need to write an
appropriate comparator function. We want to sort the rows according to
the textual contents of the relevant table cells, so this will be the
information the comparator function will examine. We know which cell to
look at because we captured the column index in the enclosing .each() call. We convert the text to uppercase because string comparisons in JavaScript are case-sensitive and we wish our sort to be case-insensitive.
We store the key values in variables to avoid redundant calculations,
compare them, and return a positive or negative number as discussed
above.
Finally, with the array sorted, we loop through the rows and reinsert them into the table. Since .append() does not clone nodes, this moves them rather than copying them. Our table is now sorted.
This is an example of progressive enhancement's counterpart, graceful degradation.
Unlike the AJAX solution discussed earlier, this technique cannot
function without JavaScript; we are assuming the server has no scripting
language available to it for this example. Since JavaScript is required
for the sort to work, we are adding the clickable
class through code only, thereby making sure that the interface
indicates that sorting is possible (with a background image) only if the
script can run. The page degrades into one that is still functional, albeit without sorting available.
We have moved the actual rows around, hence our alternating row colors are now out of whack:
We need to reapply the row
colors after the sort is performed. We can do this by pulling the
coloring code out into a function that we call when needed:
$(document).ready(function() {
var alternateRowColors = function($table) {
$('tbody tr:odd', $table)
.removeClass('even').addClass('odd');
$('tbody tr:even', $table)
.removeClass('odd').addClass('even');
};
$('table.sortable').each(function() {
var $table = $(this);
alternateRowColors($table);
$('th', $table).each(function(column) {
var $header = $(this);
if ($header.is('.sort-alpha')) {
$header.addClass('clickable').hover(function() {
$header.addClass('hover');
}, function() {
$header.removeClass('hover');
}).click(function() {
var rows = $table.find('tbody > tr').get();
rows.sort(function(a, b) {
var keyA = $(a).children('td').eq(column).text()
.toUpperCase();
var keyB = $(b).children('td').eq(column).text()
.toUpperCase();
if (keyA < keyB) return -1;
basic alphabetical sorting, JavaScript sortingcomparator, usingif (keyA > keyB) return 1;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
});
alternateRowColors($table);
});
}
});
});
});
This corrects the row coloring after the fact, fixing our issue:
The power of plugins
The alternateRowColors() function that we wrote is a perfect candidate to become a jQuery plugin.
In fact, any operation that we wish to apply to a set of DOM elements
can easily be expressed as a plugin. To accomplish this, we need to
modify our existing function only a little bit:
jQuery.fn.alternateRowColors = function() {
JavaScript sorting, sortingplug-ins$('tbody tr:odd', this)
.removeClass('even').addClass('odd');
$('tbody tr:even', this)
.removeClass('odd').addClass('even');
return this;
};
We have made three important changes to the function.
It is defined as a new property of jQuery.fn rather than as a standalone function. This registers the function as a plugin method.
We use the keyword this as a replacement for our $table parameter. Within a plugin method, this refers to the jQuery object that is being acted upon.
Finally, we return this at the end of the function. Supplying the jQuery object as the return value makes our new method chainable.
With our new plugin defined, we can call $table.alternateRowColors(), a more natural jQuery statement, instead of alternateRowColors($table).
Performance concerns
Our code works, but it is
quite slow. The culprit is the comparator function, which is performing a
fair amount of work. This comparator will be called many times during
the course of a sort, which means that every extra moment it spends on
processing will be magnified.
The actual sort algorithm used by JavaScript is not defined by the standard. It may be a simple sort like a bubble sort (worst case of Θ(n2) in computational complexity terms) or a more sophisticated approach like quick sort (which is Θ(n log n)
on average). It is safe to say, though, that doubling the number of
items in an array will more than double the number of times the
comparator function is called.
The remedy for our slow comparator is to pre-compute the keys for the comparison. We begin with our current, slow sort function:
rows.sort(function(a, b) {
var keyA = $(a).children('td').eq(column).text()
.toUpperCase();
var keyB = $(b).children('td').eq(column).text()
.toUpperCase();
if (keyA < keyB) return -1;
if (keyA > keyB) return 1;
return 0;
});
We can pull out the key computation and do that in a separate loop:
$.each(rows, function(index, row) {
row.sortKey = $(row).children('td').eq(column)
.text().toUpperCase();
});
rows.sort(function(a, b) {
if (a.sortKey < b.sortKey) return -1;
if (a.sortKey > b.sortKey) return 1;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
row.sortKey = null;
});
In the new loop, we are doing all of the expensive work and storing the result in a new .sortKey property. This kind of property, attached to a DOM element but not a normal DOM attribute, is called an expando.
This is a convenient place to store the key, since we need one per
table row element. Now, we can examine this attribute within the
comparator function, and our sort is markedly faster.
We set the expando property to null
after we're done with it to clean up after ourselves. This is not
strictly necessary in this case, but is a good habit to establish
because expando properties left lying around can be the cause of memory leaks. For more information, see Appendix C.
Instead of using expando properties, jQuery provides an alternative data storage mechanism we could use. The .data() method sets or retrieves arbitrary information associated with page elements, and the .removeData() method gets rid of any such stored information:
$.each(rows, function(index, row) {
$(row).data('sortKey', $(row).children('td')
.eq(column).text().toUpperCase());
});
rows.sort(function(a, b) {
if ($(a).data('sortKey') < $(b).data('sortKey'))
return -1;
if ($(a).data('sortKey') > $(b).data('sortKey'))
return 1;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
$(row).removeData('sortKey');
});
Using .data() instead
of expando properties can, at times, be more convenient, since we are
often working with jQuery objects rather than directly with DOM nodes.
It also avoids potential problems with Internet Explorer memory leaks.
However, for the remainder of this example, we will stick with expando
properties in order to practice switching between operations on DOM
nodes and operations on jQuery objects.
Finessing the sort keys
Now, we want to apply the same kind of sorting behavior to the Author(s) column of our table. By adding the sort-alpha class to its table header cell, the Author(s)
column can be sorted with our existing code. Ideally authors should be
sorted by last name, not first. Since some books have multiple authors,
and some authors have middle names or initials listed, we need outside
guidance to determine what part of the text to use as our sort key. We
can supply this guidance by wrapping the relevant part of the cell in a
tag:
<tr>
<td><img src="../images/covers/small/1847192386.png"
width="49" height="61" alt="Building Websites with
Joomla! 1.5 Beta 1" />
</td>
<td>Building Websites with Joomla! 1.5 Beta 1</td>
<td>Hagen <span class="sort-key">Graf</span></td>
<td>Feb 2007</td>
<td>$40.49</td>
</tr>
<tr>
<td><img src="../images/covers/small/1904811620.png"
width="49" height="61" alt="Learning Mambo: A
Step-by-Step Tutorial to Building Your Website" />
</td>
<td>Learning Mambo: A Step-by-Step Tutorial to Building
Your Website
</td>
<td>Douglas <span class="sort-key">Paterson</span></td>
<td>Dec 2006</td>
<td>$40.49</td>
</tr>
Now, we have to modify our sorting code to take this tag into account without disturbing the existing behavior for the Title
column, which is already working well. By prepending the marked sort
key to the key we have previously calculated, we can sort first on the
last name if it is called out, but on the whole string as a fallback:
$.each(rows, function(index, row) {
var $cell = $(row).children('td').eq(column);
row.sortKey = $cell.find('.sort-key').text().toUpperCase()
+ ' ' + $cell.text().toUpperCase();
});
Sorting by the Author(s) column now uses the provided key, thereby sorting by last name:
If two last names are identical, the sort uses the entire string as a tiebreaker for positioning.
Sorting other types of data
Our user should be able to sort not just by the Title and Author(s) columns, but the Publish Date and Price
columns as well. Since we streamlined our comparator function, it can
handle all kinds of data, but first the computed keys will need to be
adjusted for other data types. For example, in the case of prices we
need to strip off the leading $ character and parse the rest so that we can compare them numerically:
var key = parseFloat($cell.text().replace(/^[^\d.]*/, ''));
row.sortKey = isNaN(key) ? 0 : key;
The regular expression used here removes any leading characters other than numbers and decimal points, passing the result on to parseFloat(). The result of parseFloat() then needs to be checked, because if no number can be extracted from the text, NaN (not a number) is returned. This can wreak havoc on .sort().
For the date cells, we can use the JavaScript Date object:
row.sortKey = Date.parse('1 ' + $cell.text());
The dates in this table contain a month and year only; Date.parse() requires a fully-specified date, so we prepend the string with 1. This provides a day to complement the month and year, and the combination is then converted into a timestamp, which can be sorted using our normal comparator.
We can apportion
these expressions across separate functions, and call the appropriate
one based on the class applied to the table header:
jQuery.fn.alternateRowColors = function() {
$('tbody tr:odd', this)
.removeClass('even').addClass('odd');
$('tbody tr:even', this)
.removeClass('odd').addClass('even');
return this;
};
$(document).ready(function() {
$('table.sortable').each(function() {
var $table = $(this);
$table.alternateRowColors();
$('th', $table).each(function(column) {
var $header = $(this);
var findSortKey;
if ($header.is('.sort-alpha')) {
findSortKey = function($cell) {
return $cell.find('.sort-key')
.text().toUpperCase()
+ ' ' + $cell.text().toUpperCase();
};
}
else if ($header.is('.sort-numeric')) {
findSortKey = function($cell) {
var key = $cell.text().replace(/^[^\d.]*/, '');
key = parseFloat(key);
return isNaN(key) ? 0 : key;
};
}
else if ($header.is('.sort-date')) {
findSortKey = function($cell) {
return Date.parse('1 ' + $cell.text());
};
}
if (findSortKey) {
$header.addClass('clickable').hover(function() {
$header.addClass('hover');
}, function() {
$header.removeClass('hover');
}).click(function() {
var rows = $table.find('tbody > tr').get();
$.each(rows, function(index, row) {
var $cell = $(row).children('td').eq(column);
row.sortKey = findSortKey($cell);
});
rows.sort(function(a, b) {
if (a.sortKey < b.sortKey) return -1;
if (a.sortKey > b.sortKey) return 1;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
row.sortKey = null;
});
$table.alternateRowColors();
});
}
});
});
});
The findSortKey
variable doubles as the function to calculate the key, and a flag to
indicate whether the column header is marked with a class making it
sortable. We can now sort on date or price:
Column highlighting
It can be a nice user
interface enhancement to visually remind the user of what has been done
in the past. By highlighting the column that was most recently used for
sorting, we can focus the user's attention on the part of the table that
is most likely to be relevant. Fortunately, since we've already
determined how to select the table cells in the column, applying a class
to those cells is simple:
$table.find('td').removeClass('sorted')
.filter(':nth-child(' + (column + 1) + ')')
.addClass('sorted');
This snippet first removes the sorted
class from all cells, then adds it to cells that are in the same column
we just used for our sort. Note that we have to add 1 to the column
index we found earlier, since the :nth-child() selector is one-based rather than zero-based. With this code in place, we get a highlighted column after any sort operation:
Alternating sort directions
Our final sorting enhancement is to allow for both ascending and descending sort orders. When the user clicks on a column that is already sorted, we want to reverse the current sort order.
To reverse a sort, all we have to do is to invert the values returned by our comparator. We can do this with a simple variable:
if (a.sortKey < b.sortKey) return -sortDirection;
if (a.sortKey > b.sortKey) return sortDirection;
If sortDirection equals 1, then the sort will be the same as before. If it equals -1, the sort will be reversed. We can use classes to keep track of the current sort order of a column:
jQuery.fn.alternateRowColors = function() {
$('tbody tr:odd', this)
.removeClass('even').addClass('odd');
$('tbody tr:even', this)
.removeClass('odd').addClass('even');
return this;
};
$(document).ready(function() {
$('table.sortable').each(function() {
var $table = $(this);
$table.alternateRowColors();
$('th', $table).each(function(column) {
var $header = $(this);
var findSortKey;
if ($header.is('.sort-alpha')) {
findSortKey = function($cell) {
return $cell.find('.sort-key').text().toUpperCase()
+ ' ' + $cell.text().toUpperCase();
};
}
else if ($header.is('.sort-numeric')) {
findSortKey = function($cell) {
var key = $cell.text().replace(/^[^\d.]*/, '');
key = parseFloat(key);
return isNaN(key) ? 0 : key;
};
}
else if ($header.is('.sort-date')) {
findSortKey = function($cell) {
return Date.parse('1 ' + $cell.text());
};
}
if (findSortKey) {
$header.addClass('clickable').hover(function() {
$header.addClass('hover');
}, function() {
$header.removeClass('hover');
}).click(function() {
var sortDirection = 1;
if ($header.is('.sorted-asc')) {
sortDirection = -1;
}
var rows = $table.find('tbody > tr').get();
$.each(rows, function(index, row) {
var $cell = $(row).children('td').eq(column);
row.sortKey = findSortKey($cell);
});
rows.sort(function(a, b) {
JavaScript sorting, sortingsort, reversingif (a.sortKey < b.sortKey) return -sortDirection;
if (a.sortKey > b.sortKey) return sortDirection;
return 0;
});
$.each(rows, function(index, row) {
$table.children('tbody').append(row);
row.sortKey = null;
});
$table.find('th').removeClass('sorted-asc')
.removeClass('sorted-desc');
if (sortDirection == 1) {
$header.addClass('sorted-asc');
}
else {
$header.addClass('sorted-desc');
}
$table.find('td').removeClass('sorted')
.filter(':nth-child(' + (column + 1) + ')')
.addClass('sorted');
$table.alternateRowColors();
});
}
});
});
});
As a side benefit, since we use
classes to store the sort direction we can style the column headers to
indicate the current order as well: