Building a JavaScript Component for Conditional Formatting in HTML Tables

This is my study of conditional formatting for HTML table cells without jQuery. After looking for a simple visual solution to table cell conditioning with vanilla JavaScript failed, I decided to create my own. While this will forever be a draft, I hope it can be the seed for a community initiative to bring better data representation to HTML tables.

Introduction

While working for a financial company, I spent a great deal of time staring at HTML tables filled with numbers. There was no way around this, as the main product for this company revolved around comparative performance of their products against its benchmark – all represented in numbers. However, being the visual person that I am, I often found it hard to compare hard numbers against each other. I needed to have the comparison visually represented, ideally through bold colors.

After spending some time searching for a simple solution to conditional formatting of table cells, I found that all of the solutions I came across, though simple, relied heavily upon jQuery. Furthermore, the solutions were not very flexible and required multiple instantiations when the same code could be reused. This led me to explore my own solution to this problem without relying on jQuery.

My first draft of this code relied much too heavily on if statements. It required me to take a step back and look at what the core functionality required. After some thought, I realized that I needed to rely more on scalable parsers. My ultimate goal is to provide a fully customizable solution to conditional formatting of HTML tables; the current solution is a step in that direction.

The Final Result

Data Group Sorted Numbers Numbers with Ties Some Numbers,
Some Not
Set 1 -6.73 -7.82 -12.16
Set 1 7.22 -7.82 text
Set 2 99.89 -4.75 8.33
Set 2 85.88 -4.75 4.79
Set 2 63.24 -4.75 text
Set 2 9.12 -4.75 2.30
Set 2 -8.22 -4.75 text
Set 3 -6.59 -10.92 9.32
Set 3 -5.67 -10.92 -2.99
Set 3 -4.68 5.06 text
Set 3 -3.87 5.00 4.21
Set 3 2.22 5.06 -3.89
Set 3 5.22 -4.38 text
Set 3 9.323 5.00 100.241
Set 3 11.54 5.06 0.11
Set 3 14.229 -4.38 4.5

The HTML

One of my goals was to modify the HTML as little as possible. To achieve this, I used the HTML5 ‘data’ attribute. This attribute allows the flexibility of grouping the elements while also not adding too much weight or obscure classes and identifiers. The developed component uses just one ‘data’ attribute with multiple associations to group together sets of comparable data. This allows the JavaScript to focus on core functionality and not worry about grouping together the rows of a table.

<table cellpadding="0" cellspacing="0">
    <thead>
        <tr>
            <th>Data Group</th>
            <th>Sorted Numbers</th>
            <th>Numbers with Ties</th>
            <th>Some Numbers,<br >Some Not</th>
        </tr>
    </thead>
    <tbody>
        <tr data-compare-group="group1">
            <td>Set 1</td>
            <td class="center">-6.73</td>
            <td class="center">-7.82</td>
            <td class="center">-12.16</td>
        </tr>
        <tr data-compare-group="group1">
            <td>Set 1</td>
            <td class="center">7.22</td>
            <td class="center">-7.82</td>
            <td class="center">text</td>
        </tr>
        <tr data-compare-group="group2">
            <td>Set 2</td>
            <td class="center">99.89</td>
            <td class="center">-4.75</td>
            <td class="center">8.33</td>
        </tr>
        <tr data-compare-group="group2">
            <td>Set 2</td>
            <td class="center">85.88</td>
            <td class="center">-4.75</td>
            <td class="center">4.79</td>
        </tr>
        <tr data-compare-group="group2">
            <td>Set 2</td>
            <td class="center">63.24</td>
            <td class="center">-4.75</td>
            <td class="center">text</td>
        </tr>
        <tr data-compare-group="group2">
            <td>Set 2</td>
            <td class="center">9.12</td>
            <td class="center">-4.75</td>
            <td class="center">2.30</td>
        </tr>
        <tr data-compare-group="group2">
            <td>Set 2</td>
            <td class="center">-8.22</td>
            <td class="center">-4.75</td>
            <td class="center">text</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">-6.59</td>
            <td class="center">-10.92</td>
            <td class="center">9.32</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">-5.67</td>
            <td class="center">-10.92</td>
            <td class="center">-2.99</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">-4.68</td>
            <td class="center">5.06</td>
            <td class="center">text</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">-3.87</td>
            <td class="center">5.00</td>
            <td class="center">4.21</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">2.22</td>
            <td class="center">5.06</td>
            <td class="center">-3.89</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">5.22</td>
            <td class="center">-4.38</td>
            <td class="center">text</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">9.323</td>
            <td class="center">5.00</td>
            <td class="center">100.241</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">11.54</td>
            <td class="center">5.06</td>
            <td class="center">0.11</td>
        </tr>
        <tr data-compare-group="group3">
            <td>Set 3</td>
            <td class="center">14.229</td>
            <td class="center">-4.38</td>
            <td class="center">4.5</td>
        </tr>
    </tbody>
</table>
                            
HTML

The CSS

While the CSS for the output above appears simple, the challenge here was determining how the JavaScript would use CSS to implement the changes. For certain results where blending of colors was needed and the number of rows was unknown, creating classes for each table cell would not be possible. With cell color blending as my basis for this project, I did not originally develop this to handle custom classes for certain attributes. This is an area where I hope the community can add value.

body table {
  width: 100%;
}
body table tr td,
body table tr th {
  padding: 8px;
  text-align: center;
  border-right: 1px solid #aaaaaa;
}
body table tr td.left,
body table tr th.left {
  text-align: left;
}
body table tr td.right,
body table tr th.right {
  text-align: right;
}
body table tr td:last-child,
body table tr th:last-child {
  border-right: none;
}
body table tr td:first-child,
body table tr th:first-child {
  text-align: left;
}
body table tr th {
  font-weight: bold;
  border-bottom: 3px solid #444444;
}
body table tr td {
  border-bottom: 1px solid #aaaaaa;
}
body table tr:last-child td,
body table tr.last-group td {
  border-bottom: 3px solid #444444;
}
                            
CSS

The JavaScript

The JavaScript is the backbone of the whole component. My desired pattern was to mimic a typical jQuery plugin, without the jQuery. This required some workarounds, although the final result is a lighter weight than including the jQuery library in its entirety.

I began with creating the default settings for the component. This helped to create a foundation from which I could build functions without worrying about variables changing or being overwritten. Once the core functions were written, I started pulling them apart to create functions that were reused within each of the more general functions to create a simpler appearance and more modularity. Lastly, I focused on customizing the experience for the user, with jQuery in mind. This required me to define certain features directly in the object passed through the function declaration. The challenge was to then make sure these customizations were passed to the settings and, more importantly, were overwriting the defaults.

;function compareTableData(options){

    "use strict";


    /* 
        Default settings and variables, can be overriden by user
    */
    var defaults = {
        // The HTML5 data attribute which groups the elements
        attribute : 'data-compare-group',
        // The default cell indexes for which to compare, 0 is the first cell
        cellIndex : [0], 
        colors : {
            low : 'rgb(255, 0, 0, 0.5)', // Any RGBA or HEX color
            high : 'rgb(0, 128, 0, 0.5)' // Any RGBA or HEX color
        },
        // A class added to each cell for easy removal of css styles
        comparedClass : 'compared',
        // The default parser uses numeric  
        parser : function(data) { 
            var arr = [];
            for (var i = 0; i < data.length; i++) {
                if (Number(data[i])) {
                    arr.push(data[i])
                }
            };
            function sorter(a,b) {
                return Number(a) > Number(b) ? true : false;
            }
            return arr.sort(sorter);
        },
        debug : false
    }


    /* 
        Commonly used functions
    */ 
    var utilities = {
        getGroupRows : function(group){
            if (!group) return document.querySelectorAll('table tbody tr[' + settings.attribute + ']')
            else return document.querySelectorAll('table tbody tr[' + settings.attribute + '=' + group + ']')   
        },
        getGroups : function(){
            var arr = [],
                tableRows = this.getGroupRows();
            for (var i = 0; i < tableRows.length; i++) {
                if (arr.indexOf(tableRows[i].getAttribute(settings.attribute)) == -1 ) {
                    arr.push(tableRows[i].getAttribute(settings.attribute))
                }
            };
            return arr;
        },
        colorStyles : function(percentage){
            var colorLow = settings.colors.low,
                colorHigh = settings.colors.high;
            /*
                All credits for the below function go to Pimp Trizkit of Stack Overflow for spending years writing a function to mathematically blend HEX and RGBA colors
                http://stackoverflow.com/users/693927/pimp-trizkit
            */
            function shadeBlendConvert(p, from, to) {
                if(typeof(p)!="number"||p<-1||p>1||typeof(from)!="string"||(from[0]!='r'&&from[0]!='#')||(typeof(to)!="string"&&typeof(to)!="undefined"))return null; //ErrorCheck
                var sbcRip=function(d){
                        var l=d.length,RGB=new Object();
                        if(l>9){
                            d=d.split(",");
                            if(d.length<3||d.length>4)return null;//ErrorCheck
                            RGB[0]=i(d[0].slice(4)),RGB[1]=i(d[1]),RGB[2]=i(d[2]),RGB[3]=d[3]?parseFloat(d[3]):-1;
                        }else{
                            switch(l){case 8:case 6:case 3:case 2:case 1:return null;} //ErrorCheck
                            if(l<6)d="#"+d[1]+d[1]+d[2]+d[2]+d[3]+d[3]+(l>4?d[4]+""+d[4]:""); //3 digit
                            d=i(d.slice(1),16),RGB[0]=d>>16&255,RGB[1]=d>>8&255,RGB[2]=d&255,RGB[3]=l==9||l==5?r(((d>>24&255)/255)*10000)/10000:-1;
                        }
                        return RGB;
                    },
                    i=parseInt,r=Math.round,h=from.length>9,h=typeof(to)=="string"?to.length>9?true:to=="c"?!h:false:h,b=p<0,p=b?p*-1:p,to=to&&to!="c"?to:b?"#000000":"#FFFFFF",f=sbcRip(from),t=sbcRip(to);
                if(!f||!t)return null; //ErrorCheck
                if(h)return "rgba("+r((t[0]-f[0])*p+f[0])+","+r((t[1]-f[1])*p+f[1])+","+r((t[2]-f[2])*p+f[2])+(f[3]<0&&t[3]<0?")":","+(f[3]>-1&&t[3]>-1?r(((t[3]-f[3])*p+f[3])*10000)/10000:t[3]<0?f[3]:t[3])+")");
                else return "#"+(0x100000000+(f[3]>-1&&t[3]>-1?r(((t[3]-f[3])*p+f[3])*255):t[3]>-1?r(t[3]*255):f[3]>-1?r(f[3]*255):255)*0x1000000+r((t[0]-f[0])*p+f[0])*0x10000+r((t[1]-f[1])*p+f[1])*0x100+r((t[2]-f[2])*p+f[2])).toString(16).slice(f[3]>-1||t[3]>-1?1:3);
            };

            return shadeBlendConvert(percentage, colorLow, colorHigh)
        },
        removeDuplicates : function(arr) {
            var temp = {};
            for (var i = 0; i < arr.length; i++) {
                temp[arr[i]] = true;
            }
            var r = [];
            for (var k in temp) {
                r.push(k);
            }
            return r;
        }
    }


    /* 
        Merges the defaults object with the options object given by the user
    */ 
    var extend = function(out) {
        out = out || {};
        for (var i = 1; i < arguments.length; i++) {
            var obj = arguments[i];

            if (!obj) continue;

            for (var key in obj) {
                if (obj.hasOwnProperty(key)) {
                    if (typeof obj[key] === 'object') extend(out[key], obj[key]);
                    else out[key] = obj[key];
                }
            }
        }

        return out;
    };


    /* 
        Declares the settings which should be used by the program
    */ 
    var settings = extend(defaults, options)


    /*
        Object used to compare each set of data
    */ 
    var compareDataSet = {
        // Defined with each new object creation
        group : undefined, 
        // Defined with each new object creation
        cell : undefined, 
        // Returns the rows within the defined group
        groupRows : function(){
            return utilities.getGroupRows(this.group)
        },
        // Returns the original cell data for the set to compare
        cellData : function(){
            var arr = [];
            var rows = this.groupRows();
            for (var i = 0; i < rows.length; i++) {
                arr.push(rows[i].children[this.cell].innerHTML)
            };
            return arr;
        },
        // Returns the cleaned cell data for the set to compare and makes sure it only has unique values
        cellDataCleaned : function() {
            return utilities.removeDuplicates(settings.parser(this.cellData()))
        },
        // Compares the original data to the sorted data to determine the sort order of the original data
        compare : function(){
            var origData = this.cellData(),
                cleanData = this.cellDataCleaned(),
                arr = [];

            for (var i = 0; i < origData.length; i++) {

                for (var j = 0; j < cleanData.length; j++) {
                    // The original data matches an instance in the new data
                    if (origData[i] == cleanData[j]) {
                        arr.push(j)
                        break;
                    }
                    // The original data does not match an instance in the new data because it was stripped out
                    else if (j+1 == cleanData.length){
                        arr.push(undefined)
                    }
                };
            };
            return arr;
        },
        // Sets the corresponding color for each cell
        getColor : function() {
            var comparedData = this.compare(),
                comparedLength = this.cellDataCleaned(),
                arr = [];
            for (var i = 0; i < comparedData.length; i++) {
                var percentage;

                if (!isNaN(comparedData[i])){
                    if (comparedLength.length > 1) {
                        percentage = comparedData[i]/(comparedLength.length-1)
                    }
                    else {
                        percentage = undefined;
                    }
                }
                else {
                    percentage = undefined;
                }
                arr.push(utilities.colorStyles(percentage))
            };
            return arr;
        },
        // Applies the color and classname to each cell
        updateHTML : function(){
            var coloredCells = this.getColor();
            var rows = this.groupRows();
            for (var i = 0; i < rows.length; i++) {
                var cell = rows[i].children[this.cell]
                    cell.style.backgroundColor = coloredCells[i]
                    cell.className += " " + settings.comparedClass
                
            };
        }
    }


    /*
        Base function that iterates through each of the compare sets
    */ 
    function compare(){
        var groups = utilities.getGroups(),
            cells = settings.cellIndex;
        if (settings.debug) console.log(settings); console.log(utilities); console.log(compareDataSet)
        for (var i = 0; i < groups.length; i++) {
            loopThroughGroup(cells)
        };

        function loopThroughGroup(cells){

            for (var j = 0; j < cells.length; j++) {
                var compareSet = Object.create(compareDataSet);
                compareSet.group = groups[i];
                compareSet.cell = cells[j];
                compareSet.updateHTML();

                if(settings.debug) {
                    console.log('Group = '+ groups[i] + '\n' +
                                'Cell Index = '+ cells[j] + '\n' +
                                'Group Cell Data = '+ compareSet.cellData() + '\n' +
                                'Group Cell Data Cleaned = '+ compareSet.cellDataCleaned() + '\n' +
                                'Group Cell Data Compare = '+ compareSet.compare() + '\n' +
                                'Group Cell Data Colors = '+ compareSet.getColor() + '\n'
                    );
                }
            };
        }
    }


    /*
        Initializes the module
    */ 
    compare();
    
}


/* 
    Initializes the component
*/
compareTableData({
    // The HTML5 data attribute which groups the elements
    attribute : 'data-compare-group', 
    // Points to which columns to compare
    cellIndex : [1,2,3], 
    colors : {
        low : 'rgb(255, 0, 0, 0.7)', // Any RGBA or HEX color
        high : 'rgb(0, 128, 0, 0.7)' // Any RGBA or HEX color
    },
    // A class added to each cell for easy removal of css styles
    comparedClass : 'compared', 
    // Helps to fix those pesky bugs 
    debug : false
});
                            
JavaScript

Changes I Hope to Make

While this code is still a working draft, I do have some changes I would ultimately like to make to see this code be even further customizable.

  • Modify the parser function to allow further customization
  • Add a sort customization, similar to a parser, so the two can be paired together
  • Add some built-in parsers, such as date, alphabetical text, and a simple text-to-range