Filtering
Earlier we examined sorting
and paging as techniques for helping users focus on relevant portions of
a table's data. We saw that both could be implemented either with
server-side technology, or with JavaScript.
Filtering completes this arsenal of data
arrangement strategies. By displaying to the user only the table rows
that match a given criterion, we can strip away needless distractions.
We have already seen how to
perform one type of filter: highlighting a set of rows. Now we will extend this idea to
actually hiding rows that don't match
the filter.
We can begin by creating a
place to put our filtering links. In a typical progressive enhancement strategy, we insert these controls using JavaScript so that
people without scripting available do not see the options:
$(document).ready(function() {
$('table.filterable').each(function() {
var $table = $(this);
$table.find('th').each(function(column) {
if ($(this).is('.filter-column')) {
var $filters = $('<div class="filters"></div>');
$('<h3></h3>')
.text('Filter by ' + $(this).text() + ':')
.appendTo($filters);
$filters.insertBefore($table);
}
});
});
});
We get the label for the filter
box from the column headers so that this code can be reused for other
tables quite easily. Now we have a heading awaiting some buttons:
Filter options
Now, we can move on to
actually implementing a filter. To start with, we will add filters for a
couple of known topics. The code for this is quite similar to the
author highlighting example from before:
$(document).ready(function() {
$('table.filterable').each(function() {
var $table = $(this);
$table.find('th').each(function(column) {
if ($(this).is('.filter-column')) {
var $filters = $('<div class="filters"></div>');
$('<h3></h3>')
.text('Filter by ' + $(this).text() + ':')
.appendTo($filters);
var keywords = ['conference', 'release'];
$.each(keywords, function(index, keyword) {
$('<div class="filter"></div>').text(keyword)
.bind('click', {key: keyword}, function(event) {
$('tr:not(:has(th))', $table).each(function() {
var value = $('td', this).eq(column).text();
if (value == event.data['key']) {
$(this).show();
}
else {
$(this).hide();
}
});
$(this).addClass('active')
.siblings().removeClass('active');
}).addClass('clickable').appendTo($filters);
});
$filters.insertBefore($table);
}
});
});
});
Starting with a static array
of keywords to filter by, we loop through and create a filtering link
for each. Just as in the paging example, we need to use the data .bind() to avoid accidental
problems due to the properties of closures.
Then, in the click handler, we
compare each cell's contents against the keyword and hide the row if
there is no match. Since our row selector excludes rows containing a<th>
element, we don't need to worry about subheadings being hidden.
parameter of
Both of the links now work as advertised:
Collecting filter
options from content
Now we need to expand the
filter options to cover the range of available topics in the table.
Rather than hard-coding all of the topics, we can gather them from the
text that has been entered in the table. We can change the definition of
keywords to read:
var keywords = {};
filtering, table appearancesfilter options, gathering from content$table.find('td:nth-child(' + (column + 1) + ')')
.each(function() {
keywords[$(this).text()] = $(this).text();
});
This code relies on two tricks:
1. By using a map rather than an array to hold the
keywords as they are found, we eliminate duplicates automatically; each
key can have only one value, and keys are always unique.
2. jQuery's
$.each() function lets us operate on
arrays and maps identically, so no subsequent code has to change.
Now we have a full complement of filter options:
Reversing the filters
For completeness, we need a way
to get back to the full list after we have filtered it. Adding an
option for all topics is pretty straightforward:
$('<div class="filter">all</div>').click(function() {
filtering, table appearancesfilters, reversing$table.find('tbody tr').show();
$(this).addClass('active')
.siblings().removeClass('active');
}).addClass('clickable active').appendTo($filters);
This gives us an all link that
simply shows all rows of the table. For good measure, this new link is
marked active to begin with.
Interacting with other
code
We learned with our sorting
and paging code that we can't treat the various features we write as
islands. The behaviors we build can interact in sometimes surprising
ways; for this reason, it is worth revisiting our earlier efforts to
examine how they coexist with the new filtering capabilities we have
added.
Row striping
The advanced row striping we
put in place earlier is confused by our new filters. Since the tables
are not re-striped after a filter is performed, rows retain their
coloring as if the filtered rows were still present.
To account for the
filtered rows, the row-striping code needs to be able to find them. The
jQuery pseudo-class :visible can assist
us in collecting the correct set of rows to stripe. While we're making
this change, we can prepare our row-striping code to be invoked from
other places by creating a custom event type for it, as we did when
making sorting and paging work together.
$(document).ready(function() {
$('table.striped').bind('stripe', function() {
$('tbody', this).each(function() {
$(this).find('tr:visible:not(:has(th))')
.removeClass('odd').addClass('even')
.filter(function(index) {
return (index % 6) < 3;
}).removeClass('even').addClass('odd');
});
}).trigger('stripe');
});
In our filtering code, we can now call $table.trigger('stripe') each time a filtering operation occurs. With both
the new event handler and its triggers in place, the filtering operation
respects row striping:
Expanding and
collapsing
The expanding and
collapsing behavior added earlier also conflicts with our filters. If a
section is collapsed and a new filter is chosen, then the matching items
are displayed, even if in the collapsed section. Conversely, if the
table is filtered and a section is expanded, then all items in the
expanded section are displayed regardless of whether they match the
filter.
One way to address the
latter situation is to change the way we show and hide rows. If we use a
class to indicate a row should be hidden, we don't need to explicitly
call .hide() and .show(). By replacing .hide()
with .addClass('filtered') and .show() with
.removeClass('filtered'), along with a CSS
rule for the class, we can accomplish the hiding and showing but play
more nicely with the collapsing code. If the class is removed and the
row is collapsed, the row will not be inadvertently displayed.
Introducing this new
filtered class also helps us with the
converse issue. We can test for the presence of filtered when performing a section expansion,
skipping these rows instead of showing them. Testing for this class is a
simple matter of adding :not(.filtered)
to the selector expression used during expansion.
Now our features play nicely,
each able to hide and show the rows independently.
The finished code
Our second example page
has demonstrated table row striping, highlighting, tooltips,
collapsing/expanding, and filtering. Taken together, the JavaScript code
for this page is:
$(document).ready(function() {
table appearances, modifyingJavaScript code$('table.striped').bind('stripe', function() {
$('tbody', this).each(function() {
$(this).find('tr:visible:not(:has(th))')
.removeClass('odd').addClass('even')
.filter(function(index) {
return (index % 6) < 3;
}).removeClass('even').addClass('odd');
});
}).trigger('stripe');
});
$(document).ready(function() {
var $authorCells = $('table.striped td:nth-child(3)');
var $tooltip = $('<div id="tooltip"></div>').appendTo('body');
var positionTooltip = function(event) {
var tPosX = event.pageX;
var tPosY = event.pageY + 20;
$tooltip.css({top: tPosY, left: tPosX});
};
var showTooltip = function(event) {
var authorName = $(this).text();
var action = 'Highlight';
if ($(this).parent().is('.highlight')) {
action = 'Unhighlight';
}
$tooltip
.text(action + ' all articles by ' + authorName)
.show();
positionTooltip(event);
};
var hideTooltip = function() {
$tooltip.hide();
};
$authorCells
.addClass('clickable')
.hover(showTooltip, hideTooltip)
.mousemove(positionTooltip)
.click(function(event) {
var authorName = $(this).text();
$authorCells.each(function(index) {
if (authorName == $(this).text()) {
$(this).parent().toggleClass('highlight');
}
else {
$(this).parent().removeClass('highlight');
}
});
showTooltip.call(this, event);
});
});
$(document).ready(function() {
table appearances, modifyingJavaScript codevar collapseIcon = '../images/bullet_toggle_minus.png';
var collapseText = 'Collapse this section';
var expandIcon = '../images/bullet_toggle_plus.png';
var expandText = 'Expand this section';
$('table.collapsible tbody').each(function() {
var $section = $(this);
$('<img />').attr('src', collapseIcon)
.attr('alt', collapseText)
.prependTo($section.find('th'))
.addClass('clickable')
.click(function() {
if ($section.is('.collapsed')) {
$section.removeClass('collapsed')
.find('tr:not(:has(th)):not(.filtered)')
.fadeIn('fast');
$(this).attr('src', collapseIcon)
.attr('alt', collapseText);
}
else {
$section.addClass('collapsed')
.find('tr:not(:has(th))')
.fadeOut('fast', function() {
$(this).css('display', 'none');
});
$(this).attr('src', expandIcon)
.attr('alt', expandText);
}
$section.parent().trigger('stripe');
});
});
});
$(document).ready(function() {
$('table.filterable').each(function() {
var $table = $(this);
$table.find('th').each(function(column) {
if ($(this).is('.filter-column')) {
var $filters = $('<div class="filters"></div>');
$('<h3></h3>')
.text('Filter by ' + $(this).text() + ':')
.appendTo($filters);
$('<div class="filter">all</div>').click(function() {
$table.find('tbody tr').removeClass('filtered');
$(this).addClass('active')
.siblings().removeClass('active');
$table.trigger('stripe');
}).addClass('clickable active').appendTo($filters);
var keywords = {};
$table.find('td:nth-child(' + (column + 1) + ')')
.each(function() {
keywords[$(this).text()] = $(this).text();
});
table appearances, modifyingJavaScript code$.each(keywords, function(index, keyword) {
$('<div class="filter"></div>').text(keyword)
.bind('click', {key: keyword}, function(event) {
$('tr:not(:has(th))', $table).each(function() {
var value = $('td', this).eq(column).text();
if (value == event.data['key']) {
$(this).removeClass('filtered');
}
else {
$(this).addClass('filtered');
}
});
$(this).addClass('active')
.siblings().removeClass('active');
$table.trigger('stripe');
}).addClass('clickable').appendTo($filters);
});
$filters.insertBefore($table);
}
});
});
});