Table (Sortable)

Table (Sortable)

This script allows a table to be sorted by the column headers, in ascending or descending order.



Turn on a screen reader to experience this example in action.

People, Ages, and Favorite Colors
1 Myk 33 Purple
2 Hannah 28 Blue
3 Salim 7 Green
4 Greg 45 Orange
5 Caitlin 21 Red
6 Cyan 35 Burgundy

Initial HTML Markup

<div class="deque-table-sortable-group">
  <table class="deque-table-sortable" role="grid" aria-readonly="true">
    <caption>People, Ages, and Favorite Colors</caption>
    <thead>
      <tr role="row">
        <th role="columnheader" scope="col">
          <button class="sortableColumnLabel">
            "Index"
          </button>
        </th>
        <th role="columnheader" scope="col">
          <button class="sortableColumnLabel">
            "Name"
          </button>
        </th>
        <th role="columnheader" scope="col">
          <button class="sortableColumnLabel">
            "Age"
          </button>
        </th>
        <th role="columnheader" scope="col">
          <button class="sortableColumnLabel">
            "Favorite Color"
          </button>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr role="row">
        <th scope="row" role="rowheader">1</th>
        <td role="gridcell">Myk</td>
        <td role="gridcell">33</td>
        <td role="gridcell">Purple</td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">2</th>
        <td role="gridcell">Hannah</td>
        <td role="gridcell">28</td>
        <td role="gridcell">Blue</td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">3</th>
        <td role="gridcell">Salim</td>
        <td role="gridcell">7</td>
        <td role="gridcell">Green</td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">4</th>
        <td role="gridcell">Greg</td>
        <td role="gridcell">45</td>
        <td role="gridcell">Orange</td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">5</th>
        <td role="gridcell">Caitlin</td>
        <td role="gridcell">21</td>
        <td role="gridcell">Red</td>
      </tr>
      <tr role="row">
        <th scope="row" role="rowheader">6</th>
        <td role="gridcell">Cyan</td>
        <td role="gridcell">35</td>
        <td role="gridcell">Burgundy</td>
      </tr>
    </tbody>
  </table>
  <span id="liveRegion" aria-live="polite" readCaptions="false"></span>
</div>

JavaScript

Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js

Key parts of the JavaScript file

Note: The code below is functional only in the context of the complete JavaScript file.

In the @table-sortable section:


      function createSortableTable(tableGroup) {
        var table = tableGroup.querySelector('table');
        var headerGroup = table.querySelector('thead');
        var headerRow = headerGroup.querySelector('tr');
        var headers = headerRow.querySelectorAll('th');
        var rowGroup = table.querySelector('tbody');
        var rows = rowGroup.querySelectorAll('tr');
        var captionElement = table.querySelector('caption');
        var caption = captionElement.innerText;

        var liveRegion = tableGroup.querySelector('#liveRegion');
        var readCaptions = liveRegion.getAttribute('readCaptions');
        if (readCaptions === null) {
          readCaptions = false;
        }
        liveRegion.classList.add('deque-visuallyhidden');
        liveRegion.notify = function (text) {
          liveRegion.innerHTML = text;
        };

        var sortOrder = null;
        var sortDirection = 1;

        function getSortHeader() {
          if (sortOrder === null) {
            return null;
          }
          return headerRow.children[sortOrder];
        }

        function getSortLabel() {
          var header = getSortHeader();
          if (!header) {
            return null;
          }
          return header.innerText;
        }

        function getSortDirection() {
          return sortDirection > 0 ? 'ascending' : 'descending';
        }

        function getSortInfo() {
          if (sortOrder === null) {
            return 'unsorted';
          }

          return 'sorted by ' + getSortLabel() + ', ' + getSortDirection();
        }

        function renderSorting() {
          updateCaption();
          updateAriaSort();
          updateLiveRegion();
        }

        function updateAriaSort() {
          for (var i = 0; i < headerRow.children.length; i++) {
            var child = headerRow.children[i];

            if (sortOrder !== null && i === Math.abs(sortOrder)) {
              var direction = getSortDirection();
              child.setAttribute('aria-sort', direction);
            } else {
              child.removeAttribute('aria-sort');
            }
          }
        }

        function updateCaption() {
          var captionText = caption + ', ' + getSortInfo();
          captionElement.innerText = captionText;
        }

        function updateLiveRegion() {
          if (readCaptions) {
            var captionText = 'Table ' + caption + ' is now ' + getSortInfo();
            liveRegion.notify(captionText);
          }
        }

        rows = Array.prototype.slice.call(rows);
        var isValid = rows.every(function (row) {
          return row.children.length === headers.length;
        });

        if (!isValid) {
          throw new Error('Each row must be the same length as the headers row.');
        }

        headers = Array.prototype.slice.call(headers);
        [].slice.call(headers).forEach(function (header, i) {
          createHeaderCell(header, function (e) {
            e.preventDefault();
            rows = sortByIndex(rows, i);
            table.renderData(rows);
          });
        });

        table.renderData = function (rows) {
          rowGroup.innerHTML = toHTML(rows);
          renderSorting();
        };

        table.renderData(rows);

        function sortByIndex(rows, index) {
          rows = tableGroup.querySelectorAll('tbody tr');
          rows = [].slice.call(rows);

          if (sortOrder === index) {
            sortDirection = -sortDirection;
            return rows.reverse();
          } else {
            sortDirection = 1;
            sortOrder = index;
            return rows.sort(function (a, b) {
              a = Array.prototype.slice.call(a.children);
              b = Array.prototype.slice.call(b.children);
              var aVal = null;
              var bVal = null;

              if (a[index]) {
                aVal = a[index].innerText;
              }

              if (b[index]) {
                bVal = b[index].innerText;
              }

              if (!isNaN(parseInt(aVal)) && !isNaN(parseInt(bVal))) {
                if (parseInt(aVal) < parseInt(bVal)) {
                  return -1;
                }
                if (parseInt(aVal) > parseInt(bVal)) {
                  return 1;
                }
                return 0;
              } else {
                if (aVal < bVal) {
                  return -1;
                }
                if (aVal > bVal) {
                  return 1;
                }
                return 0;
              }
            });
          }
        }

        var firstOne = table.querySelector('.sortableColumnLabel');
        if (firstOne) {
          firstOne.click();
        } // give the table a default sort...
      }

      function createHeaderCell(header, handler) {
        var button = header.querySelector('button');
        button.setAttribute('tabindex', '0');

        button.addEventListener('click', handler);
      }

      function toHTML(rows) {
        return rows.map(function (row) {
          row = Array.prototype.slice.call(row.children);
          return '<tr role="row">\n    ' + row.map(function (item, index) {
            if (index === 0) {
              return '<th scope="row" role="rowheader">' + item.innerText + '</th>';
            }
            return '<td role="gridcell">' + item.innerText + '</td>';
          }).join('') + '</tr>';
        }).join('');
      }

      function activateAllSortableTables() {
        var sortableTables = document.querySelectorAll('.deque-table-sortable-group');
        for (var i = 0; i < sortableTables.length; i++) {
          createSortableTable(sortableTables[i]);
        }
      }

      activateAllSortableTables();
      

In the @containerUtils section:


      function elementIsChildOfElement(child, potentialParent) {
        while (child) {
          if (child === potentialParent) {
            return true;
          }

          child = child.parentNode;
        }

        return false;
      }

      function createFieldset(label) {
        var fieldset = document.createElement('fieldset');
        var legend = document.createElement('legend');
        legend.classList.add('legend'); // for easy lookup regardless of mode
        legend.id = (0, _guidUtils.generateGuid)();
        legend.innerText = label;
        fieldset.appendChild(legend);
        return fieldset;
      }

      function createLiveRegion() {
        var level = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'polite';
        var classes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : [];

        var output = document.createElement('span');
        classes.forEach(function (c) {
          return output.classList.add(c);
        });
        output.id = (0, _guidUtils.generateGuid)();
        output.setAttribute('aria-live', level);
        output.classList.add('deque-visuallyhidden');
        output.innerText = '';
        output.notify = function (text) {
          // TODO: Clean this up...no need to extend the element prototype
          while (output.firstChild) {
            output.removeChild(output.firstChild);
          }
          var msg = document.createElement('div');
          msg.innerHTML = text;
          output.appendChild(msg);
        };

        return output;
      }
      

In the @guidUtils section:


      /*
        note - not a true guid. I prepend 'g' because 
        the ID of an element cannot start with a numeral
      */

      function generateGuid() {
        var S4 = function S4() {
          return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
        };
        return 'g' + (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4());
      }
      

Note: No additional JavaScript initialization code is necessary for this pattern. All elements with class="deque-table-sortable-group" will be initialized automatically by the external JavaScript file.

CSS

Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css

Key styles within the CSS file (other styles may also be necessary):


.deque-table-sortable-group caption,
.deque-table-responsive caption {
  margin-bottom: 15px;
}
.deque-table-sortable-group table,
.deque-table-responsive table {
  border-collapse: collapse;
  border-spacing: 0;
  overflow: visible;
  margin-top: 8px;
  width: 100%;
}
.deque-table-sortable-group table thead,
.deque-table-responsive table thead {
  border-bottom: 1px solid #e3e3e3;
}
.deque-table-sortable-group table thead th,
.deque-table-responsive table thead th {
  position: relative;
  line-height: 16px;
  vertical-align: bottom;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.6);
  border-right: 1px solid #cccccc;
}
.deque-table-sortable-group table thead th:last-child,
.deque-table-responsive table thead th:last-child {
  border-right: none;
}
.deque-table-sortable-group table thead th .sortableColumnLabel,
.deque-table-responsive table thead th .sortableColumnLabel {
  width: 100%;
  display: inline-block;
  height: 100%;
  outline: none;
  cursor: pointer;
  padding: 0 0 0 25px;
  text-align: left;
}
.deque-table-sortable-group table thead th .sortableColumnLabel:focus,
.deque-table-responsive table thead th .sortableColumnLabel:focus,
.deque-table-sortable-group table thead th .sortableColumnLabel:active,
.deque-table-responsive table thead th .sortableColumnLabel:active {
  outline: 1px dashed #000000;
}
.deque-table-sortable-group table thead th .sortableColumnLabel:hover,
.deque-table-responsive table thead th .sortableColumnLabel:hover {
  outline: 1px dashed #767676;
}
.deque-table-sortable-group table thead th .sortableColumnLabel:after,
.deque-table-responsive table thead th .sortableColumnLabel:after {
  content: '\E738';
  font-family: 'mwf-glyphs';
  position: absolute;
  left: 6px;
  font-size: 12px;
}
.deque-table-sortable-group table thead th button,
.deque-table-responsive table thead th button {
  font-size: 11px;
  line-height: 16px;
  font-weight: 400;
  color: rgba(0, 0, 0, 0.6);
  background: transparent;
  border: 0;
  padding-left: 0;
}
.deque-table-sortable-group table thead th button:hover,
.deque-table-responsive table thead th button:hover {
  color: rgba(0, 0, 0, 0.8);
  margin: 0;
}
.deque-table-sortable-group table thead th button:focus,
.deque-table-responsive table thead th button:focus {
  outline: 1px dashed rgba(0, 0, 0, 0.6);
  margin: 0;
}
.deque-table-sortable-group table thead th button:active,
.deque-table-responsive table thead th button:active {
  color: #000000;
  outline: 1px solid transparent;
  margin: 0;
}
.deque-table-sortable-group table thead th[colspan]:not([colspan='1']),
.deque-table-responsive table thead th[colspan]:not([colspan='1']) {
  text-align: center;
}
.deque-table-sortable-group table thead th[aria-sort='descending'] .sortableColumnLabel:after,
.deque-table-responsive table thead th[aria-sort='descending'] .sortableColumnLabel:after {
  content: '\E70D';
  font-family: 'mwf-glyphs';
  position: absolute;
  left: 6px;
  font-size: 12px;
}
.deque-table-sortable-group table thead th[aria-sort='ascending'] .sortableColumnLabel:after,
.deque-table-responsive table thead th[aria-sort='ascending'] .sortableColumnLabel:after {
  content: '\E70E';
  font-family: 'mwf-glyphs';
  position: absolute;
  left: 6px;
  font-size: 12px;
}
.deque-table-sortable-group table thead:before,
.deque-table-responsive table thead:before {
  content: '';
  display: block;
  width: 7px;
}
.deque-table-sortable-group table thead td,
.deque-table-responsive table thead td,
.deque-table-sortable-group table thead th,
.deque-table-responsive table thead th {
  padding: 12px 0;
  text-align: left;
}
.deque-table-sortable-group table tbody tr:nth-child(even),
.deque-table-responsive table tbody tr:nth-child(even) {
  background-color: #eeeeee;
}
.deque-table-sortable-group table tbody td,
.deque-table-responsive table tbody td,
.deque-table-sortable-group table tbody th,
.deque-table-responsive table tbody th {
  text-align: left;
  padding: 7px;
  border-right: 1px solid #cccccc;
}
.deque-table-sortable-group table tbody td:last-child,
.deque-table-responsive table tbody td:last-child,
.deque-table-sortable-group table tbody th:last-child,
.deque-table-responsive table tbody th:last-child {
  border-right: none;
}

Fonts

Note: You will need to edit the src for font-family:'mwf-glyphs' in the external CSS file.

Implementation Instructions

Step 1: Add Dependencies

Add deque-patterns.min.css in the <head> of the document.

<link rel="stylesheet" type="text/css" href="deque-patterns.min.css">

Add a script link to deque-patterns.min.js to the bottom of the page.

<script type="text/javascript" src="deque-patterns.min.js"></script>

Fonts: Font glyphs are required for this pattern (as currently styled). The CSS file refers to them in a folder called "_fonts" as a child element of the folder in which the CSS file resides. The fonts, from the Microsoft Web Framework (MWF) are available at these links:

Step 2: Add HTML

  • Create a <div> or <span> container, with class="deque-table-sortable-group". Every container with this class is selected and activated by the javascript. This class is also for styling purposes.
    • Add a <table> container with class="deque-table-sortable", role="grid", and aria-readonly="true".
      • Create a <caption> container with the inner text being the caption for your table. The javascript automatically adds the column chosen by the user and whether the table is ascending or descending.
      • The header row of the table is created by adding a <thead> container. Make sure that the number of headers created matches the number of rows.
        • Create a <tr> container with role="row".
          • For each header add a <th> container with role="columnheader" and scope="col". The javascript code automatically sorts the first column in descending order and adds aria-sort="descending" to the first header.
            • Within the header create a <button> container with class="sortableColumnLabel". The inner text will be the header title.
      • To make the main content of the table add a <tbody> container
        • For each row create a <tr> container with role="row".
          • If you would like to include a row header add a <th> container with scope="row" and role="rowheader". The inner text will appear within the data cell.
          • Every other data cell in this row is a <td> container with role="gridcell".
    • Create a <span> container with id="liveRegion" and aria-live="polite".