Sortable Grid

  • To sort a sortable column, click the heading button. The caption will update with the sorted state along with the "aria-sort" attributes.
  • With a keyboard users can sort a column by pressing ENTER or SPACE BAR on the column heading
  • Notice that "Company" column is NOT sortable
  • It would have been best to put the aria-live on the table <caption> itself, or the <span> with in the <caption>, but for some reason NVDA and VoiceOver did not read the aria-live correctly when inside a table <caption>, so we put a visually-hidden aria-live region below the table and put a timer on it so it disappears after 2 seconds, to ensure screen reader users won't come across it as regular text after leaving the table.
Employee Table
Name Salary ($) Extension Start Date Company
Rob Derhak 74,500 1159 11/17/98 Telemericorp
Chuck Garvey 96,000 1056 11/21/98 Telemericorp
Al Schnier 92,000 4653 06/07/08 Telemericorp
Vinnie Amico 47,000 9844 12/16/14 Telemericorp
Jim Loughlin 55,000 7745 04/20/00 Telemericorp

HTML Source Code


<div id="table-cage">

  <table id="employee-table" role="grid" aria-labelledby="employee-caption" aria-readonly="true">
    <caption id="employee-caption">Employee Table<span id="captionUpdated"></span></caption>
    <thead>
      <tr role="row">
        <th scope="col" role="columnheader" id="e-name" class="sortable" aria-sort="none" data-sort="text">
            <span role="button" tabindex="0" class="th-body">Name<i class="fa fa-arrows-v"></i></span>
        </th>
        <th scope="col" role="columnheader" id="e-salary" class="sortable" aria-sort="none" data-sort="money">
          <span role="button" tabindex="0" class="th-body">Salary ($)<i class="fa fa-arrows-v"></i></span>
        </th>
        <!-- not providing the sort type because it defaults to "standard" -->
        <th scope="col" role="columnheader" id="e-extension" class="sortable" aria-sort="none">
          <span role="button" tabindex="0" class="th-body">Extension<i class="fa fa-arrows-v"></i></span>
        </th>
        <th scope="col" role="columnheader" id="e-start-date" class="sortable" aria-sort="none" data-sort="date">
            <span role="button" tabindex="0" class="th-body">Start Date<i class="fa fa-arrows-v"></i></span>
        </th>
        <th scope="col" role="columnheader" id="e-company" aria-disabled="true">Company</th>
      </tr>
    </thead>
    <tbody>
      <tr role="row">
        <td role="rowheader" id="rob">Rob Derhak</td>
        <td role="gridcell">74,500</td>
        <td role="gridcell">1159</td>
        <td role="gridcell">11/17/98</td>
        <td role="gridcell">Telemericorp</td>
      </tr>
      <tr role="row">
        <td role="rowheader" id="chuck">Chuck Garvey</td>
        <td role="gridcell">96,000</td>
        <td role="gridcell">1056</td>
        <td role="gridcell">11/21/98</td>
        <td role="gridcell">Telemericorp</td>
      </tr>
      <tr role="row">
        <td role="rowheader" id="al">Al Schnier</td>
        <td role="gridcell">92,000</td>
        <td role="gridcell">4653</td>
        <td role="gridcell">06/07/08</td>
        <td role="gridcell">Telemericorp</td>
      </tr>
      <tr role="row">
        <td role="rowheader" id="vinnie">Vinnie Amico</td>
        <td role="gridcell">47,000</td>
        <td role="gridcell">9844</td>
        <td role="gridcell">12/16/14</td>
        <td role="gridcell">Telemericorp</td>
      </tr>
      <tr role="row">
        <td role="rowheader" id="jim">Jim Loughlin</td>
        <td role="gridcell">55,000</td>
        <td role="gridcell">7745</td>
        <td role="gridcell">04/20/00</td>
        <td role="gridcell">Telemericorp</td>
      </tr>
    </tbody>
  </table>
</div>
<div id="liveForScreenReaders" class="visuallyhidden" aria-live="polite"></div>

JavaScript Source Code

Dependencies:

  • JQuery
var sortOrder;
var $container = $('#table-cage');
var $table = $('#employee-table');
var $tbody = $table.find('tbody');
var $sortables = $table.find('.sortable');
var $rows = $table.find('tbody tr');
var $captionSpan = $('#captionUpdated');

$table.on('click', '.sortable', sortCol);
$table.on('keydown', '.th-body', function (e) {
  if (e.which === 13 || e.which === 32) {
    this.click();
  }
});

var iOS = (navigator.userAgent.match(/(iPad|iPhone|iPod)/g) ? true : false );
if (iOS) {
  labelledbyConfig($table);
}

// ensure that the updating of the caption is read out by AT
/*$table.find('caption').attr({
  'aria-live': 'polite',
  'aria-atomic': 'false'
});
*/

function sortCol() {
  // updates the sort icon and returns the new sort state
  sortOrder = updateIcon(this);
  var items = [];
  var sortType = this.getAttribute('data-sort');
  var thisIndex = $.inArray(this, $sortables);

  // loop through each row and build our `items` array
  // which will become an array of objects:
  // {
  //  tr: (the HTMLElement reference to the given row),
  //  val: (the String value of the corresponding td)
  // }
  $rows.each(function () {
    var item = {};
    item.tr = this;
    $tds = $(this).find('td');
    var td = $tds[thisIndex];
    item.val = $(td).text();
    items.push(item);
  });

  // sort the array of values
  if (!sortType || sortType === 'standard') {
    items.sort(standardSort);
  } else if (sortType === 'date') {
    items.sort(dateSort);
  } else if (sortType === 'text') {
    items.sort(textSort);
  } else if (sortType === 'money') {
    items.sort(moneySort);
  }

  // clear the tbody's contents
  $tbody.html('');

  // append each row in the new, sorted order
  // currently not working in IE
  $.each(items, function (i, item) {
    $tbody.append(item.tr);
  });

  // update the caption:
  var updatedMessage = ' (Sorted by ' + $(this).text() + ': ' + sortOrder + ')';
  $captionSpan.text(updatedMessage);
  $('#liveForScreenReaders').text(updatedMessage);
  setTimeout(function() {
      $('#liveForScreenReaders').html('');
	}, 2000);
}

/**
 * Updates the arrow icon based on new sort status
 * @param  {HTMLElement} th    The table heading element reference
 * @return {String}      state The new sort state ("ascending" or "descending")
 */
function updateIcon(th) {
  var state = 'ascending';
  var $icon = $(th).find('i');
  if ($icon.hasClass('fa-arrows-v')) { // No sort -> Ascending
    $icon
      .removeClass('fa-arrows-v')
      .addClass('fa-arrow-up');
  } else if ($icon.hasClass('fa-arrow-down')) { // Descending -> Ascending
    $icon
      .removeClass('fa-arrow-down')
      .addClass('fa-arrow-up');
      state = 'ascending';
  } else { // Ascending -> Descending
    $icon
      .removeClass('fa-arrow-up')
      .addClass('fa-arrow-down');
    state = 'descending';
  }

  $(th).attr('aria-sort', state);

  $(th).siblings().each(function () {
    // update all other rows with the neutral sort icon
    $(this)
      .attr('aria-sort', 'none')
      .find('i')
      .removeClass('fa-arrow-up')
      .removeClass('fa-arrow-down')
      .addClass('fa-arrows-v');
  });
  return state;
}

/**
 * Executes a standard sort (direct comparisons)
 */
function standardSort(a, b) {
  return (sortOrder === 'ascending')
          ? a.val - b.val
          : b.val - a.val;
}

/**
 * Takes two formatted date/time values
 * (see `formatDate`) and compares them
 */
function dateSort(a, b) {
  return (sortOrder === 'ascending')
          ? formatDate(a.val) - formatDate(b.val)
          : formatDate(b.val) - formatDate(a.val);
}

function textSort(a, b) {
  if (sortOrder === 'ascending') {
    if (a.val.toLowerCase() < b.val.toLowerCase()) {
      return -1;
    }
    if (a.val.toLowerCase() > b.val.toLowerCase()) {
      return 1;
    }

    return 0;
  } else {
    if (a.val.toLowerCase() < b.val.toLowerCase()) {
      return 1;
    }
    if (a.val.toLowerCase() > b.val.toLowerCase()) {
      return -1;
    }

    return 0;
  }
}

function moneySort(a, b) {
  var strippedA = a.val.replace(/,/g , '');
  var strippedB = b.val.replace(/,/g , '');

  if (sortOrder === 'ascending') {
    if (strippedA < strippedB) {
      return -1;
    }
    if (strippedA > strippedB) {
      return 1;
    }

    return 0;
  } else {
    if (strippedA < strippedB) {
      return 1;
    }
    if (strippedA > strippedB) {
      return -1;
    }

    return 0;
  }
}


/**
 * Formats a date string ("01/01/01") as
 * a numeric value using `Date.getTime`
 */
function formatDate(dateString) {
  var formattedDate = new Date(dateString);
  return formattedDate.getTime();
}


function labelledbyConfig($table) {
  // below is an attempt to fix voiceover bug which
  // does not respect table cell to heading realationships
  var labelledbyText;
  var colHeadIDs = ['e-name', 'e-salary', 'e-extension', 'e-start-date', 'e-company'];
  var $tbody = $table.find('tbody').first();
  var $tds = $tbody.find('td');

  $tds.each(function () {
    var tdRole = this.getAttribute('role');

    if (tdRole === 'rowheader') {
      labelledbyText = 'e-name';
    } else {
      // find its index within its row
      var $rowTds = $(this).closest('tr').first().find('td');
      var index = $.inArray(this, $rowTds);
      labelledbyText = colHeadIDs[index] + ' ' + $rowTds[0].id;
    }

    this.setAttribute('aria-describedby', labelledbyText);
  });
}

CSS Source Code

#employee-table td,
#employee-table th {
  padding: 0;
  border: 2px solid;
  font-size: 16px;
}
#employee-table {
  width: 100%;
  border-collapse: collapse;
}

#employee-table th {
  background: #eee;
}

#employee-table th i {
  font-size: 14px;
  padding: 5px;
  width: 10px;
}

.th-body:focus {background-color:#fdf6e7 !important;}

.th-body {
  display: block;
  width: 100%;
}

.visuallyhidden { 
	border: 0; 
	clip: rect(0 0 0 0); 
	height: 1px; 
	margin: -1px; 
	overflow: hidden; 
	padding: 0; 
	position: absolute; 
	width: 1px; 
}

Copy and Paste Full Page Example