The Joy of a Minimal, Complete Javascript Table Sort

Wow! This takes me back! Please check the date this post was authored, as it may no longer be relevant in a modern context.

When you’re done poking through this, take a peek at how you can double the speed of this script in The Joy of an Optimized, Complete Javascript Table Sort, and then check out the final version of it at The Joy of Cows On Tables

Ah table sorting. There are few problems that have been solved as many times as you have been. Unfortunately, some of the nicest solutions, such as mootable, are also pretty overbearing. Check out this feature list:

  1. Total re-styling of your table.
  2. Editable table cells.
  3. Loading table contents from JSON.
  4. Loading table contents from JSON over XHR.
  5. Server-side sorting using the above.
  6. Client-side sorting.
  7. Re-ordering of columns, column options.
  8. Nice fade effects.
  9. Event hooks.

Whoa, too much. Over at ICA we needed something way more lightweight. I was pretty much looking for this:

  1. Client-side sort of various formats (like mm/dd/yy).
  2. Zebra or striped tables.
  3. Support sorting with hidden rows on the table.
  4. Be fairly fast.
  5. Use mootools (which we already use).
  6. Use a table already on the DOM.

Let’s take a look at how to make a javascript table sort that follows best practices, is relatively minimal, and fast. Nothing here is completely new stuff, but hopefully walking through it will help you write a better table sort next time you need just a table sort, and not all the overhead of a library. I’ll be using mootools sort of aggressively, but the core ideas and practices here are portable to any environment.

Here we go!

HTML Assumptions and Javascript Style

Our code is going to make a few assumptions. Assumptions are always a tradeoff. They can be a detriment if you don’t know what the assumptions are, but are a great enhancement to your consistency and coding speed if you know what they are. We’re going to assume the table we want to sort’s HTML looks something like this:

<table cellpadding="0" cellspacing="0" id="sort_this">
  <thead>
    <tr>
      <th>a header</th>
      ...
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>a value
      ...
    </tr>
    <tr>
      <td>another value</td>
      ...
    </tr>
  </tbody>
</table>

Take note of a few things:

  1. We gave the table an id, “sort_this”
  2. We used thead and tbody sections
  3. We used “th” tags for the headers

All of that is really just good HTML, the semantic use of th and td, for example, is just good HTML table markup.

Javascript can be kludged onto a page in a thousand different ways, we’re going to stick with three tenets:

  1. Be unobtrusive (keep javascript out of our HTML).
  2. Use objects and instances to keep our code reusable (and able to work with multiple tables).
  3. Use options as a hash for readability.

Let’s look at a basic mootools javascript object:

var SortingTable;
SortingTable = new Class({

  initialize: function( table, options ){
    ...
  },

  sort_by_header: function( text ){
    ...
  }

});

SortingTable.stripe_table = function( tr_elements ){

}

You can see how we added instance and class functions. “initialize” is an instance function run when we instantiate our object, “sortbyheaders” is an instance function we call on an object.

“stripe_table” is added in a different manner, that’s because “stripe_table” is a class function, we want to be able to use it without instantiating “SortingTable” at all. When we use SortingTable, it should look like this:

new SortingTable( 'sort_this' );

Or maybe if we have options:

new SortingTable( 'sort_this', { zebra: false } );

We could create an instance and call sortbymethods:

var sorting_table = new SortingTable( 'sort_this', { zebra: false } );
sorting_table.sort_by_header( 'name' );

Or call a class method without an instance:

SortingTable.stripe_table( $$( '#sort_this tbody tr' ) );

Alright, we have a basic set of common practices we can base our code off. Let’s dive into a very basic table sort script.

Basic Javascript Table Sorting

Now this is only a starting point, the table sort below is so basic it isn’t very user friendly. It is a good place to start understanding how we sort things in general, and deal with the tables on the DOM.

var SortingTable;
SortingTable = new Class({

  initialize: function( table, options ) {
    this.table = $(table);
    this.tbody = $(this.table.getElementsByTagName('tbody')[0]);

    this.headers = new Hash;
    var thead = $(this.table.getElementsByTagName('thead')[0]);
    $each(thead.getElementsByTagName('tr')[0].getElementsByTagName('th'), function( header, index ) {
      var header = $(header);
      this.headers.set( header.getText(), { column: index } );
      header.addEvent( 'mousedown', function(evt){
        var evt = new Event(evt);
        this.sort_by_header( evt.target.getText() );
      }.bind( this ));
    }.bind( this ) );
  },

  sort_by_header: function( header_text ){
    this.rows = new Array;
    var trs = this.tbody.getElements( 'tr' );
    while ( trs.length > 0 ) {
      var row = { row: trs.shift().remove() };
      this.rows.unshift( row );
    }

    var header = this.headers.get( header_text );
    if ( this.sort_column >= 0 && this.sort_column == header.column ) {
      // They were pulled off in reverse
    } else {
      this.sort_column = header.column;
      this.rows.sort( this.compare_rows.bind( this ) );
    }

    while (this.rows.length > 0) {
      var row = this.rows.shift();
      row.row.injectInside( this.tbody );
    }
    this.rows = false;
  },

  compare_rows: function( r1, r2 ) {
    r1.compare_value = $(r1.row.getElementsByTagName('td')[this.sort_column]).getText();
    r2.compare_value = $(r2.row.getElementsByTagName('td')[this.sort_column]).getText();
    if ( r1.compare_value > r2.compare_value ) { return  1 }
    if ( r1.compare_value < r2.compare_value ) { return -1 }
    return 0;
  }

});

Oh the bitter pill of javascript. Let’s boil it down, there really isn’t too much going on here. In the initialize (which remember, is run as soon as we instantiate):

  1. We find some nodes on the DOM so we don’t need to find them again later: this.table, this.tbody and this.thead.
  2. In $each, we walk all of the “th” tags in thead and do two things:
    1. add the innerText as a key in this.headers, with a value that includes the index, or which column we’re dealing with. This is a map for later.
    2. add a “mousedown” event handler to the “th” tag, firing sortbyheader with the th’s innerText

Stay with me. Look at the initialize again:

  initialize: function( table, options ) {
    this.table = $(table);
    this.tbody = $(this.table.getElementsByTagName('tbody')[0]);

    this.headers = new Hash;
    var thead = $(this.table.getElementsByTagName('thead')[0]);
    $each(thead.getElementsByTagName('tr')[0].getElementsByTagName('th'), function( header, index ) {
      var header = $(header);
      this.headers.set( header.getText(), { column: index } );
      header.addEvent( 'mousedown', function(evt){
        var evt = new Event( evt );
        this.sort_by_header( evt.target.getText() );
      }.bind( this ));
    }.bind( this ) );
  },

See the lines that close out the each and addEvent functions? They’re using .bind( this ) to attach the internal “this” of the function back to our instantiated object. It’s a nice trick that let’s us have a “mousedown” event that is attached to our object.

Deep breath here, let’s step through the sortbyheader section.

  sort_by_header: function( header_text ){
    this.rows = new Array;
    var trs = this.tbody.getElements( 'tr' );
    while ( trs.length > 0 ) {
      var row = { row: trs.shift().remove() };
      this.rows.unshift( row );
    }

So this creates an internal array of rows, then proceeds to walk all the tr’s on tbody. For each one, it is doing two things: Shifting one tr off the array of trs, and using .remove() to drop it off the DOM. Each row is stuffed in an object and added to the top of the this.rows array.

It’s important that it’s added to the beginning of the rows array, because that means if all we needed to do was reverse the rows, we can just replay this array one by one and attach it’s rows to the DOM again. Reverse without calling .reverse(), nice.

    var header = this.headers.get( header_text );
    if ( this.sort_column >= 0 && this.sort_column == header.column ) {
      // They were pulled off in reverse
    } else {
      this.sort_column = header.column;
      this.rows.sort( this.compare_rows.bind( this ) );
    }

Here we pull the header object out of our headers hash using the passed text (which is the innerText from the attached event in initialize), and then compare it to our last sort_column. It they are the same, we can move on and just re-insert the rows. If they’re different, we need to call this.rows.sort().

.sort() is a javascript method for sorting an array, it’s native to the language. By default, .sort() will sort rows in alphabetical order. To sort in any other way, you can pass it a function, and it’ll use answers of 1, -1 and 0 to figure out the row order. In this example, we’re telling sort to use this.compare_rows, and also reminding it that compare_rows should be run on our current object.

Learn more about sort at w3schools.

    while (this.rows.length > 0) {
      var row = this.rows.shift();
      row.row.injectInside( this.tbody );
    }
    this.rows = false;
  },

This section is the meat- we take out sorted or reversed array, shift it’s contents off the top one by one, and add then to this.tbody. Shifting them off gets them out of memory as we move along so we don’t leave arrays sitting in RAM.

That’s the brunt of table sorting in Javascript right there. sortbyheader ripped rows off the table, reversed or sorted them, and then reinserted them onto the DOM.

Our actual table sort logic is the following:

  compare_rows: function( r1, r2 ) {
    r1.compare_value = $(r1.row.getElementsByTagName('td')[this.sort_column]).getText();
    r2.compare_value = $(r2.row.getElementsByTagName('td')[this.sort_column]).getText();
    if ( r1.compare_value > r2.compare_value ) { return  1 }
    if ( r1.compare_value < r2.compare_value ) { return -1 }
    return 0;
  }

});

compare_rows get’s two arguments from sort, two rows objects to compare. The text of the td cells is fetched and compared. Pretty straight ahead here, the only trickery is finding out what column to use by reaching into this.sort_column. I like having the whole row in there to compare, it opens to door to having secondary sorting by another column (like iTunes’ “Album By Artist” sorting).

Huzzah! we can nearly rejoice. I’d go back and look over the code we just walked through, if you can understand what’s going on up there, this next block of hackery should make perfect sense.

Why Our Simple Sort Sucks

There are some problems with the simple script above:

  1. It pulls the a given td cell off the DOM and does conversion on it multiple times, that makes it slow.
  2. It isn’t flexible enough to sort mm/dd/yy.

Those are two pretty damning faults, so let’s clean them up before adding any new features.

var SortingTable;
SortingTable = new Class({

  initialize: function( table, options ) {
    this.table = $(table);
    this.tbody = $(this.table.getElementsByTagName('tbody')[0]);

    this.headers = new Hash;
    var thead = $(this.table.getElementsByTagName('thead')[0]);
    $each(thead.getElementsByTagName('tr')[0].getElementsByTagName('th'), function( header, index ) {
      var header = $(header);
      this.headers.set( header.getText(), { column: index } );
      $(header).addEvent( 'mousedown', function(evt){
        var evt = new Event(evt);
        this.sort_by_header( new evt.target.getText() );
      }.bind( this ));
    }.bind( this ) );

    this.load_conversions();
  },

  sort_by_header: function( header_text ){
    this.rows = new Array;
    var trs = this.tbody.getElements( 'tr' );
    while ( trs.length > 0 ) {
      var row = { row: trs.shift().remove() };
      this.rows.unshift( row );
    }

    var header = this.headers.get( header_text );
    if ( this.sort_column >= 0 && this.sort_column == header.column ) {
      // They were pulled off in reverse
    } else {
      this.sort_column = header.column;
      if (header.conversion_function) {
        this.conversion_function = header.conversion_function;
      } else {
        this.conversion_function = false;
        this.rows.some(function(row){
          var to_match = $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
          if (to_match == ''){ return false }
          this.conversions.some(function(conversion){
            if (conversion.matcher.test( to_match )){
              this.conversion_function = conversion.conversion_function;
              return true;
            }
            return false;
          }.bind( this ));
          if (this.conversion_function){ return true; }
          return false;
        }.bind( this ));
        header.conversion_function = this.conversion_function.bind( this );
        this.headers.set( header_text, header );
      }
      this.rows.each(function(row){
        row.compare_value = this.conversion_function( row );
      }.bind( this ));
      this.rows.sort( this.compare_rows.bind( this ) );
    }

    while (this.rows.length > 0) {
      var row = this.rows.shift();
      row.row.injectInside( this.tbody );
    }
    this.rows = false;
  },

  compare_rows: function( r1, r2 ) {
    if ( r1.compare_value > r2.compare_value ) { return  1 }
    if ( r1.compare_value < r2.compare_value ) { return -1 }
    return 0;
  },

  load_conversions: function() {
    this.conversions = $A([
      // YYYY-MM-DD, YYYY-m-d
      { matcher: /\d{4}-\d{1,2}-\d{1,2}/,
        conversion_function: function( row ) {
          var cell = $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
          var re = /(\d{4})-(\d{1,2})-(\d{1,2})/;
          cell = re.exec( cell );
          return new Date(parseInt(cell[1]), parseInt(cell[2], 10) - 1, parseInt(cell[3], 10));
        }
      },
      // Fallback
      { matcher: /.*/,
        conversion_function: function( row ) {
          return $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
        }
      }
    ]);
  }

});

this.load_conversions(); is the big new thing in initialize. It’s that function at the end of the class that has and array of hashes each with a “matcher” and “conversion_function”.

Really, the main difference is in the sorting section of sortbyheader, in the meat and bones:

      this.sort_column = header.column;
      if (header.conversion_function) {
        this.conversion_function = header.conversion_function;
      } else {
        this.conversion_function = false;
        this.rows.some(function(row){
          var to_match = $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
          if (to_match == ''){ return false }
          this.conversions.some(function(conversion){
            if (conversion.matcher.test( to_match )){
              this.conversion_function = conversion.conversion_function;
              return true;
            }
            return false;
          }.bind( this ));
          if (this.conversion_function){ return true; }
          return false;
        }.bind( this ));
        header.conversion_function = this.conversion_function.bind( this );
        this.headers.set( header_text, header );
      }
      this.rows.each(function(row){
        row.compare_value = this.conversion_function( row );
      }.bind( this ));
      this.rows.sort( this.compare_rows.bind( this ) );

Ok, don’t get thrown. Javascript’s weird features mean loops are pretty messy. Notice the first time the rows are walked we use “.some(”. Some is a mootools array function that acts like each until the function returns true, then it breaks the loop. This is what’s going on here:

  1. See if we have a conversion_function. If we don’t…
    1. Walk through td’s in the column.
    2. If it’s innerText is blank, go to the next element.
    3. Walk through the available conversions.
    4. If a conversion matches the matcher, assign it to this.conversion_function and break the loop
    5. Save the conversion_function on the header object so we don’t need to find it later.
  2. Walk all our row objects and run the conversion_function on each, save it onto the row.
  3. Run sort with compare_rows.

compare_rows, you can see, now expects to sort with the compare_value:

  compare_rows: function( r1, r2 ) {
    if ( r1.compare_value > r2.compare_value ) { return  1 }
    if ( r1.compare_value < r2.compare_value ) { return -1 }
    return 0;
  },

By adding new conversions to load_conversions, you can support sorting of all kinds of different formats and sub-columns. And you’ll only be running that conversion once on a cell (until you sort another column and come back, this doesn’t do aggressive caching of the whole table in memory).

The Sweet Smell Of Success

All that’s needed now is a sprinkling of zebra or striped tables, and stuffing some extra baggage onto the row objects, and we’ll support those last two features:

  1. Zebra or striped tables.
  2. Hidden/expandable rows.

It looks something like this:

//
// new Star.Table( 'my_table', {
//   zebra: true,     // Stripe the table, also on initialize
//   details: false,  // Has details every other row
// });
//
// The above were the defaults.  The regexes in load_conversions test a cell
// begin sorted for a match, then use that conversion for all elements on that
// column.
//
// Requires mootools Class, Array, Function, Element, Element.Selectors,
// Element.Event, and you should probably get Window.DomReady if you're smart.
//

var SortingTable;
SortingTable = new Class({

  initialize: function( table, options ) {
    this.options = $merge({
      zebra: true,
      details: false
    }, options);

    this.table = $(table);

    this.tbody = $(this.table.getElementsByTagName('tbody')[0]);
    if (this.options.zebra) {
      SortingTable.stripe_table( this.tbody.getElements( 'tr' ) );
    }

    this.headers = new Hash;
    var thead = $(this.table.getElementsByTagName('thead')[0]);
    $each(thead.getElementsByTagName('tr')[0].getElementsByTagName('th'), function( header, index ) {
      var header = $(header);
      this.headers.set( header.getText(), { column: index } );
      header.addEvent( 'mousedown', function(evt){
        var evt = new Event(evt);
        this.sort_by_header( evt.target.getText() );
      }.bind( this ));
    }.bind( this ) );

    this.load_conversions();
  },

  sort_by_header: function( header_text ){
    this.rows = new Array;
    var trs = this.tbody.getElements( 'tr' );
    while ( trs.length > 0 ) {
      var row = { row: trs.shift().remove() };
      if ( this.options.details ) {
        row.detail = trs.shift().remove();
      }
      this.rows.unshift( row );
    }

    var header = this.headers.get( header_text );
    if ( this.sort_column >= 0 && this.sort_column == header.column ) {
      // They were pulled off in reverse
    } else {
      this.sort_column = header.column;
      if (header.conversion_function) {
        this.conversion_function = header.conversion_function;
      } else {
        this.conversion_function = false;
        this.rows.some(function(row){
          var to_match = $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
          if (to_match == ''){ return false }
          this.conversions.some(function(conversion){
            if (conversion.matcher.test( to_match )){
              this.conversion_function = conversion.conversion_function;
              return true;
            }
            return false;
          }.bind( this ));
          if (this.conversion_function){ return true; }
          return false;
        }.bind( this ));
        header.conversion_function = this.conversion_function.bind( this );
        this.headers.set( header_text, header );
      }
      this.rows.each(function(row){
        row.compare_value = this.conversion_function( row );
      }.bind( this ));
      this.rows.sort( this.compare_rows.bind( this ) );
    }

    var index = 0;
    while (this.rows.length > 0) {
      var row = this.rows.shift();
      row.row.injectInside( this.tbody );
      if (row.detail){ row.detail.injectInside( this.tbody ) };
      if ( this.options.zebra ) {
        row.row.removeClass( 'alt' );
        if (row.detail){ row.detail.removeClass( 'alt' ); }
        if ( ( index % 2 ) == 0 ) {
          row.row.addClass( 'alt' );
          if (row.detail){ row.detail.addClass( 'alt' ); }
        }
      }
      index++;
    }
    this.rows = false;
  },

  compare_rows: function( r1, r2 ) {
    if ( r1.compare_value > r2.compare_value ) { return  1 }
    if ( r1.compare_value < r2.compare_value ) { return -1 }
    return 0;
  },

  load_conversions: function() {
    this.conversions = $A([
      // YYYY-MM-DD, YYYY-m-d
      { matcher: /\d{4}-\d{1,2}-\d{1,2}/,
        conversion_function: function( row ) {
          var cell = $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
          var re = /(\d{4})-(\d{1,2})-(\d{1,2})/;
          cell = re.exec( cell );
          return new Date(parseInt(cell[1]), parseInt(cell[2], 10) - 1, parseInt(cell[3], 10));
        }
      },
      // Fallback
      { matcher: /.*/,
        conversion_function: function( row ) {
          return $(row.row.getElementsByTagName('td')[this.sort_column]).getText();
        }
      }
    ]);
  }

});

SortingTable.stripe_table = function ( tr_elements  ) {
  var counter = 0;
  $$( tr_elements ).each( function( tr ) {
    if ( tr.style.display != 'none' && !tr.hasClass('collapsed') ) {
      counter++;
    }
    tr.removeClass( 'alt' );
    if ( !(( counter % 2 ) == 0) ) {
      tr.addClass( 'alt' );
    }
  }.bind( this ));
}

Nice.

That was a lot of ground to cover, as in, way more than I had any intention of covering :-). I’ve made a lot of assumptions (remember those?) about your javascript fu in this post, but if you have any questions just ask!

You can pull this code down as javascript or take a look at some running examples, including how to use hidden rows.

Also look at optimization steps and an updated script at The Joy of an Optimized, Complete Javascript Table Sort, and then check out the final script in The Joy of Cows On Tables