Table (Sortable)

Table (Sortable)

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

Note:

There is a known issue (bug) in this code. The table sorts correctly the first time a user clicks on a table header, but does not re-sort after clicking on the header again.


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".