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!