JS Table Layout Adjustment: Naming


Published: 2016-02-06
Updated: 2016-05-25
Web: https://fritzthecat-blog.blogspot.com/2016/02/js-table-layout-adjustment-naming.html


To cope with something means to live together with something unknown and seemingly uncontrollable. We don't need to control everything, but we also don't want to suffer from consequences of incalculable risks.

One thing we can do when we have a problem is giving it a name. Practically that name should make it more comprehensible. When we can understand it, we can cope with it.

This is not a self-help-group-instruction. This is about a web-page layout-problem solved by giving names. Although these are different things, I would consider the naming-activity being more important in this than the layout-solution. Remember:

We can cope with things that got a name.
And it will be fine when it is a meaningful name.

Layout Problem

This Blog is about HTML tables nested into other tables. The nested tables are not related to each other, thus their column widths will be different. Such layout does not look good. The JavaScript to be introduced in the following tries to fix that. (Mind that you won't have this problem when you never embed tables into other tables.)

Here is an example of what we have to cope with:

Date
ProductPriceNote
2016-02-05
Chocolate for nothing! Was a gift
Bread $ 2.50, or was it $ 3.00 ?
2016-02-06
Vegetables $ 5.30 Bought on market
Fish $ 7.80 I do not eat salmon any more
Free afternoon, went for dinner with friends
2016-02-07
Fruit $ 2.60 Fresh and tasty

In this example table, all tables have a green border, header cells a blue, data cells a magenta. Tables of nesting-level 1 are yellow. Table cells that have a colspan attribute are rendered orange.

Sure, all data are there and readable. But we can hardly associate the columns in the different nested tables to each other. For example, try to sum up all prices.

The fix won't be just for the eye, it'll be for avoiding human mistakes. When we talk about shape and content, we should be aware that we need them both and together. As is HTML.

Synopsis

The introduction of the JavaScript solving this layout problem will be divided into several Blogs. This one is the first, and it is about naming cells being logically (but sometimes not visually) below each other.

The second one will be about adjusting these cells to have same widths. The column-width will be the initial width of the widest cell in it.

The third Blog will be about script extensions to achieve an elastic column in a 100% stretched table, and how to do the same for a DIV table.

All JS scripts will be modules, and I always use functional inheritance to reuse JS code in a simple and safe manner. No jQuery is used here, nevertheless the code works even in IE-9.

For a more compound test page and full JS source code you can visit my homepage.

Naming the Unknown

When I want to size all cells of one logical column to the same width, I have to cope with table-cell elements being somewhere in a tree of HTML elements, most likely quite far away from each other. To be able to size them, I give names to them. My naming scheme is the well-known chapter numbering system: 1 for first chapter, 1.1 for first sub-chapter of first chapter, 1.1.2 for second sub-sub-chapter of first sub-chapter, and so on. I will call these names categories, because they won't be unique identifiers for cells, but more a category a cell falls into.

Transferring this numbering system to the cells of a table containing nested tables, I would give 1 to all cells in first column, 2 to all cells in second column, and so on. To all cells in first column of a table nested into a cell 2, I would give 2.1. Would there be another table in that cell, its first cell would have the category 2.1.1.

1
2.1 2.2
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3
2.1 2.2
2.3.1 2.3.2 2.3.3
1
2.1 2.2
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3
2.3.1 2.3.2 2.3.3

As you see, all cells that must have same widths are named by the same category (dotted number). The count of dots in the category reflects the nesting level, and the numbers give the column order index.

You could immediately write some CSS now to set fixed widths to the columns categorized in that way. But, in my opinion, JS solutions are more sophisticated and reusable, so I will do it with JS.

Abstract Categorizer

As the table nesting is not restricted to any depth, the JS implementation should work recursive. Here is a JS module that categorizes a given table. It is called "abstract" because it does not know yet the nature of the HTML tags it should look for. It just traverses the HTML table and names elements. A concrete extension of that module will add functions that decide which HTML elements should be looked for.

  1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
    /**
* Usage: elementCategorizer().init(arrayOfTables);
* @param CATEGORY_ATTRIBUTE_NAME optional name of the attribute where to put the
* category into, default is "data-layout-category".
*/
var abstractCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
{
"use strict";

CATEGORY_ATTRIBUTE_NAME = CATEGORY_ATTRIBUTE_NAME || "data-layout-category";

var buildDottedNumber = function(topElement, element, index) {
var dottedNumber = ""+(index + 1); /* start numbering at 1 */
var parent = element.parentElement;

while (parent !== topElement) { /* search for parent's dotted number */
var parentCategory = that.getCategory(parent);
if (parentCategory)
return parentCategory+"."+dottedNumber;

parent = parent.parentElement;
}
return dottedNumber;
};

var categorizeElement = function(topElement, element, index, levelArraysMap) {
var predecessor = (index > 0) ? element.parentElement.children[index - 1] : undefined;
var span = that.getSpan(predecessor);
if (span > 1) /* when predecessor has colspan, increment index */
index += (span - 1);

var dottedNumber = buildDottedNumber(topElement, element, index);
element.setAttribute(CATEGORY_ATTRIBUTE_NAME, dottedNumber);

var level = dottedNumber.split(".").length - 1;
if (levelArraysMap[level] === undefined)
levelArraysMap[level] = [];

levelArraysMap[level].push(element);
return index;
};

var categorize = function(topElement, element, index, levelArraysMap) {
/* build categories top-down, to enable parent category retrieval. */
if (that.isElementToCategorize(element))
index = categorizeElement(topElement, element, index, levelArraysMap);

var children = element.children; /* go recursive */
var childIndex = 0;
for (var i = 0; i < children.length; i++) {
if (that.isVisible(children[i])) {
childIndex = categorize(topElement, children[i], childIndex, levelArraysMap);
childIndex++;
}
}
return index;
};

var that = {};

/* public functions */

/** @return the next after the given dotted number (category). */
that.nextCategory = function(dottedNumber) {
var head = "";
var tail = dottedNumber;
var lastDotIndex = dottedNumber.lastIndexOf(".");
if (lastDotIndex > 0) {
head = dottedNumber.substring(0, lastDotIndex + 1);
tail = dottedNumber.substring(lastDotIndex + 1);
}
return head+(window.parseInt(tail) + 1);
};

/** @return the category of given element, can be undefined. */
that.getCategory = function(element) {
return element.getAttribute(CATEGORY_ATTRIBUTE_NAME);
};

/** @return true when the categorized elements can be found below given one. */
that.containsCategorizedElements = function(element) {
return element.querySelector("["+CATEGORY_ATTRIBUTE_NAME+"]") !== null;
};

/** @return true when given criterion starts with startPart, considering dots. */
that.startsWith = function(criterion, startPart) {
if (criterion === startPart)
return true; /* equal */

if (criterion.length <= startPart.length)
return false; /* same length but not equal */

var part = criterion.substring(0, startPart.length);
if (part === startPart && criterion.charAt(startPart.length) === '.')
return true; /* excluded "1.22" matching "1.2" */

return false;
};

/** @return true if given element's display style is different from "none". */
that.isVisible = function(element) {
return element && window.getComputedStyle(element).display !== "none";
};

/**
* Categorize elements below given topElement.
* @param topElement required, the element where to categorize below.
* @return map of arrays of elements found in topElement,
* key is level 0-n,
* value is array of categorized elements on that level.
*/
that.init = function(topElement) {
if (topElement === undefined)
throw "Need a top-element to categorize!";

var levelArraysMap = {};
categorize(topElement, topElement, 0, levelArraysMap);
return levelArraysMap;
};

return that;
};

Mind that two functions are not yet implemented:

The isElementToCategorize() function will restrict the module to HTML TABLE elements with TH and TD cells. But I want to apply that module also on tables built from DIV elements with CSS display: table. So I will implement concrete sub-classes of this, one for TABLE elements, one for DIV-table elements.

The colspan attribute is specific to TABLE, a DIV-table doesn't support that. The getSpan() implementation for TABLE will return the number in the colspan attribute, or 1 when not found, and the DIV-table implementation always will return 1.

The CATEGORY_ATTRIBUTE_NAME parameter lets set a name for the element attribute where the name (category) will be written into.

The buildDottedNumber() function receives the index of the element to name, and it ascends until it finds a parent-category. When it does not find one, the index alone will be the category, else it is appended to the parent-category.

The categorizeElement() function recognizes the colspan of a predecessor element, and corrects the index when one exists. It sets the built category into the element attribute. Then it calculates the nesting level from the number of dots in the category, and adds the element to an array in a level-map which is to return.

The categorize() function is the recursive traversal. First it categorizes the element received as parameter, then it calls itself recursively in a loop over all children (whereby the child-index will be part of the the generated category). Keeping that call-order, the children will always find parent-categories when calling buildDottedNumber().

The getCategory() function reads the category from an element. This is here to encapsulate CATEGORY_ATTRIBUTE_NAME. Mind that this is the only public function here, besides init(). No other function will need to be called or overridden from modules extending this one.

The init() function at last returns a map of levels. In each level (0-n, used as map-key) an array of elements on that level is stored. This return-map can be used to layout the table.

Now I can extend this module to implement a concrete table-column categorizer.

Concrete TABLE Categorizer


 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
    /**
* Concrete categorizer for HTML table columns.
*/
var tableColumnCategorizer = function(CATEGORY_ATTRIBUTE_NAME)
{
"use strict";

var that = abstractCategorizer(CATEGORY_ATTRIBUTE_NAME);

/* public functions */

/**
* @return true when given element should be categorized.
* This implementation returns true when element is
* TD (table cell) or TH (table header).
*/
that.isElementToCategorize = function(element) {
return element.tagName === "TD" || element.tagName === "TH";
};

/** @return the number of following elements the given one spans. */
that.getSpan = function(element) {
var colspan = element ? element.getAttribute("colspan") : undefined;
return colspan ? window.parseInt(colspan) : 1;
};

return that;
};

This adds the not-yet-implemented functions, and thus makes the abstract module concrete. The extension of the abstract module is done by the line

var that = abstractCategorizer(CATEGORY_ATTRIBUTE_NAME); 

This is like extending a class in Java. Just that in JS no classes exist, everything is an object. As a consequence you can extend modules even at runtime, which is quite useful in some situations.

The isElementToCategorize() function determines that TD and TH elements will be named (categorized).

The getSpan() function delivers the number of colums the given element spans. As I did not want to restrict this to colspan I named it getSpan(), because we also have rowspan, and basically the script is also able to categorize cells for adjusting row heights.

That's it! Functional inheritance helps to keep JS modules short and encapsulated. When you start the concrete module over a TABLE, all cells will be categorized.

In my next Blog I will show how this can be used to adjust table columns. Again there will be an abstractColumnAdjuster with concrete sub-modules.





ɔ⃝ Fritz Ritzberger, 2016-02-06