See the grid in action here
Introduction
This article describes how to create an HTML5 grid from scratch. It explores a technique where the grid skeleton is created with placeholders that are then populated on demand. The advantage of writing one yourself is that you are in full control of what your grid is capable of. If anything needs changing or adding then this can be done in a matter of hours rather than days as I found out our over the last few years.
Have a closer 'live' look at the grid here. Source code is available on GitHub as BlueSkyGrid.
Background
The idea of building your own control(s) is a hotly contested one. Especially a grid which is perceived as a complex control falls in this category.
Whilst it's true that buying a ready made grid gets you a lot of functionality 'out of the box' it does come at a price. We found that if there is anything you want that is not provided by the grid natively you are stuck. Yes, one can buy the source code and try to extend it but that is often not that easy. It also needs to be managed when new versions or updates are released by the vendor.
Don't get me wrong, there are advantages to buying a grid off the shelf, it can be up and running quickly and it probably plays well with some other components (from the same vendor though).
So if you are willing to compromise then buying a grid might be for you.
However, as I will demonstrate in this article, writing your own grid is actually not as hard as you might think. On top of that you get exactly what you want and if anything isn't working you can find and fix it quickly yourself rather than endlessly checking the vendors FAQ's and forums where people may have 'work-rounds' which can break when new versions are released.
Grid Features
The grid currently provides the following features:
- Column resizing,
- Column reordering,
- Sorting,
- Paging,
- Flexible Column - a single column that fills up any white space in the grid. (resize the flexible column to disable it from flexing, simply double click on the column resizer to activate this feature again!)
- Currencies
It currently lacks:
- Filters (however, one can restrict the data given to the grid for now!)
Create your first grid
You have a DOM element that you wish to insert a grid into. So first define a set of Column Definitions to represent the data you wish to display. The ColDefs inform the grid what, where and how to display it's data. Note that more data can be given but simply won't be shown if no matching ColDef is found. We also define (optionally) some CurrencyInfo objects so the grid will have a way to lookup any currency symbols for fields that may require this.
The code below is defined in your client code from where you create and manage your grid.
__cm.setCurrencyInfo(new __cm.CurrencyInfo("GBP", "British Pound", "£", "GBP", ""));
__cm.setCurrencyInfo(new __cm.CurrencyInfo("USD", "US Dollar", "$", "USD", ""));
__cm.setCurrencyInfo(new __cm.CurrencyInfo("EUR", "Euro", "€", "EUR", ""));
this._coldefs = [];
this._coldefs.push(new __gc.ColDefinition("code", "Code", 100, "", "", "", "asc"));
this._coldefs.push(new __gc.ColDefinition("fullname", "Fullname", -1));
this._coldefs.push(new __gc.ColDefinition("county", "County", 110, "", "", "", "", "", true));
this._coldefs.push(new __gc.ColDefinition("currency", "Currency", 90, "", "", "center"));
this._coldefs.push(new __gc.ColDefinition("valuation", "Valuation", 110, "number", "0,0.00", "right", "", "currency"));
this._coldefs.push(new __gc.ColDefinition("price", "Price", 110, "number", "0,0.00", "right", "", "currency"));
this._coldefs.push(new __gc.ColDefinition("myimage", "Img", 50, "image", "", "center"));
this._coldefs.push(new __gc.ColDefinition("created", "Created", 150, "date", "", "center"));
Let's take a closer look at the 'Price' row. This row defines a ColDefinition for a property called 'price' on the given data rows. In this case we specify that the header should display 'Price', have a width of 110 pixels, tell the column it is numeric and have formatting applied ('0,0.00') through the use of numeral.js. If the field was of type 'date' it calls on moment.js for its formatting. The column should also be right aligned and look at the 'currency' data-field for its currency sign. Supplying a currency column lookup will force the grid to look-up the equivalent sign and display it.
Now that we have a set of ColDefinitions we find the DOM element to place the grid within, create a GridController and ask for the grid skeleton to be created (as shown in the code snippet below).
Now the grid is fully created and ready to accept data we create some sample data and hand this over to the grid instance (together with the coldefs).
Note that in this example we supply the ColDefs array together with the data itself after the grid is created. This shows that you can send in different data at any time as long as it is accompanied by a set of matching ColDefs!
var $grid = $('.mygrid');
this._gc = new __gc.GridController();
this._gc.createGrid($grid);
var data = __data.generateSampleData(rowcount);
this._gc.setData(data, this._coldefs).done(function () {
...
});
...
That's all there is to it to have the grid placed in your placeholder element.
Note that the call to setData returns a jQuery promise informing the caller when the creation of the grid is complete.
The code
There is too much code to describe the grid code in detail so in this article I will discuss the core principles and highlight some code parts that are of key interest. I recommend copying the zip file or check out this code on github for a deeper understanding.
Note that I come from a C# background and therefore have embraced Typescript like a long lost child. It allows me to stay in an OO thinking mode, easily derive from other classes or implement interfaces, have type safety and tons of other good stuff!
As mentioned, the full source code can be found on GitHub and a working example in all its glory can be seen on my far from finished blog site. I will edit and enhance this grid further over time if there is enough interest.
Grid Construction
The core idea of creating an instance of this grid is that it is constructed in code and injected into a target div using the 'createGridStructure' function. This function builds up a string in code which gets converted into a DOM element (using jQuery) which is then injected into the awaiting target element.
Handles to the $header and $datarows sections are then obtained after which these sections are populated. Again, first in code then injected into each of their placeholders (createHeader and createRows respectively).
The grid is based on three pieces of information, the actual data, an array of Column Definitions describing the columns that show this data and the target DOM element that we will inject the finished grid into.
The Column Definitions describe each column in terms of what data field to use from the datarow, the header, formatting, alignment, it's type (string, number, date, image etc) and more.
An overview of the core classes used to generate this grid are shown below:
DataEngine.ts
An instance of the DataEngine is created to separate the concerns of controlling grid actions and data manipulation. The job of the DataEngine is to hold onto the original data and manage a set of perpared data that is called upon by the Controller when it builds up the rows. This can be extended later to include a pipeline of filtering, grouping and/or editing of data.
GridController.ts
The GridController.ts focusses on creating all HTML that is injected into the given DOM element from scratch. This means that all these rows are recreated on re-sorting and column-reorder. On Column-resize all effected cells are adjusted in code so is faster. Re-creating all rows is not the end of the world as I found (1000 rows is still sub-zero seconds)
CurrencyMananger.ts
When a ColDefinition identifies a currency lookup field the currencymanager is presented with the value of that field (like 'USD') and returns its symbol ('$') which is then used as part of the formatting of the cell.
All grid cells within a single row are placed next to one another using css flex-boxes. I've put together some useful css classes calling on css-flexboxes that I use in many places where I have several div's within a parent div and one of these div's needs to fill up any available space.
Check the example below - there is a parent div (that can vary in height) which contains two fixed height div's on top and one at the bottom, I then want any remainder to be filled up. All there is to make this work is to set the parent class to 'flex-parent-col' and have one of the children set it's class to 'flex-child', that's all. There is a 'flex-parent-row' for the row equivalent.
The grid-row is also build upon this structure. The row itself is decorated with a class 'flex-parent-row' and one of its children will have set its class to 'flex-child' to fill up available space.
GridController.ts
All the following code samples are held within the GridController.ts
Below is the code that shows the creation of the core structure of the grid. It produces a string that is then converted to a DOM element and injected into the relevant placeholder.
private createGridStructure(showBorder: boolean): string {
var s: string = "";
var s: string = "";
s += "<div class='bs-grid flex-parent-col " + (showBorder ? "border" : "") + "' >";
s += " <div class='spinner' style='display: none'}> <div> <i class='fa fa-spinner fa-spin fa-3x'></i> </div> </div>"
s += " <div class='header-containment'> </div>"
s += " <div class='header' >"
s += " <div class='resize-marker' title='resize this column or double-click to make it the flex-column'> </div>"
s += " <div class='insert-marker'>";
s += " <i class='down fa fa-caret-down fa-2x'></i>";
s += " </div>";
s += " <div class='header-template' style='position: relative'> </div>"
s += " </div>";
s += " <div class='data-scrollable flex-child flex-scrollable' >";
s += " <div class='data'> </div>";
s += " <div class='full-resize-marker'> </div>"
s += " </div> "
s += " " + this.createPager();
s += "</div>";
return s;
}
private createPager(): string {
var s: string = "";
s += " <div class='pager'>"
s += " <button type='button' class='btnFirstPage pager-button'> <i class='fa fa-step-backward' title='First page'></i> </button> ";
s += " <button type='button' class='btnPrevPage pager-button'> <i class='fa fa-caret-left fa-lg' title='Previous page'></i> </button> ";
s += " <span class='pager-text' style='margin: 0px 4px 0px 4px'></span>";
s += " <button type='button' class='btnNextPage pager-button'> <i class='fa fa-caret-right fa-lg' title='Next page'></i> </button> ";
s += " <button type='button' class='btnLastPage pager-button'> <i class='fa fa-step-forward' title='Last page'></i> </button> ";
s += " <span style='margin: 0px 2px 0px 20px'>page size: </span>";
s += " <select class=page-size title='Number of rows per page'></select>";
return s;
}
And is called as such:
...
var grid = this.createGridStructure(showBorder);
this.$grid = $(grid);
this.$grid.appendTo($el);
this.$header = $('.header-template', this.$grid);
this.$datarows = $('.data', this.$grid);
this.$pager = $('.pager', this.$grid);
this.$resizemarker = $('.resize-marker', this.$grid);
this.$resizeline = $('.full-resize-marker', this.$grid);
this.$insertmarker = $('.insert-marker', this.$grid);
this.$sgloading = $('.spinner', this.$grid);
this.$datascrollable = $('.data-scrollable', this.$grid);
...
So at first we call 'createGridStructure' which returns the entire make up of the grid as a string. We then convert this string into a jQuery DOM element and append it to the given DOM element.
Voila, our grid has structure and is placed in the client DOM tree.
We then get handles on some important element like the header, the data-area, resizemarker and various other elements that we wish to control. Remember that at this point we still only have the skeleton outlay so let's create the header and rows sections now.
The creation of the header and datarows follow the same pattern as the grid skeleton. First we build up the string representing the complete header (or data-rows) then we create real DOM element(s) out of these and inject them into our placeholders ($header or $datarows respectively).
Let's have a look at the code constructing this header:
private createHeader(): string {
var self = this;
var headerTemplate: string = "<div class='row-header flex-parent-row' > ";
$.each(this.colDefinitions, function (index, coldef: ColDefinition) {
var cssclasses: string = "cell-header cell-right-column ";
cssclasses += coldef.classAlign + " ";
if (coldef.isFlexCol) cssclasses += "stretchable ";
var colName = coldef.colName.toLowerCase();
var hitem: string = "";
hitem += "<div class='" + cssclasses + "' data-sgcol='" + colName + "' style='width: " + coldef.width + "px;'>";
hitem += "<span>" + coldef.colHeader + "</span>";
hitem += "<span class='" + self.getSortSymbol(coldef) + "'></span>";
if (coldef.isFlexCol)
hitem += "<i class='pull-right fa fa-arrows-h' style='background-color: transparent; color: rgb(201, 201, 208);' title='this column is flexible sized'></i>";
hitem += "</div>";
headerTemplate += hitem;
});
headerTemplate += "</div>";
return headerTemplate;
}
This is then called upon as such:
var header = self.createHeader();
$(header).appendTo(self.$header);
And we have a header!
Then the same applies to the actual data rows:
private createRows(): string {
var self = this;
var s = "";
for (var i = 0; i < self.dataEngine.baseDataPrepared.length; i++) {
var dataItem: any = self.dataEngine.baseDataPrepared[i];
s += " <div class='row flex-parent-row' data-pkvalue='" + dataItem.pkvalue + "' >";
self.colDefinitions.forEach(function (coldef: ColDefinition) {
var myvalue = dataItem[coldef.colName];
myvalue = self.getFormattedValue(myvalue, dataItem, coldef);
var styleProp: CellStyleProperies = self.cbStyling(coldef, dataItem);
s += "<div class='cell cell-right-column " + (coldef.isFlexCol ? "stretchable" : "") + " " + coldef.classAlign + "' ";
s += "data-sgcol='" + coldef.colName + "' style='width: " + coldef.width + "px; ";
if (styleProp.cellBackColour)
s += " background-color: " + styleProp.cellBackColour + "; ";
if (styleProp.cellForeColour)
s += " color: " + styleProp.cellForeColour + "; ";
s += "'>";
if (coldef.mergeWithImage && styleProp.imgName)
s += "<div class='merged-image " + coldef.colAlign + "' style='background-color: " + styleProp.imgBackColour + "; color: " + styleProp.imgForeColour + ";'> <i class='fa " + styleProp.imgName + "' > </i> </div>"
if (coldef.colType == "image") {
var faImage = styleProp.imgName;
if (faImage.length == 0) faImage = myvalue;
s += "<i class='fa " + faImage + "'\"></i>";
}
else
s += myvalue;
s += " </div>"
});
s += " </div>";
}
return s;
}
And again is called like this:
var rows = self.createRows();
$(rows).appendTo(self.$datarows);
Few things to note here. First, we iterate through our actual (prepared) dataitems and for each dataitem we iterate through the ColDefinitions array which then gives us enough information to define each individual cell.
When defining a cell we first push the raw value through a formatter that returns the formatted value. We then decorate the cell by assigning any appropriate classes as well as setting the width.
Cell Styling
To allow the client to define custom styling per cell we check if a custom styling function was given. If so, we call this with the current ColDef and the actual datarow allowing the client to make a decision having all available data at hand.
The idea is that for each cell the client creates a CellStyleProperties object that will be used when defining the cell by the GridController. Properties that can be set include back and foreground colours, images and others.
To see how custom styling of each cell works let's have a look at the following code (which is defined in the client code class):
this._gc.cbStyling = function (coldef: __gc.ColDefinition, item: any) {
var styleProp: __gc.CellStyleProperies = new __gc.CellStyleProperies();
if (coldef.colName == "county") {
if (item["county"] == "Kent" && Math.floor(item["price"]) % 2 == 0)
styleProp.cellBackColour = "rgb(178, 232, 178)";
if (Math.floor(item["price"]) % 4 == 0)
styleProp.imgBackColour = "rgb(255, 196, 8)";
if (item["county"] == "Sussex")
styleProp.imgForeColour = "red";
}
if (coldef.colName == "myimage") {
if (item["price"] < 250)
styleProp.imgName = "fa-floppy-o";
else if (item["price"] < 500)
styleProp.imgName = "fa-warning";
}
...
...
return styleProp;
};
The function is called for each cell as they are constructed allowing us a high degree of customisation.
Grid Images
You may have noticed that my preferred way of getting images into the grid is by using font-awesome images. Since these work by decorating your elements with specific classes they are ideal for a grid constructed in code.
A word on Event handling
After we have created the grid skeleton and have obtained its placeholders we attach handlers to some of these. These include click handlers for the navigation buttons, row click and double click and many more.
The above mentioned event handlers are connected as a 'one-off' since the skeleton if not recreated. The grid-header is different in that it is recreated frequently. Therefore the code that attaches the draggable and droppable jQuery-ui interactions allowing us to move columns is placed in a separate function and is called each time the header is created.
Selecting a row
In order to support the ability to select a row the DataEngine ensures that a property called 'pkvalue' is attached to each generated row. This unique value is placed in each DOM row with a data-attribute called 'data-pkvalue' so that when a row is clicked jQuery can easily identify which row this was and add a 'selected' class to the row element.
Ajax
In this article the grid received all data which is held and controlled by the DataEngine from the start. Even though javascript seems perfectly capable holding on and processing large amounts of data clearly there is nothing stopping you extending this DataEngine and call back to the server for paged data. Just ensure you use a JQueryPromise<T> as a return object from the 'refresh' call to the Engine and adjust your code accordingly.
Closing word
This article has hopefully demonstrated that creating your own grid is a do-able proposition. With the grid in its current state it really is not difficult to add more functionality. If there is enough interest I will enhance the grid to expose functionality such as filtering, formula columns and perhaps ajax calls?
For my employer I have created a similar grid that has many more features that uses Knockout bindings for many properties and functions like populating cell content, width, order, dynamic updates to the data and much more. Here I tried to stay clear of knockout bindings to keep things simple and have everything done through code.
Please do have a look at the GitHub repository where the source code is held and feel free to fork the code and/or contribute to the project!
Points of interest
At first I was sceptical to what extend I could push the browser and JavaScript to callback that many times and to do that much DOM maniupulations on the fly but suffice to say that JS and the compilers/optimisers seem more than capable of handling this! One has to give the JavaScript compilers and optimisers much credit for this!!
History
vs 1.0 - initial release.
vs 1.1 - update to domain reference of demo grid