Dave Rolsky - Widget.SortableTable-0.22

Documentation | Source

NAME

Widget.SortableTable - A widget to make a table sortable

SYNOPSIS

DESCRIPTION

Instruments a table so that each of its headers is a clickable link that resorts the table based on that column.

METHODS

  • new Widget.SortableTable( { tableId: "table-id", ... } )
  • Returns a new Widget.SortableTable object for the specified element.

    This method expects an anonymous object as its only parameter. The only required property for this object is "tableId".

    The columns of the table are numbered starting at 0, from left to right

    The object passed to the constructor can have several additional properties:

    • initialSortColumn
    • If "initialSortColumn" is specified, it tells the widget what column it should sort the table on when the widget is created.

      By default it does the initial sort of the table on column 0.

    • noInitialSort
    • If this is true, then no initial sort of table is done when the widget is constructed.

      The appropriate even/odd CSS class names will still be added to each row, but no cells will have their CSS class set to "w-st-current-sorted-column". See "HOW THE WIDGET ALTERS THE TABLE" for details about what this means.

    • secondarySortColumn
    • The "secondarySortColumn" parameter specifies which column the widget should use for secondary sorting. This is necessary when it tries to sort on a column where some cells contain identical values. In this case, it will fall back to sorting those particular rows on the data in the "secondarySortColumn". You should use a column which contains unique data in each cell. This defaults to column 0.

      Note that when we fall back to to the secondary column we always sort the secondary column in its default direction.

    • columnSpecs
    • The "columnSpecs" property is an array containing information about each column in the table.

      The spec for a single column is an anonymous object which can contain three properties, "skip", "sort", and "defaultDir". If "skip" is true, then this column will not be sortable. The "sort" property may be a custom sort function for that column, or a type name. See "TYPES AND SORTING" for more details on what type names are allowed. Finally, the "defaultDir" property may be either "asc" or "desc". This specifies which direction the column will be sorted on by default.

      If you want do not want to give a spec for a column you can use "null" to fill its place in the array.

      An example:

        var table = new Widget.SortableTable( {
          "tableId":           "my-table",
          "initialSortColumn": 1,
          "columnSpecs": [
            null,                   no spec for column 0
            { skip: true },         do not sort column 1
            { sort: "text" },       sort column 2 as text
            { defaultDir: "desc" }, sort column 3 in descending order by default
            { sort: mySortFunc,     sort column 4 with a custom function and ...
              defaultDir: "desc" }, ... sort in descending order by default
          ]
        } );
    • onSortRowCallback
    • If this option is given, this callback will be called when the rows are resorted. It will be passed the row and its new row number (starting at 1) in the table.

      This is primarily provided to allow for row numbering:

        function renumberRow ( row, idx ) {
            row.cells[0].innerHTML = "row number " + idx;
        }

    Since this class uses DOM.Ready to get the table, you can create a widget object whenever you want during the course of page rendering.

  • sortOnColumn(number)
  • Tells the widget to sort the table on the specified column. If the widget is not ready (the table is not in the DOM yet), then this method will simply return.

Tables with One Row

You can create a widget for a table with one row, but sorting will be a no-op. Creating the widget will still alter the table, but since no initial sort will be done, its CSS will not be altered.

TYPES AND SORTING

By default, Widget.SortTable tries to figure out the type of a column based on its text contents. It currently recognizes four types of data, numeric (integer or floating point), currency (a number preceded by "$" or the Euro symbol), dates in YYYY-MM-DD format, and text.

Text is sorted case-insensitively. Dates are sorted in descending order by default.

It detects the type by looking at the second cell in the column every time you sort, so in theory the type it detects could change from sort to sort. If this is blank for a column, it will assume that the type is text. If you have blank cells in a column and the data is not text, you should always specify its type when constructing the Widget.SortTable object.

Empty cells are sorted as being less than non-empty cells for all types.

HOW THE WIDGET ALTERS THE TABLE

When the widget object is first instantiated, it makes a number of changes to the DOM for the table. It assumes that the first row contains column headers. For each cell, it takes the contents of the cell, whatever they may be, and wraps them in a new <a> tag. The href for this tag is "#". The tag has an "onClick" event set which calls sortOnColumn() with the appropriate column number. This tag's class name is "w-st-resort-column". The "onClick" handler will stop the event from propogating further.

Note that the addition of this <a> tag may have break existing CSS rules you are using.

The widget will call sortOnColumn() as soon as it can in order to establish an initial sort order for the table, unless you set the "noInitialSort" constructor parameter to true. If no initial sorting is done, then there is no current sort-by column, but all the other CSS changes are still applied.

When the table is sorted, the widget will make a number of changes to the class names for table elements. The changes are as follows:

  • Current sort-by column
  • All cells in this column will have the "w-st-current-sorted-column" class added. In addition, the header cell, at the top of the column, will also have the class "w-st-asc-column-header" or "w-st-desc-column-header", depending on the current sort order.

  • Other column header cells
  • All other header cells will be given the "w-st-unsorted-column-header" class.

  • Sort-by column changes
  • When the column upon which the widget is sorting changes, it removes the "w-st-current-sorted-column" class from the previous sort-by column. It also removes the "w-st-asc-column-header" or "w-st-desc-column-header" class from that column's header cell.

  • All rows besides the header row
  • These rows (<tr> tags) will be given a class of either "w-st-odd-row" or "w-st-even-row". The first row below the headers will be even.

Any class names you originally assigned in your HTML will be left untouched by the widget.

Current Sort Indicators

This widget does not add any DOM elements to show which column is the current sort-by column. Instead, you can take advantage of the CSS class names it uses to get the same effect.

For example, let's start with this HTML for a column header cell:

  <th>Name
      <img class="none" src="none.png" />
      <img class="asc"  src="asc.png" />
      <img class="desc" src="desc.png" />
  </th>

Then in your CSS you can define the following style rules:

 img.asc, img.desc {
     display: none;
 }

 th.w-st-current-sorted-column img.none {
     display: none;
 }

 th.w-st-asc-column-header  img.asc  {
     display: inline;
 }

 th.w-st-desc-column-header img.desc  {
     display: inline;
 }

With these rules, the appropriate image will be displayed simply based on the class name changes that the widget makes.

AUTHOR

Dave Rolsky, <autarch@urth.org>.

COPYRIGHT

Copyright (c) 2006-2007 Dave Rolsky. All rights reserved.

This module is free software; you can redistribute it and/or modify it under the same terms as the Perl programming language (your choice of GPL or the Perl Artistic license).

JSAN.use("DOM.Ready");
JSAN.use("DOM.Events");

if ( typeof Widget == "undefined" ) { Widget = {}; }

Widget.SortableTable = function (params) {
    this._initialize(params);
};

Widget.SortableTable.VERSION = "0.22";

Widget.SortableTable.prototype._initialize = function (params) {
    if ( ! params ) {
        throw new Error("Cannot create a new Widget.SortableTable without parameters");
    }

    if ( ! params.tableId ) {
        throw new Error("Widget.SortableTable requires a tableId parameter");
    }

    this._initialSortColumn = params.initialSortColumn;
    if ( ! this._initialSortColumn ) {
        this._initialSortColumn = 0;
    }
    this._col_specs = [];
    if ( params.columnSpecs ) {
        for ( var i = 0; i < params.columnSpecs.length; i++ ) {
            if ( params.columnSpecs[i] ) {
                this._col_specs[i] = params.columnSpecs[i];
            }
        }
    }

    this._noInitialSort = params.noInitialSort;

    this._onSortRowCallback = params.onSortRowCallback;

    if ( ! params.secondarySortColumn ) {
        this._secondarySortColumn = 0;
    }
    else {
        this._secondarySortColumn = params.secondarySortColumn;
    }

    var self = this;
    DOM.Ready.onIdReady( params.tableId,
                         function (elt) { self._instrumentTable(elt); }
                       );
};

Widget.SortableTable._seenId = {};

Widget.SortableTable.prototype._instrumentTable = function (table) {
    this._table = table;

    var head = table.rows[0];

    if ( ! head ) {
        return;
    }

    for ( var i = 0; i < head.cells.length; i++ ) {
        if ( this._col_specs[i] && this._col_specs[i].skip ) {
            continue;
        }

        if ( ! Widget.SortableTable._seenId[ table.id ] ) {
            this._makeColumnSortable( head.cells[i], i );
        }

        this._removeCSSClass( head.cells[i], "w-st-desc-column-header" );
        this._removeCSSClass( head.cells[i], "w-st-asc-column-header" );
        this._addCSSClass( head.cells[i], "w-st-unsorted-column-header" );
    }

    if ( this._noInitialSort ) {
        this._setRowCSS();
    }
    else {
        this.sortOnColumn( this._initialSortColumn );
    }

    Widget.SortableTable._seenId[ table.id ] = true;
};

Widget.SortableTable.prototype._makeColumnSortable = function (cell, idx) {
    var href = document.createElement("a");
    href.setAttribute( "href", "#" );
    href.setAttribute( "onClick", "return false;" );
    href.className = "w-st-resort-column";

    this._moveChildTree( cell, href );
    cell.appendChild(href);

    DOM.Events.addListener(
        href,
        "click",
        this._makeSortLinkFunction(idx)
    );
};

Widget.SortableTable.prototype._makeSortLinkFunction = function (idx) {
    var self = this;

    var func = function (e) {
        self.sortOnColumn(idx);

        e.preventDefault();
        if ( e.stopPropogation ) {
            e.stopPropagation();
        }
    };

    return func;
};

Widget.SortableTable.prototype._moveChildTree = function (from, to) {
    if ( document.implementation.hasFeature( "Range", "2.0" ) ) {
        var range = document.createRange();
        range.selectNodeContents(from);

        to.appendChild( range.extractContents() );
    }
    else {
        /* XXX - this is gross but seems to work */
        to.innerHTML = from.innerHTML;
        from.innerHTML = "";
    }
};

Widget.SortableTable.prototype.sortOnColumn = function (idx) {
    if (! this._table ) {
        return;
    }

    if ( this._table.rows.length == 1 ) {
        return;
    }

    var cell_data = [];
    var rows = [];
    /* start at 1 to ignore the header row when sorting */
    for ( var i = 1; i < this._table.rows.length; i++ ) {
        var text = this._getAllText( this._table.rows[i].cells[idx] );
        var cell_info = { "primaryText": text, "rowNumber": i - 1 };
        if ( idx != this._secondarySortColumn ) {
            cell_info.secondaryText =
                this._getAllText( this._table.rows[i].cells[ this._secondarySortColumn ] );
        }

        cell_data.push(cell_info);
        rows.push( this._table.rows[i] );
    }

    var sort_info = this._sortFor( idx, cell_data[0].primaryText );
    if ( idx != this._secondarySortColumn ) {
        var sec_sort_info = this._sortFor( this._secondarySortColumn, cell_data[0].secondaryText );
        sort_info.secondaryFunc = sec_sort_info.func;

        var sec_dir = this._defaultDirForCol( this._secondarySortColumn, sec_sort_info.type );
        sort_info.reverseSecondary = sec_dir != sort_info.dir;
    }

    cell_data.sort( Widget.SortableTable._makeCellDataSorter
                        ( sort_info.func, sort_info.secondaryFunc, sort_info.reverseSecondary ) );

    if ( sort_info.dir == "desc" ) {
        cell_data.reverse();
    }

    this._resortTable( cell_data, rows );

    this._updateCSSClasses( idx, sort_info.dir );

    this._lastSort = { "index": idx,
                       "dir":   sort_info.dir };
};

/* More or less copied from
 * http://www.kryogenix.org/code/browser/sorttable/sorttable.js
 */
Widget.SortableTable.prototype._getAllText = function (elt) {
    if ( typeof elt == "string") {
        return elt;
    }
    if ( typeof elt == "undefined") {
        return "";
    }

    var text = "";
	
    var children = elt.childNodes;
    for ( var i = 0; i < children.length; i++ ) {
        switch ( children[i].nodeType) {
            case 1: /* ELEMENT_NODE */
                text += this._getAllText( children[i] );
                break;
            case 3:	/* TEXT_NODE */
                text += children[i].nodeValue;
                break;
        }
    }

    return text;
};

Widget.SortableTable.prototype._sortFor = function (idx, content) {
    var func;
    var type;

    if ( this._col_specs[idx] && this._col_specs[idx].sort ) {
        if ( typeof this._col_specs[idx].sort == "function" ) {
            func = this._col_specs[idx].sort;
        }
        else {
            var sort_name = this._col_specs[idx].sort;
            type = sort_name;
            func = Widget.SortableTable._sortFunctionsByType[sort_name];
        }
    }

    if ( ! func ) {
        if ( content.match( /^\s*[\$\u20AC]\s*\d+(?:\.\d+)?\s*$/ ) ) {
            type = "currency";
            func = Widget.SortableTable._sortFunctionsByType.currency;
        }
        else if ( content.match( /^\s*\d+(?:\.\d+)?\s*$/ ) ) {
            type = "number";
            func = Widget.SortableTable._sortFunctionsByType.number;
        }
        else if ( content.match( /^\s*\d\d\d\d[^\d]+\d\d[^\d]+\d\d\s*$/ ) ) {
            type = "date";
            func = Widget.SortableTable._sortFunctionsByType.date;
        }
        else {
            type = "text";
            func = Widget.SortableTable._sortFunctionsByType.text;
        }
    }

    var dir = this._defaultDirForCol( idx, type );

    if ( this._lastSort
         && this._lastSort.index == idx
         && this._lastSort.dir   == dir ) {
        dir = dir == "asc" ? "desc" : "asc";
    }

    return { "func": func,
             "dir":  dir,
             "type": type };
};

Widget.SortableTable.prototype._defaultDirForCol = function ( idx, type ) {
    if ( this._col_specs[idx] && this._col_specs[idx].defaultDir ) {
        return this._col_specs[idx].defaultDir;
    }
    else if (type)  {
        return Widget.SortableTable._defaultDirByType[type];
    }
    else {
        return "asc";
    }
};

Widget.SortableTable._sortCurrency = function (a, b) {
    var a_num = parseFloat( a.replace( /[^\d\.]/g, "" ) );
    var b_num = parseFloat( b.replace( /[^\d\.]/g, "" ) );

    return Widget.SortableTable._sortNumberOrNaN(a_num, b_num);
};

Widget.SortableTable._sortNumber = function (a, b) {
    var a_num = parseFloat(a);
    var b_num = parseFloat(b);

    return Widget.SortableTable._sortNumberOrNaN(a_num, b_num);
};

Widget.SortableTable._sortNumberOrNaN = function (a, b) {
    if ( isNaN(a) && isNaN(b) ) {
        return 0;
    }
    else if ( isNaN(a) ) {
        return -1;
    }
    else if ( isNaN(b) ) {
        return 1;
    }
    else if ( a < b ) {
        return -1;
    }
    else if ( a > b ) {
        return 1;
    }
    else {
        return 0;
    }
};

Widget.SortableTable._sortDate = function (a, b) {
    var a_match = a.match( /(\d\d\d\d)[^\d]+(\d\d)[^\d]+(\d\d)/ );
    var b_match = b.match( /(\d\d\d\d)[^\d]+(\d\d)[^\d]+(\d\d)/ );

    if ( ! a_match ) {
        a_match = [ "", -9999, 1, 1 ];
    }

    if ( ! b_match ) {
        b_match = [ "", -9999, 1, 1 ];
    }

    var a_date = new Date( a_match[1], a_match[2], a_match[3] );
    var b_date = new Date( b_match[1], b_match[2], b_match[3] );

    if ( a_date < b_date ) {
        return -1;
    }
    else if ( a_date > b_date ) {
        return 1;
    }
    else {
        return 0;
    }
};

Widget.SortableTable._sortText = function (a, b) {
    var a_text = a.toLowerCase();
    var b_text = b.toLowerCase();

    if ( a_text < b_text ) {
        return -1;
    }
    else if ( a_text > b_text ) {
        return 1;
    }
    else {
        return 0;
    }
};

Widget.SortableTable._sortFunctionsByType = {
    "currency": Widget.SortableTable._sortCurrency,
    "number":   Widget.SortableTable._sortNumber,
    "date":     Widget.SortableTable._sortDate,
    "text":     Widget.SortableTable._sortText
};

Widget.SortableTable._defaultDirByType = {
    "currency": "asc",
    "number":   "asc",
    "date":     "desc",
    "text":     "asc"
};

Widget.SortableTable._makeCellDataSorter = function ( real_func, secondary_func, reverse_secondary ) {
    return function(a, b) {
        var sort = real_func( a.primaryText, b.primaryText );
        if ( sort == 0 && secondary_func ) {
            if (reverse_secondary) {
                return secondary_func( b.secondaryText, a.secondaryText );
            }
            else {
                return secondary_func( a.secondaryText, b.secondaryText );
            }
        }
        return sort;
    };
};

Widget.SortableTable.prototype._resortTable = function (cell_data, rows) {
    for ( var i = 0; i < cell_data.length; i++ ) {
        var row = rows[ cell_data[i].rowNumber ];
        if ( i % 2 ) {
            this._removeCSSClass( row, "w-st-even-row" );
            this._addCSSClass( row, "w-st-odd-row" );
        }
        else {
            this._removeCSSClass( row, "w-st-odd-row" );
            this._addCSSClass( row, "w-st-even-row" );
        }

        if ( this._onSortRowCallback ) {
            this._onSortRowCallback( row, i + 1 );
        }

        this._table.tBodies[0].appendChild(row);
    }
};

Widget.SortableTable.prototype._setRowCSS = function () {
    for ( var i = 0; i < this._table.rows.length; i++ ) {
        if ( i % 2 ) {
            this._addCSSClass( this._table.rows[i], "w-st-even-row" );
            this._removeCSSClass( this._table.rows[i], "w-st-odd-row" );
        }
        else {
            this._addCSSClass( this._table.rows[i], "w-st-odd-row" );
            this._removeCSSClass( this._table.rows[i], "w-st-even-row" );
        }
    }
};

Widget.SortableTable.prototype._updateCSSClasses = function (idx, dir) {
    if ( ( ! this._lastSort )
         ||
         ( this._lastSort && this._lastSort.index != idx ) ) {

        for ( var i = 0; i < this._table.rows.length; i++ ) {
            this._addCSSClass( this._table.rows[i].cells[idx], "w-st-current-sorted-column" );
            if ( this._lastSort ) {
                var old_idx = this._lastSort.index;
                this._removeCSSClass( this._table.rows[i].cells[old_idx], "w-st-current-sorted-column" );
            }
        }
    }

    if ( this._lastSort ) {
        var old_header_cell = this._table.rows[0].cells[ this._lastSort.index ];
        this._removeCSSClass(
            old_header_cell,
            this._lastSort.dir == "asc" ? "w-st-asc-column-header" : "w-st-desc-column-header" );
        this._addCSSClass( old_header_cell, "w-st-unsorted-column-header" );
    }

    var header_cell = this._table.rows[0].cells[idx];
    if ( this._lastSort && this._lastSort.index == idx ) {
        var old_dir = this._lastSort.dir;
        this._removeCSSClass( header_cell,
                              "w-st-" + old_dir + "-column-header" );
    }
    else {
        this._removeCSSClass( header_cell, "w-st-unsorted-column-header" );
    }
    this._addCSSClass( header_cell, "w-st-" + dir + "-column-header" );
};

Widget.SortableTable.prototype._addCSSClass = function (elt, add_class) {
    var class_regex = new RegExp(add_class);
    if ( ! elt.className.match(class_regex) ) {
        elt.className = elt.className + (elt.className.length ? " " : "" ) + add_class;
    }
};

Widget.SortableTable.prototype._removeCSSClass = function (elt, remove_class) {
    var class_regex = new RegExp( "\\s*" + remove_class );
    elt.className = elt.className.replace( class_regex, "" );
};


/*

*/