Table (Responsive, Collapsible)

Tables are notoriously difficult to view on small devices, and difficult for web designers to implement in ways that don't break the layout of the page. This responsive table design displays as a table in screens wide enough to display the table and collapses into a list format when the screen is too small to show the table horizontally.



Example 1

When collapsed, the rows of this table show as nested bulleted lists, organized by a parent list with the word "row" at the beginning of each row. This method is useful as a generic fallback when you're not sure which table cells would work best as labels when the table is collapsed.

Generic "Row" Label When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

Example 2

When collapsed, the nested lists are labeled by parent list items that take the text from the first two columns of the table (e.g. "Alice Egghead"). This method has the advantage of giving each list a more human-readable label than simply "row" as in the previous table.

Two-Column Label Function (First Name and Last Name) When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

Example 3

When collapsed, the rows turn into a single button. This result isn't applicable to many situations, but it may be appropriate in cases where the main point of the row is to have the user activate a button.

Row Summed Up Into a Button When Collapsed
First Name Last Name Age Favorite Color Select
Alice Egghead 33 blue
Bob Smith 43 purple
Charlie Jones 23 yellow
Denise Brown 53 green
Eloise Humperdink 83 red

Initial HTML Markup

Generic "Row" Label When Collapsed

<table class="deque-table-responsive" id="table1" border="1">
  <caption>
  Generic &quot;Row&quot; Label When Collapsed
  </caption>
  <thead>
    <tr role="row">
      <th scope="col" role="columnheader">First Name</th>
      <th scope="col" role="columnheader">Last Name</th>
      <th scope="col" role="columnheader">Age</th>
      <th scope="col" role="columnheader">Favorite Color</th>
      <th scope="col" role="columnheader">Select</th>
    </tr>
  </thead>
  <tbody>
    <tr role="row">
      <th scope="row" role="rowheader">Alice</th>
      <td>Egghead</td>
      <td>33</td>
      <td>blue</td>
      <td>
        <button id="select_1a" aria-label="Select Alice">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Bob</th>
      <td>Smith</td>
      <td>43</td>
      <td>purple</td>
      <td>
        <button id="select_1b" aria-label="Select Bob">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Charlie</th>
      <td>Jones</td>
      <td>23</td>
      <td>yellow</td>
      <td>
        <button id="select_1c" aria-label="Select Charlie">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Denise</th>
      <td>Brown</td>
      <td>53</td>
      <td>green</td>
      <td>
        <button id="select_1d" aria-label="Select Denise">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Eloise</th>
      <td>Humperdink</td>
      <td>83</td>
      <td>red</td>
      <td>
        <button id="select_1e" aria-label="Select Eloise">Select</button>
      </td>
    </tr>
  </tbody>
</table>

Two-Column Label Function (First Name and Last Name) When Collapsed

<table class="deque-table-responsive" id="table2" border="1">
  <caption>
  Two-Column Label Function (First Name and Last Name) When Collapsed
  </caption>
  <thead>
    <tr role="row">
      <th scope="col" role="columnheader">First Name</th>
      <th scope="col" role="columnheader">Last Name</th>
      <th scope="col" role="columnheader">Age</th>
      <th scope="col" role="columnheader">Favorite Color</th>
      <th scope="col" role="columnheader">Select</th>
    </tr>
  </thead>
  <tbody>
    <tr role="row">
      <th scope="row" role="rowheader">Alice</th>
      <td>Egghead</td>
      <td>33</td>
      <td>blue</td>
      <td>
        <button id="select_2a" aria-label="Select Alice">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Bob</th>
      <td>Smith</td>
      <td>43</td>
      <td>purple</td>
      <td>
        <button id="select_2b" aria-label="Select Bob">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Charlie</th>
      <td>Jones</td>
      <td>23</td>
      <td>yellow</td>
      <td>
        <button id="select_2c" aria-label="Select Charlie">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Denise</th>
      <td>Brown</td>
      <td>53</td>
      <td>green</td>
      <td>
        <button id="select_2d" aria-label="Select Denise">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Eloise</th>
      <td>Humperdink</td>
      <td>83</td>
      <td>red</td>
      <td>
        <button id="select_2e" aria-label="Select Eloise">Select</button>
      </td>
    </tr>
  </tbody>
</table>

Row Summed Up Into a Button When Collapsed

<table class="deque-table-responsive" id="table3" border="1">
  <caption>
  Row Summed Up Into a Button When Collapsed
  </caption>
  <thead>
    <tr role="row">
      <th scope="col" role="columnheader">First Name</th>
      <th scope="col" role="columnheader">Last Name</th>
      <th scope="col" role="columnheader">Age</th>
      <th scope="col" role="columnheader">Favorite Color</th>
      <th scope="col" role="columnheader">Select</th>
    </tr>
  </thead>
  <tbody>
    <tr role="row">
      <th scope="row" role="rowheader">Alice</th>
      <td>Egghead</td>
      <td>33</td>
      <td>blue</td>
      <td>
        <button id="select_3a" aria-label="Select Alice">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Bob</th>
      <td>Smith</td>
      <td>43</td>
      <td>purple</td>
      <td>
        <button id="select_3b" aria-label="Select Bob">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Charlie</th>
      <td>Jones</td>
      <td>23</td>
      <td>yellow</td>
      <td>
        <button id="select_3c" aria-label="Select Charlie">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Denise</th>
      <td>Brown</td>
      <td>53</td>
      <td>green</td>
      <td>
        <button id="select_3d" aria-label="Select Denise">Select</button>
      </td>
    </tr>
    <tr role="row">
      <th scope="row" role="rowheader">Eloise</th>
      <td>Humperdink</td>
      <td>83</td>
      <td>red</td>
      <td>
        <button id="select_3e" aria-label="Select Eloise">Select</button>
      </td>
    </tr>
  </tbody>
</table>

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-collapsing section:


	function getTableName(table) {
	  var caption = table.querySelector('caption');
	  if (caption) {
	    return caption.innerText;
	  }
	
	  return table.getAttribute('aria-label') || 'unnamed table';
	}
	
	function makeTableResponsive(table) {
	  var labelDetails = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
	  var inflectionPoint = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 300;
	
	  var resizeTimeout;
	
	  function resizeThrottler() {
	    // ignore resize events as long as an handleResponsiveLogic execution is in the queue
	    if (!resizeTimeout) {
	      resizeTimeout = setTimeout(function () {
	        resizeTimeout = null;
	        handleResponsiveLogic();
	
	        // The handleResponsiveLogic will execute at a rate of 15fps
	      }, 66);
	    }
	  }
	
	  function handleResponsiveLogic() {
	    // handle the resize event
	    if (window.innerWidth < inflectionPoint) {
	      renderAsList();
	    } else {
	      renderAsTable();
	    }
	  }
	
	  var list = void 0;
	  var wrapper = document.createElement('div');
	  wrapper.classList.add('responsive-table-wrapper');
	
	  var notificationRegion = document.createElement('div');
	  notificationRegion.setAttribute('role', 'alert');
	  notificationRegion.setAttribute('aria-live', 'polite');
	  notificationRegion.classList.add('visuallyhidden');
	  wrapper.appendChild(notificationRegion);
	
	  function notify(message) {
	    notificationRegion.innerText = message;
	  }
	
	  var tableParent = table.parentElement;
	
	  tableParent.insertBefore(wrapper, table);
	  wrapper.appendChild(table);
	
	  function renderAsList() {
	    if (!list) {
	      var focusID = document.activeElement.id;
	      list = collapseTableToList(table, labelDetails);
	      wrapper.appendChild(list);
	      wrapper.removeChild(table);
	      notify('The data for ' + getTableName(table) + ' is now being rendered as a list.');
	      if (focusID) {
	        var focusTarget = document.querySelector('#' + focusID);
	        if (focusTarget) {
	          focusTarget.focus();
	        }
	      }
	    }
	  }
	
	  function renderAsTable() {
	    if (list) {
	      var focusID = document.activeElement.id;
	      wrapper.removeChild(list);
	      wrapper.appendChild(table);
	      list = null;
	      notify('The data for ' + getTableName(table) + ' is now being rendered as a table.');
	
	      if (focusID) {
	        var focusTarget = document.querySelector('#' + focusID);
	        if (focusTarget) {
	          focusTarget.focus();
	        }
	      }
	    }
	  }
	
	  window.addEventListener('resize', resizeThrottler);
	  handleResponsiveLogic();
	  return table;
	}
	
	function collapseTableToList(table, _ref) {
	  var _ref$labelColumns = _ref.labelColumns,
	      labelColumns = _ref$labelColumns === undefined ? [] : _ref$labelColumns,
	      _ref$labelFunction = _ref.labelFunction,
	      labelFunction = _ref$labelFunction === undefined ? function () {
	    return 'Row:';
	  } : _ref$labelFunction;
	
	  var output = document.createElement('div');
	  output.classList.add('deque');
	  output.classList.add('responsive-table-list-holder');
	
	  var caption = document.createElement('h3');
	  caption.innerText = getTableName(table);
	  output.appendChild(caption);
	
	  var headers = (0, _selectionUtils.queryAll)('th', table);
	  var rows = (0, _selectionUtils.queryAll)('tbody tr', table);
	
	  var list = document.createElement('ul');
	  list.classList.add('collapsed-table');
	  list.setAttribute('data-num-columns', headers.length);
	
	  rows.reduce(function (list, row) {
	    var labelData = [];
	    labelColumns.forEach(function (i) {
	      labelData.push(row.children[i]);
	    });
	
	    var header = labelFunction.apply(null, labelData);
	    var li = document.createElement('li');
	    var label = document.createElement('span');
	    label.classList.add('collapsed-table-header');
	    label.innerHTML = header.outerHTML ? header.outerHTML : header;
	    li.appendChild(label);
	
	    var sublist = document.createElement('ul');
	    sublist.classList.add('collapsed-table-content');
	
	    (0, _selectionUtils.queryAll)('th, td', row).forEach(function (cell, index) {
	      var contentColumns = [];
	      if (labelColumns.indexOf(index) === -1) {
	        contentColumns.push({ cell: cell, label: headers[index].innerHTML });
	      }
	
	      if (contentColumns.length > 0) {
	
	        contentColumns.forEach(function (datum) {
	          var item = document.createElement('li');
	
	          item.setAttribute('data-table-column-index', index);
	
	          var labelSpan = document.createElement('span');
	          labelSpan.innerHTML = datum.label;
	          item.appendChild(labelSpan);
	
	          var content = document.createElement('div');
	          content.innerHTML = datum.cell.innerHTML;
	          item.appendChild(content);
	          sublist.appendChild(item);
	        });
	      }
	      // preserves focus
	
	      list.appendChild(li);
	    });
	
	    if (sublist.children.length > 0) {
	      li.appendChild(sublist);
	    }
	
	    return list;
	  }, list);
	
	  output.appendChild(list);
	  return output;
	}
	

In the @selectionUtils section:


	function queryAll(selector, context) {
	  context = context || document;
	  return (0, _collectionUtils.toArray)(context.querySelectorAll(selector));
	}
	

In the @collectionUtils section:


	function toArray(arraylike) {
	  return Array.prototype.slice.call(arraylike);
	}
	

Required: Initialization JavaScript (with functionality specific to individual pattern instances):

var table1 = document.querySelector('#table1');
var table2 = document.querySelector('#table2');
var table3 = document.querySelector('#table3');

var partialLabelDetails = {
  labelColumns: [0, 1],
  labelFunction: function(firstName, lastName) {
    return firstName.innerText + ' ' + lastName.innerText;
  }
};

var totalLabelDetails = {
  labelColumns: [0, 1, 2, 3, 4],
  labelFunction: function(firstName, lastName, age, favoriteColor, summary) {
    var label = firstName.innerText + ' ' + lastName.innerText + ' is ' + age.innerText + ' years old and likes the color ' + favoriteColor.innerText;
    let sourceButton = summary.querySelector('button');
    let button = document.createElement('button');
    button.setAttribute('aria-label', 'select ' + label);
    button.id = sourceButton.id;
    button.innerText = label;

    // note we are returning a new button - if you start mutating
    // the original button those mutations would carry over to the
    // table view, too. Best not to do that, but YMMV.
    return button;
  }
};

deque.tables.makeTableResponsive(table1, {}, 700);
deque.tables.makeTableResponsive(table2, partialLabelDetails, 700);
deque.tables.makeTableResponsive(table3, totalLabelDetails, 700);

function activateAllResponsiveTables(){
  var responsiveTables = document.querySelectorAll('.deque-table-responsive');
  for(var i=0; i < responsiveTables.length; i++){
    makeTableResponsive(responsiveTables[i]);
  }
}
activateAllResponsiveTables();

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):


.responsive-table-wrapper table {
  text-align: left;
  border-collapse: collapse;
  margin: 0 0 40px;
}
.responsive-table-wrapper table caption {
  text-align: left;
  font-size: 1.25rem;
  margin: 20px 0;
}
.responsive-table-wrapper table thead th {
  padding: 6px;
  text-align: left;
}
.responsive-table-wrapper table thead tr {
  border-top: 1px solid #5e5e5e;
  border-bottom: 1px solid #5e5e5e;
  height: 50px;
}
.responsive-table-wrapper table tbody tr:last-child {
  border-bottom: 1px solid #5e5e5e;
}
.responsive-table-wrapper table tbody tr button {
  margin: 8px;
}
.responsive-table-wrapper table tbody tr td,
.responsive-table-wrapper table tbody tr th {
  padding: 6px;
}
.responsive-table-wrapper table tbody tr:nth-child(odd) {
  background: #e3e3e3;
}

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"></li>

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>

Step 2: Add HTML

Create a table with:

  • A <caption> element to give the table a name
  • Proper table header structure (<th> with scope="col" and/or scope="row" as appopriate).

Step 3: Add JavaScript

The makeTableResponsive function takes three arguments:

  1. The table ID.
  2. The output of a function that designates how each row is to be labeled once it is converted to a list.
    1. If the output of the function is a text string, each row will be represented as nested lists under their respective parent list item labels.
    2. If the output of the function is an element (like a <button>, <div>, <span>, etc.), the resulting list will be only one level, without nested lists, because the entire row will be represented by that one element.
    3. If you supply an empty set of braces {} instead of the output of a function, the script will create nested lists for each row, with a parent list item that says "Row."
  3. The maximum width breakpoint of the table, in pixels.