Blog-Archiv

Dienstag, 4. Oktober 2022

Collapsible Columns in HTML Table with ES6

To add another feature to my HTML table improvements, I experimented with columns that can be minimzed (folded). This can bring informations of columns together that are far from each other, or reduce information to what is essential. Of course, a collapsed column must be expandable again. User interface will be a checkbox at the left side of a collapsible table column.

This should work well together with sorting and filtering, as introduced in my recent Blogs.

Example

Here is a list of the 10 highest Austrian mountains, in a table with 3 columns, 2 of them collapsible.

Name
Region
Map
Großglockner Grenze Kärnten mit Tirol 153 - u
Wildspitze Tirol 173 - o
Weißkugel Grenze Tirol mit Italien 172 - u
Glocknerwand Grenze Kärnten mit Tirol 153 - u
Großvenediger Grenze Tirol mit Salzburg 152 - u
Hinterer Brochkogel Tirol 173 - o
Hintere Schwärze Grenze Tirol mit Italien 173 - u
Similaun Grenze Tirol mit Italien 173 - u
Gr. Wiesbachhorn Salzburg 153 - o
Vorderer Brochkogel Tirol 173 - o

HTML

<table class="collapsible">
    <thead>
        <tr>
            <th>
               Name
            </th>
            <th>
                <input title="Region, Show / Hide" tabindex="1" type="checkbox">
                <div>Region</div>
            </th>
            <th>
                <input title="Map, Show / Hide" tabindex="2" type="checkbox">
                <div>Map</div>
            </th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Großglockner</td>
            <td>Grenze Kärnten mit Tirol</td>
            <td>153 - u</td>
        </tr>
        ....
    </tbody>
</table>

To mark the table as having collapsible columns, we set the class="collapsible" onto it. The <thead> models three columns, only the first is not collapsible. Wherever a checkbox is, the column header text must be wrapped into a <div>.

The checkbox input element has no checked attribute, thus it would appear in switched-off state. The ES6 code will switch on all checkboxes initially. We don't allow a checkbox to be initially off, because then the original width of the column would not be known, which can collide with fixed column widths for filtering rows.

CSS

We just reduce the margin of the checkbox, and float it to the left of the column header text.

<style>
    table.collapsible thead th > input[type='checkbox'] {
      display: block;
      float: left;
      margin: 0;
      margin-top: 0.3em; /* align horizontally to text */
      margin-right: 0.2em; /* space to column header text */
    }
</style>

ES6 Code

The basic technique to collapse a table column is to alter the widths of its <th> and all <td> cells, and to reduce their text contents, as it could overflow.

To provide a common base class for the new class ColumnCollapser and old class TableFilter, I introduced a class RowBackup, which creates a originalTableRows backup list of all rows in its constructor, needed in both sub-classes.
Please get class TableHeaderClickHandler from my recent Blog about sorting and filtering tables. That class serves for receiving clicks into the table header.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    class RowBackup extends TableHeaderClickHandler
    {
      constructor(table) {
        super(table);
        
        this.originalTableRows = [];
        var row = this.getTableBody().firstElementChild;
        for (; row; row = row.nextElementSibling)
          this.originalTableRows.push(row);
      }
    }

Then we can implement the collapser as a sub-class:

 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
    class ColumnCollapser extends RowBackup
    {
      static getCollapseCheckbox(columnHeader) {
        return columnHeader.querySelector("input[type='checkbox']");
      }
      
      constructor(table) {
        super(table);
        
        this.columnWidths = [];
        for (var i = 0; i < this.columnHeaders.length; i++) {
          const columnHeader = this.columnHeaders[i];
          const collapseCheckbox = ColumnCollapser.getCollapseCheckbox(columnHeader);
          if (collapseCheckbox) {
            collapseCheckbox.checked = true; /* in case browser-reload restored a wrong state */
            
            this.columnWidths[i] = Geometry.getInnerWidth(columnHeader);
            
            const columnIndex = i;
            collapseCheckbox.addEventListener(
                'click',
                (event) => this.handleHeaderClick(columnIndex, true, false)
              );
          }
        }
      }
      
      handleSingleHeaderClick(columnIndex) {
        const columnHeader = this.columnHeaders[columnIndex];
        const originalInnerWidth = this.columnWidths[columnIndex];
        
        const checkbox = ColumnCollapser.getCollapseCheckbox(columnHeader);
        const COLLAPSED_WIDTH = Geometry.getOuterWidth(checkbox);
        
        const expand = (columnHeader.collapsedState === "true");
        columnHeader.collapsedState = expand ? undefined : "true";
        const innerWidth = expand ? originalInnerWidth : COLLAPSED_WIDTH;
        
        this.setChildrenVisible(columnHeader, expand, checkbox);
        Geometry.setFixedInnerWidth(columnHeader, innerWidth);
        
        this.originalTableRows.forEach( /* must set width on all column cells! */
          (tableRow) => {
            const cell = this.getCell(tableRow, columnIndex);
            this.setChildrenVisible(cell, expand);
            Geometry.setFixedInnerWidth(cell, innerWidth);
          }
        );
      }
      
      setChildrenVisible(cell, visible, exceptChild) {
        /** assuming that a cell has either textContent or child elements */
        if (cell.children.length <= 0 && cell.textContent) {
          if (cell.originalTextContent === undefined)
            cell.originalTextContent = cell.textContent;
            
          cell.textContent = visible ? cell.originalTextContent : "...";
        }
        
        for (const child of cell.children) {
          if (child.originalDisplayValue === undefined)
            child.originalDisplayValue = child.style.display;
          
          if (child !== exceptChild)
            child.style.display = visible ? child.originalDisplayValue : "none";
        }
      }
    }

The static method on line 3 encapsulates the HTML identification of the checkbox.

In the constructor, we walk through all column headers (inherited from TableHeaderClickHandler) and make a backup of the widths of all columns that have the collapser checkbox, see line 17. You will find the Geometry class below. If the collapser checkbox exists, we turn it on, see line 15. Finally, on line 20, we install a single-click-event handler on the checkbox, directing such events to method handleSingleHeaderClick(). Because the index number of the column will be used later in the lambda on line 22, we must bind the index number to const columnIndex (line 19).

The handleSingleHeaderClick() method is called whenever the checkbox was clicked. It fetches a number of things from the column header, most important maybe the width of the checkbox on line 33, this will be the target width of the collapsed column. It then checks whether the column is currently collapsed on line 35, and it toggles the state on line 36. Depending on the state, the new column width is calculated on line 38. On line 39, the elements in header-cell are handled by calling setChildrenVisible(). The only child element that shouldn't be hidden is the checkbox, to be seen in third parameter. On line 40, the header cell gets sized. On line 42, we loop through all rows and do nearly the same for all data-cells of the current column.

Method setChildrenVisible() is responsible for handling cell content on sizing. This implementation may vary for more complex tables.
For collapsing: On line 53 we check if there is only text in the cell. When yes, we make a backup of that text into cell.originalTextContent, and then replace it by "...". If there are sub-elements, we silently assume that there is no text content and set all child elements hidden (display: none) on line 65. Before that, we make a backup of its display value into cell.originalDisplayValue on line 62.
For expanding: Text content would get restored from cell.originalTextContent, child elements would get shown by restoring from cell.originalDisplayValue.
All of this would NOT WORK when columns need to be initially collapsed. If you need such, you should call the click() method on the desired column checkboxes.

Here is the missing geometry management for measuring and sizing:

 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
    class Geometry
    {
      static getInnerWidth(element) {
        var style = window.getComputedStyle(element);
        var borderWidth = window.parseInt(style["border-left-width"]) + window.parseInt(style["border-right-width"]);
        var paddingWidth = window.parseInt(style["padding-left"]) + window.parseInt(style["padding-right"]);
        return element.offsetWidth - borderWidth - paddingWidth;
      }
      
      static setFixedInnerWidth(element, innerWidth) {
        var style = window.getComputedStyle(element);
        var cssWidth = innerWidth;
        
        if (style["box-sizing"] === "border-box") {
          var borderWidth = window.parseInt(style["border-left-width"]) + window.parseInt(style["border-right-width"]);
          cssWidth += borderWidth;
          var paddingWidth = window.parseInt(style["padding-left"]) + window.parseInt(style["padding-right"]);
          cssWidth += paddingWidth;
        }
        
        if (cssWidth > 0) {
          const styleValue = cssWidth+"px";
          element.style["width"] = styleValue;
          element.style["max-width"] = styleValue;
          element.style["min-width"] = styleValue;
        }
      }
      
      static getOuterWidth(element) {
        const style = window.getComputedStyle(element);
        const marginWidth = window.parseInt(style["margin-left"]) + window.parseInt(style["margin-right"]);
        return element.offsetWidth + marginWidth;
      }
      
      static setFixedOuterWidth(element, outerWidth) {
        const style = window.getComputedStyle(element);
        const marginWidth = window.parseInt(style["margin-left"]) + window.parseInt(style["margin-right"]);
        var cssWidth = outerWidth - marginWidth;
        
        if (style["box-sizing"] !== "border-box") {
          var borderWidth = window.parseInt(style["border-left-width"]) + window.parseInt(style["border-right-width"]);
          cssWidth -= borderWidth;
          var paddingWidth = window.parseInt(style["padding-left"]) + window.parseInt(style["padding-right"]);
          cssWidth -= paddingWidth;
        }
        
        if (cssWidth > 0) {
          const styleValue = cssWidth+"px";
          element.style["width"] = styleValue;
          element.style["max-width"] = styleValue;
          element.style["min-width"] = styleValue;
        }
      }
    }

As you may know, the offsetWidth of an element is not writable, and the writable style.width is not directly connected to offsetWidth. The methods outlined here bridge the gap and make it possible to get both outer and inner width of an element, and set both again onto the element. The outer width would include border, padding and margin, the inner width would exclude them.

For further exlanations please refer to my Blog from 2016-04-11.

Last not least we must find all collapsible tables on page and initialize them with our ES6 class.

    <script>
    window.addEventListener('load', function () {
      document.querySelectorAll("table.collapsible").forEach(
        (collapsibleTable) => new ColumnCollapser(collapsibleTable)
      );
    });
    </script>

Resume

It was a bit of exploration to find out that the elements and text content within a cell must be handled somehow when collapsing a column. Just sizing the cell didn't look good, and sometimes even didn't work. But there may be better ways to collapse a table column, tell me!