Update
CPian "Kornfeld Eliyahu Peter" has kindly provided an ASP.NET implementation, the source code for which I've added to the repository as well as updated the article to the slight differences between the Ruby ERB format and the ASPX format. Regarding the ASP.NET implementation, here are the comments / suggestions from Peter:
When looking into your project I immediately though about ASP.NET MVC, Ruby on Rails is almost identical to it. For that I decided to make the port to ASP.NET Web Forms, to make it more interesting. However you may be surprised how little I had to change. In fact I never touched JavaScript and HTML (that make sense I believe, but interesting nevertheless).
In fact I did changed HTML in two cases, that not really related.
- You never closed <input> elements and my environment is a bit crazy about such things – so I did closed them to get rid of the messages!
- In the on-off switch you used <div> inside <label>. Again that’s something HTML do not like and my IDE don’t like either. So I changed them to span and added display: block; to the relevant CSS.
As you can see the differences are not more than the differences between the two framework/language - your logic is eternal! To be honest I was surprised how little changed while porting. Anyway – I do not know what you final purpose with this ‘control’, but I still see the problem of large number of contacts in your list. I tried with 700 contacts, and the page became a bit slow… I also want to tell, that this was a port of your code, no more.
Source Code
The source code is on GitHub: https://github.com/cliftonm/ContactListDemo, including the ASP.NET version.
Introduction
I decided to take on the task of putting together a decent looking contact list that can eventually become a component in a website I'm putting together. I had these basic requirements in mind:
- a table of contacts
- sortable by first name and last name
- a slider button that lets you switch first / last name columns (affecting the sorting)
- searchable with an A-Z index (like a physical address book with little "A", "B", "C..." etc. tabs)
- and with columns for email address, home / work / cell phone numbers that can be selected by a button-set
What follows is my adventure in pulling together various technologies and finagling with them to get the behavior and appearance that I wanted. Among various challenges were:
- Optimizing the use of screen space -- radio buttons and checkboxes, besides being somewhat archaic, also suck up screen space, as well as the default behaviors of JQuery-UI elements.
- Consistent fonts -- it's amazing how many different fonts, sizes, and weightings these controls have, resulting in a very messy look.
- jQuery / Javascript to do things like filtering table data
- API's -- every component has a different API, and every API has a different "mental model" of how to use the component.
Following Along
I decided to provide separate pages for each of the steps that I'm going to walk through here. It's important to do this because you can see the differences as I add behaviors and styling to the components. I'll point out the HTML file for the screenshot so that you can review the full HTML for each step of the process.
Getting Started
While the back-end is in Ruby on Rails, you won't see much Ruby code here, because this article is almost completely about the client-side.
Setting Up A Webpage Scratchpad
Ruby
After creating an initial Rails project in the RubyMine IDE, I created a database schema with one table:
create_table "contacts", :force => true do |t|
t.string "first_name"
t.string "last_name"
t.string "email"
t.string "home_phone"
t.string "work_phone"
t.string "cell_phone"
t.datetime "created_at", :null => false
t.datetime "updated_at", :null => false
end
I seeded it with a some random data (apologies to any readers of the fairer sex, all my first names are male):
Contact.delete_all
# http:
first_names = ['James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Charles', 'Joseph', 'Thomas']
# http:
last_names = ['SMITH', 'JOHNSON', 'WILLIAMS','JONES', 'BROWN', 'DAVIS', 'MILLER', 'WILSON', 'MOORE', 'TAYLOR', 'ANDERSON', 'THOMAS', 'JACKSON', 'WHITE', 'HARRIS', 'MARTIN', 'THOMPSON', 'GARCIA', 'MARTINEZ', 'ROBINSON']
20.times do
fname = first_names[rand(first_names.length)].capitalize
lname = last_names[rand(last_names.length)].capitalize
home = rand.to_s[2..4]+'-'+rand.to_s[2..4]+'-'+rand.to_s[2..5]
work = rand.to_s[2..4]+'-'+rand.to_s[2..4]+'-'+rand.to_s[2..5]
cell = rand.to_s[2..4]+'-'+rand.to_s[2..4]+'-'+rand.to_s[2..5]
Contact.create(:first_name => fname, :last_name => lname, :email => "#{fname}.#{lname}@mail.com", :home_phone => home, :work_phone => work, :cell_phone => cell)
end
ASP.NET
using System;
using System.Data;
namespace ContactListDemo
{
public class Schema : DataTable
{
public Schema()
{
TableName = "contacts";
Columns.Add( "first_name", typeof( string ) );
Columns.Add( "last_name", typeof( string ) );
Columns.Add( "email", typeof( string ) );
Columns.Add( "created_at", typeof( DateTime ) ).AllowDBNull = false;
Columns.Add( "updated_at", typeof( DateTime ) ).AllowDBNull = false;
Columns.Add( "home_phone", typeof( string ) );
Columns.Add( "work_phone", typeof( string ) );
Columns.Add( "cell_phone", typeof( string ) );
}
}
public class Contacts : Schema
{
private string[ ] _FirstNames = { "James", "John", "Robert", "Michael", "William", "David", "Richard", "Charles", "Joseph", "Thomas" };
private string[ ] _LastNames = { "SMITH", "JOHNSON", "WILLIAMS", "JONES", "BROWN", "DAVIS", "MILLER", "WILSON", "MOORE", "TAYLOR", "ANDERSON", "THOMAS", "JACKSON", "WHITE", "HARRIS", "MARTIN", "THOMPSON", "GARCIA", "MARTINEZ", "ROBINSON" };
public Contacts ( )
{
Random oRnd = new Random( );
for ( int i = 0; i < 200; i++ )
{
string szFName = _FirstNames[ oRnd.Next( _FirstNames.Length ) ].ToUpper( );
string szLName = _LastNames[ oRnd.Next( _LastNames.Length ) ].ToUpper( );
string szEmail = string.Format( "{0}.{1}@mail.com", szFName, szLName );
string szHome = string.Format( "{0}-{1}-{2}", oRnd.Next( 10, 9999 ), oRnd.Next( 10, 9999 ), oRnd.Next( 10, 99999 ) );
string szWork = string.Format( "{0}-{1}-{2}", oRnd.Next( 10, 9999 ), oRnd.Next( 10, 9999 ), oRnd.Next( 10, 99999 ) );
string szCell = string.Format( "{0}-{1}-{2}", oRnd.Next( 10, 9999 ), oRnd.Next( 10, 9999 ), oRnd.Next( 10, 99999 ) );
Rows.Add( szFName, szLName, szEmail, DateTime.Today, DateTime.Today, szHome, szWork, szCell );
}
}
}
}
And this concludes our tour of the back-end.
Javascript Files
We do however need to configure a few components. Whatever server platform you are using, you will need:
- jQuery
- jQuery-UI
- jQuery-tablesorter
Working With jQuery-tablesorter
HTML: app\views\contacts\build1.html.erb
This was, in many ways, the easiest part.
To create a table with sortable columns, like this one:
...put together some basic HTML:
Ruby
<div>
<table id='contactsTable'>
<thead>
<tr>
<th>Last Name</th>
<th>First Name</th>
<th>Email</th>
<th>Home</th>
<th>Work</th>
<th>Cell</th>
</tr>
</thead>
<% @contacts.each do |contact|%>
<tr>
<td><%= contact.last_name%></td>
<td><%= contact.first_name%></td>
<td><%= contact.email%></td>
<td><%= contact.home_phone%></td>
<td><%= contact.work_phone%></td>
<td><%= contact.cell_phone%></td>
</tr>
<% end %>
</table>
</div>
ASP.NET
<body>
<form id="form1" runat="server">
<div>
<table id='contactsTable' class='tablesorter-default'>
<thead>
<tr>
<th>Last Name</th>
<th>First Name</th>
<th>Email</th>
<th>Home</th>
<th>Work</th>
<th>Cell</th>
</tr>
</thead>
<% foreach ( System.Data.DataRow oContact in Contacts.Rows ) { %>
<tr>
<td><%= oContact["last_name"]%></td>
<td><%= oContact["first_name"]%></td>
<td><%= oContact["email"]%></td>
<td><%= oContact["home_phone"]%></td>
<td><%= oContact["work_phone"]%></td>
<td><%= oContact["cell_phone"]%></td>
</tr>
<% } %>
</table>
</div>
</form>
</body>
and a one-liner Javascript call:
<script type="text/javascript">
$(document).ready(function()
{
$("#contactsTable").tablesorter();
});
</script>
Usability and Presentation Issues
While this looks rather nice on the first take, it has a few problems. The major issues are:
- Not all fields should be sortable. It makes little sense to sort on email address and the home, work, and cell phone numbers.
- Information overload. When I look up a contact, I usually want only one thing, not the whole shebang. If I'd included physical address and mailing address, these would add two more fields from the collection of four that we already have for each contact.
Medium issues:
- Sometimes I know the person's first name and I want to look them up that way, sometimes I want to look someone up by their last name. Rather than having to shift my eyes to the second column to find people by their first name, then shift left to see if the last name matches, then shift right again for the relevant information I want, I'd like to be able to swap the first and last name columns, so that when I visually search the list, I'm mostly taking a left-to-right path with my eyeballs. I'll be addressing this issue in a separate section.
The minor issues are:
- It takes up the whole screen.
- I'd like "zebra" striping to create a less monochromatic (and monotonous) list.
Polishing the Table
HTML: app\views\contacts\build2.html.erb
Removing the Sort Feature for Specific Columns
The first thing to do is remove the ability to sort the email and phone fields. jQuery-tablesorter is a very nice component, and getting it to not sort specific columns is very easy:
<script type="text/javascript">
$(document).ready(function() {
$("#contactsTable").tablesorter({
headers : {
0: { sorter: "text" },
1: { sorter: "text" },
2: { sorter: false },
3: { sorter: false },
4: { sorter: false },
5: { sorter: false },
6: { sorter: false }
}
})
});
</script>
Fixing the Width
Well that's easy, except we should always follow the rule that if you're going to fix the width of something, write an outer div for the desired width and and set the inner div width to 100%. This way, the inner component can be re-used without fussing with things like physical dimensions, which is application-specific implementation. You will also note that this comes in handy later! So:
<div style="width:500px">
<div style="width:100%">
... etc ...
Yes, this is actually bad practice, but at some point (not in this article) I'll actually use this component with a grid system like Foundation and remove the fixed width -- this is mainly for me to see what it looks like in a narrower presentation, as other stuff is intended to go onto the final page.
Zebra Striping
This is easily specified in the tablesorter (there are also many other nifty features you can read up on in the documentation):
$("#contactsTable").tablesorter({
widgets : [ 'zebra' ],
... etc ...
We now have the following:
Getting better!
Removing the Noise
HTML: app\views\contacts\build3.html.erb
Next, we need a way to select the specific information that we want rather than polluting our grid with a lot of things we don't need when we look up a contact. For that I'll use the jQuery-UI buttonset. This requires a modicum of HTML:
<div id="MyButtonList">
<input type="checkbox" id="toggle_email"><label for="toggle_email">email</label>
<input type="checkbox" id="toggle_home"><label for="toggle_home">home</label>
<input type="checkbox" id="toggle_work"><label for="toggle_work">work</label>
<input type="checkbox" id="toggle_cell"><label for="toggle_cell">cell</label>
</div>
and another one-liner in the document ready event:
$('#MyButtonList').buttonset();
Of course, it looks like bad because it's huge and we've just added a second font to the layout. But we'll deal with that later.
Now we need to write the implementation for clicking on the button set. We'll write a four unobtrusive event click handlers:
$('#MyButtonList').buttonset(); $("#toggle_email").click(function()
{
showOrHide('#toggle_email', 3)
});
$("#toggle_home").click(function()
{
showOrHide('#toggle_home', 4)
});
$("#toggle_work").click(function()
{
showOrHide('#toggle_work', 5)
});
$("#toggle_cell").click(function()
{
showOrHide('#toggle_cell', 6)
});
function showOrHide(button, colNum)
{
if ($(button).is(":checked"))
{
$('#contactsTable tr *:nth-child('+colNum+')').removeClass('hidden');
}
else
{
$('#contactsTable tr *:nth-child('+colNum+')').addClass('hidden');
}
}
Usability and Presentation Issues
At this point we have a mostly functional implementation (it's missing one more feature) but there are yet again usability and presentation issues:
Major issues:
- When we do a page refresh:, the grid doesn't reflect the selected data columns
- Mismatching fonts and much to large of a visualization
- It's completely unclear to me in the buttonset with a gray button means "selected" or a white button means "selected." In fact, when I implemented the underlying Javascript, I was surprised to notice that gray meant "unselected!"
- Lots of dead space between columns because we aren't selecting all of them anymore.
Setting Selected Columns on Refresh
This is handled by forcibly setting the column state in the document ready event:
showOrHide('#toggle_email', 3)
showOrHide('#toggle_home', 4)
showOrHide('#toggle_work', 5)
showOrHide('#toggle_cell', 6)
Font Mismatch and Sizing
Poking around the tablesorter CSS, I discovered that the font being used is:
font: 12px/18px Arial, Sans-serif;
...which I'll specify in the CSS for MyButtonList. The next issue is the sizing. After much fussing, I decided on a line-height style of 0.8. This was determined by matching it with the button slider height that I'll be implementing next.
Making it Clearer When a Button is "Checked"
To give the button more indication that it is selected, I also chose to bold the selected text. The final CSS for the button-set looks like this:
<style>
#MyButtonList .ui-button.ui-state-active .ui-button-text {
font: 12px/18px Arial, Sans-serif;
line-height: 0.8;
color: black;
background-color: white;
font-weight:bold;
}
#MyButtonList .ui-button .ui-button-text {
font: 12px/18px Arial, Sans-serif;
line-height: 0.8;
color: black;
background-color: #eeeeee;
}
</style>
And the look now is:
Removing the Dead Space
Now that we've removed columns we don't want to see, let's also remove the dead space but preserve the full width of the grid background. Also, as a usability issue, I find it helpful to put the name on the left and the contact data (email and phones) on the right. I find this to be a visually pleasing way of separating the contact's name from the contact's other information. We achieve this with column styling:
<th style="white-space:nowrap">Last Name</th>
<th style="white-space:nowrap">First Name</th>
<th style="white-space:nowrap; width:99%"></th>
<th style="white-space:nowrap;">Email</th>
<th style="white-space:nowrap;">Home</th>
<th style="white-space:nowrap;">Work</th>
<th style="white-space:nowrap;">Cell</th>
We do the same thing for the table body rows (not shown.)
Notice the third column is an empty column with a width of 99%. The name columns now adjust to fit the content and the remaining "data" columns are moved to the right, for example:
Select First / Last Name Column Ordering
HTML: app\views\contacts\build4.html.erb
A very nifty CSS only flip switch (or button slider, as I've been calling it) can be found here. The complete CSS is bulky and not necessary to show, but this is what I ended up with initially:
The implementation of the button slider is straight-forward. Again we watch for a click event and toggle the sequence of the first and second columns:
$("#myonoffswitch").click(function()
{
var tbl = $('#contactsTable');
moveColumn(tbl, 1, 0);
});
Also, in the document ready event, we want to set the state the column sequence when the page is refreshed:
if (!$("#myonoffswitch").is(":checked"))
{
var tbl = $('#contactsTable');
moveColumn(tbl, 1, 0);
}
The moveColumn
function does all the work:
function moveColumn(table, from, to) {
var rows = $('tr', table);
var cols;
rows.each(function() {
cols = $(this).children('th, td');
cols.eq(from).detach().insertBefore(cols.eq(to));
});
}
Usability and Presentation Issues
- Yet again, the font is different
- I don't like that the "off" mode has a different background. This isn't really an on/off switch as simply a "state" control.
- It's not aligned well with the button-set. Ideally, the button-set should be right-justified to the edge of the grid and the slider should be the same height and vertically aligned with the button-set
Font, Size, Color Issues
Fixing these issues was fairly straight forward. The font became the same as used by the table. In the code you'll notice I have comments for the three places that have to be touched when fussing with the width of the control. I also made the border narrower.
Alignment Issues
To left-justify the button slider and right-justify the button-set requires a little div footwork within the div that has a width of 500px:
For the button slider:
<div style="width:100%">
For the button-set:
<div id="MyButtonList" style="float:right">
We also need to clear the div for the table so that it is forced to a new row:
<div style="clear:left; width:100%">
We now have something that appears like this:
Adding An Index Filter
HTML: app\views\contacts\build5.html.erb
Lastly, I want to add an A to Z index down the left. When the user clicks on a letter, it filters the contact list with people's names beginning with that letter. If last name is the first column, then it filters last names. If first name is the first column, then it filters first names.
To implement this requires moving everything over by 20 pixels, so we start with the slider button div:
<div style="margin-left:20px; width:100%">
We also move the table over by 20 pixels and (by the way) adjust the top padding a little bit to give us some room from the controls above it):
<div style="margin-left:20px; width:100%; padding-top: 3px">
And then, before the table div, we add a div with some ruby code to generate the index, set ID's and call a Javascript function with the letter index the user clicks on:
Ruby
<div class="index-filter">
<table style="font: 12px/18px Arial, Sans-serif;">
<tr>
<td>
<a href='#' id='filter-none' onclick='showAll()'>*</a>
</td>
</tr>
<% ('A'..'Z').each do |s| %>
<tr>
<td>
<% filter_by = %Q|filterBy("#{s}")| %>
<a href='#' id='<%="filter-#{s}" %>' onclick='<%="#{filter_by}"%>'><%= "#{s}" %></a>
</td>
</tr>
<% end %>
</table>
</div>
ASP.NET
<div class="index-filter">
<table style="font: 12px/18px Arial, Sans-serif;">
<tr>
<td>
<a href='#' id='filter-none' onclick='showAll()'>*</a>
</td>
</tr>
<% foreach ( char s in Enumerable.Range( 'A', 'Z' - 'A' + 1 ).Select( x => x ) ) { %>
<tr>
<td>
<a href='#' id='filter-<%= s %>' onclick='filterBy("<%= s %>")'><%= s %></a>
</td>
</tr>
<% } %>
</table>
</div>
(Yes, the table style should be in CSS not embedded in the HTML.)
We need some CSS to get the list to appear in the right location, which is on the left aligned with the top of the table body (not the header):
.index-filter {
clear:left;
float:left;
width:2px;
margin-top: 25px;
margin-left: 2px;
}
Notice I precede the index list with an asterisk to deselect filtering. The showAll
and filterBy
method looks like this:
function showAll()
{
$("#filterable").find("tr").each(function(idx, row){
row.hidden=false;
});
}
function filterBy(letter)
{
var rows = $("#filterable").find("tr");
rows.each(function(idx, row)
{
if (row.children[0].innerHTML.indexOf(letter)==0)
{
row.hidden=false;
}
else
{
row.hidden=true;
}
});
}
We also need to wrap the rows in a tbody tag with the a "filterable" ID:
Ruby
<tbody id='filterable'>
<% @contacts.each do |contact|%>
<tr>
<td style="white-space:nowrap"><%= contact.last_name%></td>
<td style="white-space:nowrap"><%= contact.first_name%></td>
<td style="white-space:nowrap; width:99%"></td>
<td style="white-space:nowrap;"><%= contact.email%></td>
<td style="white-space:nowrap;"><%= contact.home_phone%></td>
<td style="white-space:nowrap;"><%= contact.work_phone%></td>
<td style="white-space:nowrap;"><%= contact.cell_phone%></td>
</tr>
<% end %>
</tbody>
ASP.NET
<td style="white-space: nowrap"><%= oContact["last_name"]%></td>
<td style="white-space: nowrap"><%= oContact["first_name"]%></td>
<td style="white-space: nowrap; width: 99%"></td>
<td style="white-space: nowrap;"><%= oContact["email"]%></td>
<td style="white-space: nowrap;"><%= oContact["home_phone"]%></td>
<td style="white-space: nowrap;"><%= oContact["work_phone"]%></td>
<td style="white-space: nowrap;"><%= oContact["cell_phone"]%></td>
We now have something that works but looks like this:
An Important Lesson
Incidentally, I was first using the jQuery function like this:
$("#filterable").find("tr").hide();
... show only selected rows ...
But this was giving me a lot of grief because, as it turns out, jQuery is setting CSS with the hide()
function whereas I was trying to make the rows visible with the hidden
attribute! This is a very important lesson -- make sure that your usage of attributes and CSS is consistent for the behavior that you want!
Usability and Presentation Issues
- The indices are showing up as links and the vertical spacing is too wide.
Fixing the Look of the Indices
CSS to the rescue:
.index-filter tr td {
line-height:9px;
}
.index-filter tr td a {
text-decoration: none;
color: black;
}
.index-filter tr td a:hover {
color: white;
background-color: black;
font-weight:bold;
}
Now we've removed the underlining and have a clearer indicator of what index letter over which the mouse is hovering:
About Scrollbars, Pagination, and Color
I decided not to implement scrolling bars or pagination. I find pages that have internal scrollbars to be annoying -- I much rather prefer to scroll the entire browser window. Also, for something like a contact list, I don't think pagination is appropriate. It is incredibly fast to scroll through a list, even a long one, when it's sorted. Pagination just gets in the way. Lastly, I would have liked to color the button-set with green for selected and red for unselected, but again, that's technically bad UI design for people that are red-green color blind.
Conclusion
I hope you have enjoyed this tour and find the contact list presentation that I've created at least somewhat aesthetically pleasing! Special thanks to Peter for putting together the ASP.NET port!