Example Start

Example End

Example Description

Type: Best Practice

Simple example of a grid widget to implement an email application. Inline images are used to display the visual state of the sort order of the cells.

Keyboard Support

  • Tab: Move focus between web 2.0 widgets, links and form controls.
  • Up Arrow: Move focus to previous message
  • Down Arrow: Move focus to next message
  • Left Arrow: Move focus one column to the left.
  • Right Arrow: Move focus one column to the right.
  • Space Bar: When in message list, it selects or unselects the current message.
  • Space Bar: When the headers, it reorders the list of messages based on that column data.

Example Markup

Browser Compatibility

HTML Source Code


<div role="application">

<table id="grid1" class="email" role="grid" aria-labelledby="grid1_label" aria-readonly="true">
    
  <caption id="grid1_label">Inbox</caption>
  
  <thead>
    <tr>
      <th id="grid1_sel" tabindex="-1"><abbr title="Selected">Sel</abbr></th>
      <th id="grid1_msg" tabindex="-1"><abbr title="Message">Msg</abbr></th>
      <th id="grid1_stat" tabindex="-1">Status</th>
      <th id="grid1_att" tabindex="-1"><abbr title="Attachment">Att</abbr></th>
      <th id="grid1_pri" tabindex="-1"><abbr title="Priority">Pri</abbr></th>
      <th id="grid1_from" tabindex="-1">From</th>
      <th id="grid1_sub" tabindex="-1">Subject</th>
      <th id="grid1_date" tabindex="-1">Date</th>
      <th id="grid1_size" tabindex="-1">Size</th>
  </tr>
  </thead>
  
  <tbody id="data">
  </tbody>
</table>
</div>

CSS Code


body {
  font-size: medium;
  font-family: sans-serif;
  }
  
.warning {
  color: red;
  font-weight: bold;
}

#grid1 {
  padding: 0;
  text-align: center;
  border-collapse: collapse;
  border: 2px solid black;
}

thead th {
  padding: 2px 8px;
  background-color: #eef;
  border: 1px solid black;
}

th.hover {
  border: 2px solid black;
  background-color: #9bf;
}

tbody th, td {
  padding: 2px 5px;
  border: 1px solid black;
}

button.sort-button {
  margin: 5px;
  padding: 0px;
  width: 18px;
  height: 19px;
  border: none;
  background-color: transparent;
}

img.sort-image {
  margin: 0;
  padding: 0;
  width: 14px;
  height: 15px;
  position: relative;
  top: 0px;
  left: -1px;
}

.focus {
  border: 2px solid black;
  background-color: #79e;
}

.selected {
  color: #fff;
  background-color: #800 !important;
}

.odd {
  background-color: #fcfcec;
}
.even {
  background-color: transparent;
}

.hidden {
  position: absolute;
  top: -30em;
  left: -300em;
}

Javascript Source Code


$(document).ready(function() {

  var emailApp = new email('grid1');
}); // end ready

//
// keyCodes() is an object to contain keycodes needed for the application
//
function keyCodes() {
  // Define values for keycodes
  this.tab        = 9;
  this.enter      = 13;
  this.esc        = 27;

  this.space      = 32;
  this.pageup     = 33;
  this.pagedown   = 34;
  this.end        = 35;
  this.home       = 36;

  this.left       = 37;
  this.up         = 38;
  this.right      = 39;
  this.down       = 40;

} // end keyCodes

/*
* function email() is a class to implement an email application. email() loads example email into a
* simple data table, making the table columns sortable.
*
* email() must be passed the id of the html table to attach to. It assumes that the email data is in
* inbox.json.
*
*/
function email(id) {

  var thisObj = this;

  this.$id = $('#' + id); // the jquery object for the table to attach to.
  this.$headers = $('#' + id + ' thead').find('th'); // an array of jquery objects for the header cells
  this.$data = this.$id.find('#data'); // the jquery object for the table tbody
  this.$cells; // an array of jquery objects for the table cells - including the header column

  // create arrays to give human-readable labels to the columns and grid cells
  this.labels = new Array('Selected', 'Message Number', 'Status', 'Attachment', 'Priority', 'From', 'Subject', 'Date', 'Size');
  this.priorities = new Array('lowest', 'low', 'none', 'high', 'highest');
  this.stat = new Array('unread', 'read', 'reply');

  this.keys = new keyCodes();
  this.isSortable = false; // Set to true if data loads correctly and table has sortable columns

  // connect to the json file and load the data
  $.getJSON('http://www.oaa-accessibility.org/media/examples/js/inbox.json', function(data, rslt) {

    if (rslt == 'success') {

      // Load the data from the json file
      thisObj.loadData(data);

      // make the table sortable
      thisObj.makeSortable(thisObj.$data);

      thisObj.$cells = thisObj.$data.find('th,td');

      // bind event handlers to the application table
      thisObj.bindHandlers();
    }
  });


} // end email() constructor

//
//
// function loadData() loads the email data from the json file
//
// @return N/A
//
email.prototype.loadData = function(data) {

  var thisObj = this;
  var row;
  var tmpStr;
  var count = 1;
  var id = thisObj.$id.attr('id');
  var label;

  $.each(data.emails, function(ndx, record) {

    var msgID = id + '_msg' + count;

    row = '<tr id="' + msgID + '" role="row" aria-selected="false">';

    label = msgID + ' ' + id + '_sel"';
    row += '<th id="' + msgID + '_sel" role="gridcell" aria-labelledby="' + label
      + '" tabindex="0"><input name="email_' + count
      + '" type="checkbox" tabindex="-1"/></th>';

    row += '<td id="' + msgID + '_msg" role="gridcell" headers="' + msgID + '_sel ' + id + '_msg" tabindex="-1">' + count + '</td>';

    // set tmpstr to be the value of the message status (unread, read, reply)
    tmpStr = thisObj.stat[record.stat];

    row += '<td id="' + msgID + '_stat" role="gridcell" headers="' + msgID + '_sel ' + id + '_stat" tabindex="-1">'
      + '<span class="hidden">' + tmpStr + '</span><img src="http://www.oaa-accessibility.org/media/examples/images/' + tmpStr
      + '.gif" alt="' + tmpStr + '" role="presentation"></td>';

    // set tmpstr to indicate an attachment or no attachment
    tmpStr = (record.att == true ? 'attachment': 'no attachment');

    row += '<td id="' + msgID + '_att" role="gridcell" headers="' + msgID + '_sel ' + id + '_att" tabindex="-1">'
      + '<span class="hidden">' + tmpStr + '</span><img src="http://www.oaa-accessibility.org/media/examples/images/'
      + (record.att == true ? 'attach': 'noattach') + '.gif" alt="' + tmpStr + '" role="presentation"></td>';

    // set tmpStr to be the value of the message priority
    tmpStr = thisObj.priorities[record.pri - 1];

    row += '<td id="' + msgID + '_pri" role="gridcell" headers="' + msgID + '_sel ' + id + '_pri" tabindex="-1">'
      +'<span class="hidden">Priority ' + record.pri + '</span><img src="http://www.oaa-accessibility.org/media/examples/images/priority_'
      + tmpStr + '.gif" alt="' + tmpStr + '" role="presentation"></td>';

    row += '<td id="' + msgID + '_from" role="gridcell" headers="' + msgID + '_sel ' + id + '_from" tabindex="-1">'
      + record.from + '</td>';

    row += '<td id="' + msgID + '_sub" role="gridcell" headers="' + msgID + '_sel ' + id + '_sub" tabindex="-1">'
      + record.sub + '</td>';

    row += '<td id="' + msgID + '_date" role="gridcell" headers="' + msgID + '_sel ' + id + '_date" tabindex="-1">'
      + record.date + '</td>';

    row += '<td id="' + msgID + '_size" role="gridcell" headers="' + msgID + '_sel ' + id + '_size" tabindex="-1">'
      + record.siz + '</td></tr>';

    thisObj.$data.append(row);

    count++;
  });

} // end loadData()

//
//
// function makeSortable() applies properties to a table necessary to allow for alpha-numeric sorting
//
// @return N/A
//
email.prototype.makeSortable = function() {

  var thisObj = this;

  // iterate through the table elements
  this.$id.each(function () {

    thisObj.$id.alternateRowColors();

    // iterate through the table header
    thisObj.$headers.each(function(column) {

      var $header = $(this);
      var findSortKey;

      // Add the sort-alpha class
      $header.addClass('sort-alpha');

      // find the sort keys
      findSortKey = function ($cell) {
        return $cell.find('.sort-key').text().toUpperCase()
          + ' ' + $cell.text().toUpperCase();
      };

      if (findSortKey) {
        // Define the header highlighting handler
        $header.find('.sort-button').addClass('clickable');

        $header.append('<button class="sort-button" role="button" type="button" tabindex="-1">'
            + '<img class="sort-image" src="http://www.oaa-accessibility.org/media/examples/images/sortable.png" title="Sort column" role="presentation">'
            + '<span class="hidden">Sort ' + $header.text() + ' column</span>'
            + '</button>');

        thisObj.isSortable = true;


      } // end if
    }); // end each
  }); // end each
} // end makeSortable();

//
// function sortTable() is a member function to sort the table rows alpha-numerically based on the active column
//
// @param($id object) $id is the jQuery object of the header cell of the column to sort by
//
// return N/A
//
email.prototype.sortTable = function($id) {

  var thisObj = this;
  var colNdx = this.$headers.index($id);
  var sortDirection = 1;

  // if the table is sorted in ascending order, reverse
  // the sort order flag
  if ($id.is('.sorted-asc')) {
    sortDirection = -1;
  }

  // create an array of the table rows
  var rows = this.$data.find('tr').get();

  // iterate through the table rows and store the sort keys for each
  // in an attached expando
  $.each(rows, function (index, row) {
    var $cell;

    // The first column is marked as a header row for screen readers
    // Need to treat it seperately
    if (colNdx == 0) {
      $cell = $(row).children('th').eq(colNdx);
      row.sortKey = $cell.find('.sort-key') + ' '
          + $cell.find('input').attr('checked');
    }
    else {
      $cell = $(row).children('td').eq(colNdx - 1);

      row.sortKey = $cell.find('.sort-key').text().toUpperCase()
          + ' ' + $cell.text().toUpperCase();
    }
  });

  // sort the rows
  rows.sort(function(a, b) {

    if (a.sortKey < b.sortKey) {
      // move row a before row b
      // (according to the sort direction)
      return -sortDirection;
    }
    else if (a.sortKey > b.sortKey) {
      // move row a after row b
      return sortDirection;
    }

    // the rows are equal--do nothing
    return 0;
  });

  // iterate through the rows array and reinsert them into the table
  // according to the new sort order
  $.each(rows, function(index, row) {

    // insert the row
    thisObj.$data.append(row);

    // remove the expando
    row.sortKey = null;
  });


  // remove the previous sorted class and reset the button images
  this.$headers
    .removeClass('sorted-asc')
    .removeClass('sorted-desc')
      .removeAttr('aria-sorted')
      .not($id).find('img').attr('src', 'http://www.oaa-accessibility.org/media/examples/images/sortable.png');

  if (sortDirection == 1) {
    $id.addClass('sorted-asc');
      $id.attr('aria-sort', 'ascending');
    $id.find('img').attr('src', 'http://www.oaa-accessibility.org/media/examples/images/sorted-asc.png');
  }
  else {
    $id.addClass('sorted-desc');
      $id.attr('aria-sort', 'descending');
    $id.find('img').attr('src', 'http://www.oaa-accessibility.org/media/examples/images/sorted-desc.png');
  }

  // reapply the sorted class
  // the first column is th for screen readers
  if (this.$headers.index($id) == 0) {
    // remove the sorted class from the other columns
    this.$data.find('td').removeClass('sorted')

    // apply the class to the browser column
    this.$data.find('th').addClass('sorted');
  }
  else {
    // remove the class from the browser column
    this.$data.find('th').removeClass('sorted');

    // apply the class to the appropriate column
    this.$data.find('td').removeClass('sorted')
      .filter(':nth-child(' + (colNdx + 1) + ')')
      .addClass('sorted');
  }

  // Modify the table caption to reflect the new sort column
  this.$id.find('caption').html('Inbox sorted by '
    + this.labels[colNdx] + ': '
    + ($id.is('.sorted-asc') ? 'Ascending' : 'Descending') + ' order');
    
  // reapply the row striping
  this.$id.alternateRowColors();

} // end sortTable()
//
// function bindHandlers() is a member function to bind event handlers to the application table
//
// return N/A
//
email.prototype.bindHandlers = function() {

  var thisObj = this;

  ////////////////// bind header handlers ////////////////////
  
  this.$headers.keydown(function(e) {
    return thisObj.handleHeaderKeyDown($(this), e);
  });
  
  this.$headers.keypress(function(e) {
    return thisObj.handleHeaderKeyPress($(this), e);
  });
  
  this.$headers.click(function(e) {
    return thisObj.handleHeaderClick($(this), e);
  });

  this.$headers.hover(function() {
    $(this).addClass('hover');
  }, function() {
    $(this).removeClass('hover');
  });

  this.$headers.focus(function(e) {
    return thisObj.handleHeaderFocus($(this), e);
  });

  this.$headers.blur(function(e) {
    return thisObj.handleHeaderBlur($(this), e);
  });

  ////////////////// bind tbody handlers ////////////////////
  
  // bind a keydown handler
  this.$data.delegate('th,td', 'keydown', function (e) {
      return thisObj.handleCellKeyDown($(this), e);
  }); // end edit box keydown handler

  // bind a keypress handler - consume events for Opera
  this.$data.delegate('th,td', 'keypress', function (e) {
      return thisObj.handleCellKeyPress($(this), e);
  }); // end edit box keyup handler

  // bind a click handler
  this.$data.delegate('th,td', 'click', function (e) {
      return thisObj.handleCellClick($(this), e);
  }); // end edit box keyup handler

  // bind a focus handler
  this.$data.delegate('th,td', 'focus', function (e) {
      return thisObj.handleCellFocus($(this), e);
  }); // end edit box focus handler

  // bind a blur handler
  this.$data.delegate('th,td', 'blur', function (e) {
      return thisObj.handleCellBlur($(this), e);
  }); // end edit box blurhandler

  ////////////////// bind checkbox click handler ////////////////////
  
  // bind a click handler
  this.$data.delegate('input', 'mousedown', function (e) {
      return thisObj.handleCheckboxMouseDown($(this), e);
  }); // end edit box keydown handler

} // end bindHandlers();

//
// Function handleHeaderKeyDown() is a member function to process keydown events for the table header cells
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleHeaderKeyDown = function($id, e) {

  var curNdx = this.$headers.index($id);

  if (e.altKey || e.ctrlKey || e.shiftKey) {
    // do nothing
    return true;
  }

  switch (e.keyCode) {
    case this.keys.space: {
      $id.focus();

      this.sortTable($id);

      e.stopPropagation();
      return false;
    }
    case this.keys.left: {
      if (curNdx > 0) {
        var $prev = this.$headers.eq(curNdx - 1);

        $prev.focus();
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.right: {
      if (curNdx < this.$headers.length - 1) {
        var $next = this.$headers.eq(curNdx + 1);

        $next.focus();
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.down: {

      // set focus on the cell in this columen of the first table row
      this.$data.find('tr').first().children().eq(curNdx).focus();

      e.stopPropagation();
      return false;
    }
  }

  return true;

} // end handleHeaderKeyDown()

//
// Function handleHeaderKeyPress() is a member function to process keypress events for the table header
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleHeaderKeyPress = function($id, e) {

  if (e.altKey || e.ctrlKey || e.shiftKey) {
    // do nothing
    return true;
  }

  switch (e.keyCode) {
    case this.keys.space:
    case this.keys.down: {
      e.stopPropagation();
      return false;
    }
  }

  return true;

} // end handleHeaderKeyPress()

//
// Function handleHeaderClick() is a member function to process click events for the table header cells
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleHeaderClick = function($id, e) {

  $id.focus();

  this.sortTable($id);

  e.stopPropagation();
  return false;

} // end handleHeaderClick()

//
// Function handleHeaderFocus() is a member function to process blur events for the table header cells
//
// @return (boolean) returns true
//
email.prototype.handleHeaderFocus = function($id, e) {

  // remove the other headers from the tab order and remove the focus highlighting
  this.$headers.attr('tabindex', '-1').removeClass('focus');

  // remove the cells from the tab order and remove the focus highlighting
  this.$cells.attr('tabindex', '-1').removeClass('focus');

  // add the focus class and make the current header navigable
  $id.addClass('focus').attr('tabindex', '0');

  return true;
} // end handleHeaderFocus()

//
// Function handleHeaderBlur() is a member function to process blur events for the table header cells
//
// @return (boolean) returns true
//
email.prototype.handleHeaderBlur = function($id, e) {

  // remove the focus class
  $id.removeClass('focus');

  return true;
} // end handleHeaderBlur()

//
// Function handleCellKeyDown() is a member function to process keydown events for the table cells
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleCellKeyDown = function($id, e) {

  var $curRow = $id.parent();
  var $rows = this.$data.find('tr');
  var rowNdx = $rows.index($curRow);

  if (e.altKey || e.ctrlKey || e.shiftKey) {
    // do nothing
    return true;
  }

  switch (e.keyCode) {
    case this.keys.space: {
      var $input = $curRow.find('input');

      if ($input.attr('checked') == true) {
        $input.attr('checked', false);
        $curRow.removeClass('selected').attr('aria-selected', 'false');
      }
      else {
        $input.attr('checked', true);
        $curRow.addClass('selected').attr('aria-selected', 'true');
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.left: {
      // move left one cell

      var $prev = $id.prev();

      if ($prev.length > 0) {
        $prev.focus();
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.right: {
      // move right one cell

      var $next = $id.next();

      if ($next.length > 0) {
        $next.focus();
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.up: {
      // move up one row

      var colNdx = $curRow.children().filter($id).index();

      if (rowNdx > 0) {
        var $newCell = $rows.eq(rowNdx-1).children().eq(colNdx);

        $newCell.focus();
      }
      else {
        // move to the table header
        this.$headers.eq(colNdx).focus();

        // remove the cells from the tab order
        this.$cells.attr('tabindex', '-1');
      }

      e.stopPropagation();
      return false;
    }
    case this.keys.down: {
      // move down one row

      if (rowNdx < $rows.length-1) {
        var colNdx = $curRow.children().filter($id).index();
        var $newCell = $rows.eq(rowNdx+1).children().eq(colNdx);

        $newCell.focus();
      }

      e.stopPropagation();
      return false;
    }
  }

  return true;

} // end handleCellKeyDown()

//
// Function handleCellKeyPress() is a member function to process keypress events for the table cells
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleCellKeyPress = function($id, e) {

  if (e.altKey || e.ctrlKey || e.shiftKey) {
    // do nothing
    return true;
  }

  switch (e.keyCode) {
    case this.keys.space:
    case this.keys.left:
    case this.keys.up:
    case this.keys.right:
    case this.keys.down: {
      e.stopPropagation();
      return false;
    }
  }

  return true;

} // end handleCellKeyPress()

//
// Function handleCellClick() is a member function to process click events for the table cells
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false if consuming event; true of not processing
//
email.prototype.handleCellClick = function($id, e) {

  $id.focus()

  e.stopPropagation();
  return false;

} // end handleCellClick()

//
// Function handleCellFocus() is a member function to process blur events for the table cells
//
// @return (boolean) returns true
//
email.prototype.handleCellFocus = function($id, e) {

  // remove the headers from the tab order and remove the focus highlighting
  this.$headers.attr('tabindex', '-1').removeClass('focus');

  // remove the other cells from the tab order and remove the focus highlighting
  this.$cells.attr('tabindex', '-1').removeClass('focus');


  // add the focus class and make the current cell navigable
  $id.addClass('focus').attr('tabindex', '0');

  return true;
} // end handleCellFocus()

//
// Function handleCellBlur() is a member function to process blur events for the table cells
//
// @return (boolean) returns true
//
email.prototype.handleCellBlur = function($id, e) {

  // remove the focus class
  $id.removeClass('focus');

  return true;
} // end handleCellBlur()

//
// Function handleCheckboxMouseDown() is a member function to process mousedown for the checkboxes
//
// @param ($id object) $id is the jquery object of the cell generating the event
//
// @param (e object) e is the associated event object
//
// @return (boolean) returns false;
//
email.prototype.handleCheckboxMouseDown = function($id, e) {

  var $curRow = $id.parent().parent();

  if ($id.attr('checked') == true) {
    $id.attr('checked', false);
    $curRow.removeClass('selected').attr('aria-selected', 'false');
  }
  else {
    $id.attr('checked', true);
    $curRow.addClass('selected').attr('aria-selected', 'true');
  }

  this.$cells.removeClass('focus');
  $id.parent().focus().addClass('focus');

  e.stopPropagation();
  return false;

} // end handleCheckboxMouseDown()

// Define a small jQuery extension to alternate table row colors
jQuery.fn.alternateRowColors = function() {
  $('tbody tr:odd', this).removeClass('even').addClass('odd');
  $('tbody tr:even', this).removeClass('odd').addClass('even');

  return this;
};