Fixing Column Headers Using Angular
The background
Presenting large quantities of tabulated data in such a way as to allow the user to retain the context and meaning of the data is a common issue. Here at cap hpi we have large amounts of data which need to be easily reviewed by our expert users for quality control purposes.
To this end, we have implemented a method of fixing column headers and rows in tables of data, allowing users to retain column and row headers on screen whilst being able to scroll through large datasets.
Shuhei Kagawa’s blog post Freeze panes with CSS and a bit of JavaScript gives a neat and concise example of the concept of fixing column headers and rows, making use of CSS and JavaScript to achieve the result. We were keen to implement this into our application, however as we use Angular, we decided to translate the solution into an Angular (v1.x) directive.
See it in action
HTML
Looking at the HTML, you can see that the solution has retained the neat and concise nature of the original:
<!DOCTYPE html> <html> <head> <script data-require="angular.js@1.5.6" data-semver="1.5.6" src="https://code.angularjs.org/1.5.6/angular.min.js"></script> <link rel="stylesheet" href="style.css" /> <script src="app.module.js"></script> <script src="app.controller.js"></script> <script src="fixedColumnTable.directive.js"></script> </head> <body ng-app="app" ng-controller="AppCtrl as vm"> <h1>Fixed Header / Columns Directive</h1> <div class="container" fixed-column-table fixed-columns="3"> <table> <thead> <tr> <th>Id</th> <th>Make</th> <th>Model</th> <th>Engine</th> <th>Power</th> <th>Transmission</th> <th>CO2</th> <th>RRP</th> <th>OTR</th> </tr> </thead> <tbody> <tr ng-repeat="car in vm.cars"> <td>{{car.id}}</td> <td>{{car.make}}</td> <td>{{car.model}}</td> <td>{{car.engine}}</td> <td>{{car.power}}</td> <td>{{car.transmission}}</td> <td>{{car.co2}}</td> <td>{{car.rrp}}</td> <td>{{car.otr}}</td> </tr> </tbody> </table> </div> <p> This AngularJS directive was created from Vanilla JS and CSS found in the article <a href="http://shuheikagawa.com/blog/2016/01/11/freeze-panes-with-css-and-a-bit-of-javascript" target="_new">Freeze panes with CSS and a bit of JavaScript</a> </p> </body> </html>
Notice the fixed-column-table directive attribute on the div element, passing in the number of columns as a parameter (fixed-columns).
Directive
Now, looking at the code for the actual directive:
(function () { 'use strict'; angular .module('app') .directive('fixedColumnTable', fixedColumnTable); fixedColumnTable.$inject = ['$timeout']; function fixedColumnTable($timeout) { return { restrict: 'A', scope: { fixedColumns: "@" }, link: function (scope, element) { var container = element[0]; function activate() { applyClasses('thead tr', 'cross', 'th'); applyClasses('tbody tr', 'fixed-cell', 'td'); var leftHeaders = [].concat.apply([], container.querySelectorAll('tbody td.fixed-cell')); var topHeaders = [].concat.apply([], container.querySelectorAll('thead th')); var crossHeaders = [].concat.apply([], container.querySelectorAll('thead th.cross')); console.log('line before setting up event handler'); container.addEventListener('scroll', function () { console.log('scroll event handler hit'); var x = container.scrollLeft; var y = container.scrollTop; //Update the left header positions when the container is scrolled leftHeaders.forEach(function (leftHeader) { leftHeader.style.transform = translate(x, 0); }); //Update the top header positions when the container is scrolled topHeaders.forEach(function (topHeader) { topHeader.style.transform = translate(0, y); }); //Update headers that are part of the header and the left column crossHeaders.forEach(function (crossHeader) { crossHeader.style.transform = translate(x, y); }); }); function translate(x, y) { return 'translate(' + x + 'px, ' + y + 'px)'; } function applyClasses(selector, newClass, cell) { var arrayItems = [].concat.apply([], container.querySelectorAll(selector)); var currentElement; var colspan; arrayItems.forEach(function (row, i) { var numFixedColumns = scope.fixedColumns; for (var j = 0; j < numFixedColumns; j++) { currentElement = angular.element(row).find(cell)[j]; currentElement.classList.add(newClass); if (currentElement.hasAttribute('colspan')) { colspan = currentElement.getAttribute('colspan'); numFixedColumns -= (parseInt(colspan) - 1); } } }); } } $timeout(function () { activate(); }, 0); scope.$on('refreshFixedColumns', function () { $timeout(function () { activate(); container.scrollLeft = 0; }, 0); }); } }; } })();
The conversion process was very straightforward, the only real gotcha encountered was the need to wrap the activate() function in a timeout, as shown below:
$timeout(function () { activate(); }, 5);
If the data in the table is changed, for example a new item is added into the collection, it is necessary for the activate() function to be re-run to ensure that the appropriate classes and events are wired up for the new items added. To achieve this, there is a broadcast listener; this listens for an event and re-runs the activate() function – in the example code, this is called refreshFixedColumns. Therefore, any code that modifies the data in the table needs to raise a refreshFixedColumns event at the point that the data is changed to ensure the table fixed columns continue to work:
scope.$on('refreshFixedColumns', function() { $timeout(function () { activate(); container.scrollLeft = 0; }, 5); });
Styling
Then there’s the CSS:
body { background: #ccc; } .container { width: 500px; height: 200px; border: 2px solid #444444; overflow: scroll; position: relative; } table { table-layout: fixed; border-collapse: separate; border-spacing: 0; position: absolute; width: 100%; } th, td { border-right: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 5px 10px; width: 100px; box-sizing: border-box; margin: 0; } td { background: #fff; } th, .fixed-cell { background: #eee; } .cross { position: relative; z-index: 1; }
And that’s all there is to it!
It retains the same compatibility and performance of the original solution and allows us to simply fix as many column headers as required to support our QC process; we can apply this technique to as many different tables as necessary simply and re-useably throughout our Angular applications.