Blog-Archiv

Freitag, 16. September 2022

Sortable Filterable HTML Table with ES6, Part 3

This article is the last about sortable filterable HTML tables. It rolls out the ES6 code. To see what is coded here please go to Part 1 of this series. To see HTML and CSS, please go to Part 2.

Initialization

Without knowing the classes explained below, here is how you would use them in some <script> section of your page, given that you have set the classes "sortable" and/or "filterable" onto the affected tables:

1
2
3
4
5
6
7
8
9
    /* Find and initialize all sortable and filterable tables on page. */
    window.addEventListener('load', function () {
      document.querySelectorAll("table.sortable").forEach(
        (sortableTable) => new TableSorter(sortableTable)
      );
      document.querySelectorAll("table.filterable").forEach(
        (filterableTable) => new TableFilter(filterableTable)
      );
    });

The onload event-function starting on line 2 is executed as soon as all HTML of the page is loaded. It searches all tables of class "sortable" and passes each of them to a newly allocated instance of TableSorter, then it does the same for class "filterable", passing each found table to a new instance of TableFilter. The constructors of these classes will install everything for sorting and/or filtering.

Common Table Header Events

Both TableSorter and TableFilter need a click-handler for table header cells. The sorter needs to listen to clicks onto the sort-arrow, the filter needs to listen to clicks onto the header label text for activating the input textfield. I want the sort-button to ignore double clicks, and the filter field to appear on both single or double click.

So both classes derive a common TableHeaderClickHandler class. This takes care of the double-click problem, i.e. browsers do not provide the number of arrived clicks. The TableHeaderClickHandler class offers the overridable do-nothing methods handleSingleHeaderClick() and handleDoubleHeaderClick() to receive that, using a timeout of 300 milliseconds to detect a double click. Derived classes must install their click-listener using the method handleHeaderClick(), setting the flags for the type of clicks they want to receive, and then override the according do-nothing method.

 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
    class TableHeaderClickHandler
    {
      constructor(table) {
        this.table = table;
        this.columnHeaders = table.querySelectorAll("thead th");
        this.clicks = 0;
      }
    
      handleHeaderClick(columnIndex, singleClick, doubleClick) {
        this.clicks++;
        if (this.clicks === 1) { /* wait for double click */
          setTimeout(
            () => {
              if (this.clicks === 1 && singleClick)
                this.handleSingleHeaderClick(columnIndex);
              else if (this.clicks === 2 && doubleClick)
                this.handleDoubleHeaderClick(columnIndex);
              this.clicks = 0;
            },
            300);
        }
      }
      
      handleSingleHeaderClick(columnIndex) {
      }
      
      handleDoubleHeaderClick(columnIndex) {
      }
      
      getTableBody() {
        return this.table.querySelector("tbody");
      }
      
      getCell(row, columnIndex) {
        return row.querySelectorAll("td")[columnIndex];
      }
      
      removeAllRows() {
        const tbody = this.getTableBody();
        while (tbody.firstChild)
          tbody.removeChild(tbody.lastChild);
        return tbody;
      }
    }

Beside the click-handling, the table and all column header cells are stored as member fields, visible to deriving classes. Further there are 3 convenience methods to get the table's body (where data rows are), a certain data cell in a row, and to remove all rows from the table, which is needed by both sorting and filtering.

Sorting

The way how table rows get sorted (see sortRows() method below):

  1. build an array of temporary row-objects, each object containing
    (1) the row's index, and
    (2) the text-content of the cell in clicked column of the row
  2. at the same time, build also a parallel array of all rows
  3. sort the row-objects array by text-content, optionally numeric
  4. remove all rows from the table
  5. in the order of the sorted row-objects array, add again all rows, retrieving each row from the rows array by the index stored in the temporary row-object

The constructor allocates a German language collator for sorting non-numeric content. Then it fetches all sort-buttons it finds in column header cells and installs a single-click listener onto each.

Mind line 10, it is necessary to bind the current value of the loop-variable i into a closure-local constant, else a wrong column-index would be passed to handlHeaderClick() when the event arrives.

 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
    class TableSorter extends TableHeaderClickHandler
    {
      constructor(table) {
        super(table);
        this.collator = new Intl.Collator('de');
        
        for (var i = 0; i < this.columnHeaders.length; i++) {
          const button = this.columnHeaders[i].querySelector("button");
          if (button) {
            const columnIndex = i;
            button.addEventListener(
                'click', 
                (event) => this.handleHeaderClick(columnIndex, true, false));
          }
        }
      }
    
      handleSingleHeaderClick(columnIndex) {
        this.sortByColumn(columnIndex);
      }
      
      sortByColumn(columnIndex) {
        for (var i = 0; i < this.columnHeaders.length; i++) {
          const columnHeader = this.columnHeaders[i];
          
          if (i === columnIndex) { /* the clicked column */
            const isNumeric = columnHeader.classList.contains("num");
            const oldSortDirection = columnHeader.getAttribute('aria-sort');
            const newSortDirection = (oldSortDirection === 'ascending') ? 'descending' : 'ascending';
            
            columnHeader.setAttribute('aria-sort', newSortDirection);
            this.sortRows(columnIndex, newSortDirection, isNumeric);
          }
          else { /* is not clicked column */
            columnHeader.removeAttribute('aria-sort');
          }
        }
      }
    
      sortRows(columnIndex, sortDirection, isNumber) {
        const tableRows = []; /* will contain all table rows */
        const sortIndexes = []; /* will contain row-indexes with sort-criterion of row */
        const tbody = this.getTableBody();
        
        var row = tbody.firstElementChild; /* loop all rows and collect sort criterions */
        for (var index = 0; row; row = row.nextElementSibling, index++) {
          tableRows.push(row);
          
          const sortCell = this.getCell(row, columnIndex);
          const sortContent = sortCell.textContent.toLowerCase().trim();
          sortIndexes.push({
            index: index,
            value: isNumber ? parseFloat(sortContent) : sortContent
          });
        }
    
        /* sort list of indexes */
        sortIndexes.sort((a, b) => {
          if (a.value === b.value)
              return 0;
          const ascending = (sortDirection === 'ascending');
          const value1 = ascending ? a.value : b.value;
          const value2 = ascending ? b.value : a.value;
          if (isNumber)
            return value1 - value2; /* automatic JS operator conversion */
          else
            return this.collator.compare(value1, value2);
        });
    
        this.removeAllRows();
    
        /* add again all rows, but in new sort order */
        for (var i = 0; i < sortIndexes.length; i++)
          tbody.appendChild(tableRows[sortIndexes[i].index]);
      }
    }

The sort-button should ignore the double-click, thus it just overrides handleSingleHeaderClick() and leaves handleDoubleHeaderClick() for do-nothing. It forwards any single click on a sort-button to the sortByColumn() method.

The sortByColumn() method adjusts the arrow-icons of all columns. The clicked column gets "ascending" or "descending", depending on the current state of the aria-sort attribute, all others are set back to neutral. For the clicked column, the sortRows() method is called.

The sortRows() method performs the algorithm described above. The actual compare-function is given as a lambda on line 58. The sort() function for arrays is a native built-in.

Filtering

In ES6 classes, if you have functionality that is called from both constructor and some instance method, you need to wrap that functionality into a static method, because ES6 constructors can not call instance methods. That is the reason for the leading static getTextLabel().

The constructor first makes a backup of all rows of the table, needed to restore it when all filters get removed. Then it installs a click-listener onto the column header text label (which is implemented as a button). Both single and double clicks are supported, see line 21 and the overrides on line 33 and 37. Finally it checks the presence of a caption element in the table, and, when found, uses it to display the number of all rows and that of filtered rows. Similar code is on line 84 and 85 where filtering was done.

  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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
    class TableFilter extends TableHeaderClickHandler
    {
      static getTextLabel(columnHeader) {
        return columnHeader.querySelector("div > button");
      }
      
      constructor(table) {
        super(table);
        
        this.originalTableRows = []; /* backup current rows in sort order */
        var row = this.getTableBody().firstElementChild;
        for (; row; row = row.nextElementSibling)
          this.originalTableRows.push(row);
        
        for (var i = 0; i < this.columnHeaders.length; i++) {
          const headerText = TableFilter.getTextLabel(this.columnHeaders[i]);
          if (headerText) {
            const columnIndex = i;
            headerText.addEventListener(
              'click',
              (event) => this.handleHeaderClick(columnIndex, true, true)
            );
          }
        }
        
        const caption = table.querySelector("caption");
        if (caption) {
          const rowCount = table.querySelector("tbody").rows.length;
          caption.textContent = ""+rowCount+" / "+rowCount;
        }
      }
      
      handleSingleHeaderClick(columnIndex) {
        this.showFilterFieldInColumn(columnIndex, true);
      }
      
      handleDoubleHeaderClick(columnIndex) {
        this.showFilterFieldInColumn(columnIndex, true);
      }
      
      getAllFilters() {
        const filters = [];
        for (var i = 0; i < this.columnHeaders.length; i++) {
          const textField = this.getTextField(this.columnHeaders[i]);
          if (textField && textField.value && textField.style.display !== "none")
            filters.push({
              filter: textField.value,
              columnIndex: i
            });
        }
        return filters;
      }
      
      filterRows(columnIndex, filterText) {
        if ( ! filterText ) /* close text field */
          this.showFilterFieldInColumn(columnIndex, false);
        
        const tbody = this.removeAllRows();
        const filters = this.getAllFilters();
      
        for (var rowNumber = 0; rowNumber < this.originalTableRows.length; rowNumber++) {
          const row = this.originalTableRows[rowNumber];
          
          var match = true;
          for (var filterNumber = 0; filterNumber < filters.length; filterNumber++) {
            const filter = filters[filterNumber].filter.toLowerCase().trim();
            const columnIndex = filters[filterNumber].columnIndex;
            
            const filterCell = this.getCell(row, columnIndex);
            const isNumeric = filterCell.classList.contains("num");
            const cellContent = filterCell.textContent.toLowerCase().trim();
            
            const rowMatch = isNumeric ? cellContent.startsWith(filter) : (cellContent.indexOf(filter) >= 0);
            if (rowMatch === false)
              match = false; /* all filters must match */
          }
          
          if (match)
            tbody.appendChild(row);
        }
        
        const caption = this.table.querySelector("caption");
        if (caption) {
          caption.textContent = 
            ""+this.table.querySelector("tbody").rows.length+" / "+this.originalTableRows.length;
        }
      }
      
      getTextFieldContainer(columnHeader) {
        return columnHeader.querySelector("div > div");
      }
      
      getTextClearButton(columnHeader) {
        return columnHeader.querySelector("div > div > button");
      }
    
      getTextField(columnHeader) {
        return columnHeader.querySelector("div > div > input[type='text']");
      }
      
      showFilterFieldInColumn(columnIndex, show) {
        const columnHeader = this.columnHeaders[columnIndex];
        const textLabel = TableFilter.getTextLabel(columnHeader);
        const textFieldContainer = this.getTextFieldContainer(columnHeader);
        const textField = this.getTextField(columnHeader);
        const textClearButton = this.getTextClearButton(columnHeader);
        
        if (textField) {
          if (show) {
            textLabel.style.display = "none"; /* hide it */
            
            textFieldContainer.style.display = "block"; /* make it visible */
            textField.addEventListener(
              "input",
              (event) => this.filterRows(columnIndex, event.currentTarget.value));
            textField.focus();
            
            const cancel = () => {
              textField.value = ""; /* clear filter field */
              this.filterRows(columnIndex, "");
            };
            textClearButton.addEventListener("click", cancel);
            textField.addEventListener(
              "keyup",
              (event) => {
                if (event.key == "Escape")
                  cancel();
              });
          }
          else { /* hide */
            textLabel.style.display = "block";
            textLabel.focus(); /* for ENTER again */
            
            textFieldContainer.style.display = "none";
            textField.onchange = undefined;
            textClearButton.onclick = undefined;
          }
        }
      }
    }

On lines 33 and 37, both handleSingleHeaderClick() and handleDoubleHeaderClick() are overridden to delegate to the showFilterFieldInColumn() method on clicks. Let's first look at that on line 102.

The showFilterFieldInColumn() method activates or deactivates the filter input field and its clear-button on the clicked column. When activating the input field, the label button gets hidden on line 110, and an "input" listener gets installed onto the now shown input field, see lines 112 and 113. Such a listener is called on every key-press, thus the table is filtered for every new character entered or removed by the user, calling the filterRows() method. Then a click-listener is installed onto the clear-button, and an ESCAPE key-listener on the filter input field. Both will clear the input and filter table rows, implemented in the cancel() lambda on line 118.

The filterRows() method on line 54 does the actual work. If the given column-filter is empty, it closes the input field on line 56. Then it uses all currently shown filters, that means you can enter filters in several columns for working all together. After removing all table rows on line 58 it performs an outer loop over all original table rows on line 61. The inner loop on line 65 applies all filters as implicit AND conditions, i.e. only when all filters match, the row is appended, see line 78. The startsWith() comparison for "num" columns (line 70) on line 73 gives a nice effect for numeric contents. Finally the caption element is changed to display the number of filtered rows.

The getAllFilters() method on line 41 fetches all non-hidden filter input fields from all columns. It is called on line 59 in method filterRows().

The remaining methods (line 89, 93 and 97) encapsulate HTML structure on reading the input field container, the clear-button, or the input field.

Preserve Table Width while Filtering

Ergonomy demands that the layout of a page should not wobble or move unnecessarily on user interaction. When filtering the table, rows that determine the browser-calculated table width with their content will be added or removed, and, as a consequence, the table will get wider or narrower.

To avoid that effect we can set a fixed width on every column after table construction. A page "load" listener is sufficient to do such, see line 5 below.

 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
    /**
      Fix widths of header columns of tables with CSS-class "fixedColumnWidth"
      to their computed widths so that the table does not resize when filtering.
     */
    window.addEventListener('load', function () {
      function getOuterWidth(element) {
        const style = window.getComputedStyle(element);
        const marginWidth = window.parseInt(style["margin-left"]) + window.parseInt(style["margin-right"]);
        return element.offsetWidth + marginWidth;
      }
      
      function 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;
        }
      }
      
      /* initialization of fixed column widths */
      document.querySelectorAll("table.fixedColumnWidth").forEach(
        (fixedColumnWidthTable) => fixedColumnWidthTable.querySelectorAll("th").forEach(
          (headerCell) => setFixedOuterWidth(headerCell, getOuterWidth(headerCell))
        )
      );
    });

The getOuterWidth() function on line 8 calculates the outer width of an HTML element. It works correctly for elements with or without CSS box-sizing: border-box. This is used to calculate the actual width of any header cell after loading the table.

The setFixedOuterWidth() function on line 14 should set any column to a fixed width. Here the CSS box-sizing property plays a role, borders and paddings would have to be subtracted when not being border-box. On line 26 the outer width is established on all three of width, min-width and max-width. (For Firefox it was enough to do that on header cells; in case a browser would size only header cells then, we would have to put that also on all data cells!)

The initialization code on line 33 loops all tables with CSS-class "fixedColumnWidth". The inner loop processes all header cells of the table and fixes its current width to be permanently.

Resume

What remains to do may be enable sort chains. That means, the click on a sort-button activates the order by that column, but does not forget about the previously clicked columns. By that you could create groupings.

I hope this was helpful and not too long and complicated. Tables with more than 30 rows always should be sortable and filterable, this increases their usability a lot. Hopefully browsers will provide this in future.




Sonntag, 11. September 2022

Sortable Filterable HTML Table with ES6, Part 2

What do we need to achieve a sort- and filter-able HTML table? As usually, HTML, JavaScript (now ES6), CSS, and a HTML5-able browser. Please refer to my latest article to see what is going on now. Mind that this is client-side sorting and filtering.

Here is a template HTML page to fill out:

<!DOCTYPE HTML>
<html>
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>Sortable Filterable Table</title>

    <script>
        /* JavaScript/ES6 goes here */
    </script>

    <style>
        /* CSS goes here */
    </style>
</head>
<body>
    <!-- HTML table header and data rows go here -->
</body>
</html>

Another option would be to code everything in ES6, so that we don't need all three of HTML, CSS and ES6. Usage may become easier then, but styling the table would be more complex, and the ES6 part would become much more complex.

HTML

Here is a template HTML table to fill out:

    <table class="sortable filterable fixedColumnWidth stickyHeader bordered padded centered">
    
        <caption style="text-align: left;">
            <!-- used to display row count -->
        </caption>
        
        <thead>
            <!-- header row goes here -->
        </thead>

        <tbody>
            <!-- data rows go here -->
        </tbody>

    </table>

Don't care about the many CSS-classes the table has, I will come back to it later. Important are sortable, filterable and fixedColumnWidth only. The latter will prevent the table from changing its width dynamically when the user enters a filter and thus reduces displayed rows (→ ergonomy).

Any <thead> table column header that should be sortable and filterable must be structured like the following, so that ES6 and CSS code can build on it (the control elements are optional, leave them out if a column should not be sortable or filterable):

        <tr>
            <th>
                <button title="Click to Sort" tabindex="2"></button>
                <div>
                    <button title="Click to Filter" tabindex="1">Name</button>
                    <div>
                        <input type="text" placeholder="Name" title="Name" size="1">
                        <button title="Clear Filter"></button>
                    </div>
                </div>
            </th>

            ....
            
        </tr>

This is one column-header. The sort-button comes first, because it will be floated right relative to the column-header text. Its arrow-labels will be controlled by CSS.

Second there is a DIV wrapping another button and another DIV. The button will render the column-header text and open a filter-textfield as soon as it gets clicked or ENTERed.

The nested DIV contains the initially hidden filter-textfield and a clear-button for it. This will be positioned absolute, so the clear-button needs not to be before the input field.

Everything else is done in ES6 or CSS. Anyway title tooltips are defined here, the column-header text is duplicated as placeholder text for the filter-textfield, and a TAB-key focus-order is declared. The size attribute finally is here to prevent the text field from taking a default width, which may expand into the rightmost column and get invisible when overflow was set to hidden. Yes, layout with HTML and CSS is that complicated.

Here is the complete table HTML, including 5 sample rows:

    <table class="sortable filterable fixedColumnWidth stickyHeader bordered padded centered">
    
        <caption style="text-align: left;">
        </caption>
        
        <thead>
        <tr>
            <th>
                <button title="Click to Sort" tabindex="2"></button>
                <div>
                    <button title="Click to Filter" tabindex="1">Name</button>
                    <div>
                        <input type="text" placeholder="Name" title="Name" size="1">
                        <button title="Clear Filter"></button>
                    </div>
                </div>
            </th>
            <th class="num" aria-sort='descending'> <!-- pre-sorted column -->
                <button title="Click to Sort" tabindex="4"></button>
                <div>
                    <button title="Click to Filter" tabindex="3">Höhe</button>
                    <div>
                        <input type="text" placeholder="Höhe" title="Höhe" size="1">
                        <button title="Clear Filter"></button>
                    </div>
                </div>
            </th>
            <th>
                <button title="Click to Sort" tabindex="6"></button>
                <div>
                    <button title="Click to Filter" tabindex="5">Gruppe</button>
                    <div>
                        <input type="text" placeholder="Gruppe" title="Gruppe" size="1">
                        <button title="Clear Filter"></button>
                    </div>
                </div>
            </th>
            <th>
                <button title="Click to Sort" tabindex="8"></button>
                <div>
                    <button title="Click to Filter" tabindex="7">Land</button>
                    <div>
                        <input type="text" placeholder="Land" title="Land" size="1">
                        <button title="Clear Filter"></button>
                    </div>
                </div>
            </th>
            <th>
                Ö-Karte Nr.
            </th>
        </tr>
        </thead>

        <tbody>
        <tr>
            <td>Großglockner</td>
            <td class='num'>3798</td>
            <td>Hohe Tauern Glocknergruppe</td>
            <td>Grenze Kärnten mit Tirol</td>
            <td>153 - u</td>
        </tr>
        <tr>
            <td>Wildspitze</td>
            <td class='num'>3770</td>
            <td>Ötztaler Alpen</td>
            <td>Tirol</td>
            <td>173 - o</td>
        </tr>
        <tr>
            <td>Weißkugel</td>
            <td class='num'>3738</td>
            <td>Ötztaler Alpen</td>
            <td>Grenze Tirol mit Italien</td>
            <td>172 - u</td>
        </tr>
        <tr>
            <td>Glocknerwand</td>
            <td class='num'>3722</td>
            <td>Hohe Tauern Glocknergruppe</td>
            <td>Grenze Kärnten mit Tirol</td>
            <td>153 - u</td>
        </tr>
        <tr>
            <td>Großvenediger</td>
            <td class='num'>3674</td>
            <td>Hohe Tauern Venedigergruppe</td>
            <td>Grenze Tirol mit Salzburg</td>
            <td>152 - u</td>
        </tr>
        </tbody>

    </table>

Before introducing ES6 code, which I will do in a separate article, here is CSS for the sort- and filterable table.

CSS

You can decide to make your table sortable but not filterable, or filterable but not sortable. Just use the class attribute of the <table> element. Sortable-only tables do not need the fixedColumnWidth class nor the related ES6 code.


Following CSS is to be written into a <style> element (or an external CSS-file) for both sorting and filtering:

    <style>
    /** both sortable and filterable */
	
    /** align numbers to the right */
    table.sortable td.num,
    table.filterable td.num {
      text-align: right;
    }
    
    /** header cells */
    table.sortable thead th,
    table.filterable thead th {
      white-space: nowrap; /* keep all header labels in just one line */
      text-align: left; /* do not horizontally align to center */
    }

    /** layout container for label: take all space left of sort-button */
    table.sortable thead th > div,
    table.filterable thead th > div {
      overflow: hidden;
    }

    /** label text in column header cells */
    table.sortable thead th > div > button,
    table.filterable thead th > div > button {
      display: block;
      background: transparent;
      border: 1px solid transparent;
      width: 100%;
      cursor: pointer;
      font-size: inherit;
      font-weight: inherit;
      text-align: left;
    }

    </style>

Numeric contents normally are rendered right-aligned. The num CSS-class is used to indicate a numeric column. This is important for sorting, and plays a role also when filtering (searching by "starts-with").

All column headers should be one-lined to avoid layout problems with nested elements. The white-space: nowrap CSS does this. Table header cells are centered by default, I want them left-aligned.

The overflow: hidden statement is for the nested DIV. Without this, the nested "Name" label button would not take all space to the left of the sort-button.

The last block is about styling the "Name" label button to look like a normal column header label, and receive clicks on all space from left to right before the sort-button.


Following CSS is to be included for sorting:

    <style>
    /** sortable */

    /** don't show filter fields when not filterable */
    table.sortable:not(.filterable) thead th > div > div {
      display: none;
    }
    
    /** the sort-buttons in table header */
    table.sortable thead th > button {
      display: block;
      float: right;
      background: transparent;
      border: none;
      cursor: pointer; /* show click acceptance */
      margin-top: 2px;
    }
    
    /** sort ascending pseudo-element */
    table.sortable thead th[aria-sort="ascending"] > button::after {
      content: "\0025B2"; /* ▲ */
    }
    
    /** sort descending pseudo-element */
    table.sortable thead th[aria-sort="descending"] > button::after {
      content: "\0025BC"; /* ▼ */
    }
    
    /** pseudo-element expressing "not sorted by this column" */
    table.sortable thead th:not([aria-sort]) > button::after {
      content: "\002662"; /* ♢ */
    }
    
    </style>

CSS allows negation of classes via the :not() pseudo-class. This is used here to declare that the nested filer-DIV should not be visible when no filterable CSS-class was declared on the <table> element.

The sort button gets floated to the right of the following element. A display: block layout is OK for floated elements, it gives us the opportunity to size it. The button is styled to melt into the background, and aligned to horizontally fit the label text.

The last three blocks declare the arrow-labels of the sort button according to the value of the aria-sort attribute. ES6 will be responsible for setting that attribute to either "descending" or "ascending", or for removing it to activate the rule th:not([aria-sort]).


Following is to be included for filtering:

    <style>
    /** filterable */
    
    /** don't show sort buttons when not sortable */
    table.filterable:not(.sortable) thead th > button {
      display: none;
    }

    table.filterable thead th > div > button:hover,
    table.filterable thead th > div > button:focus {
      border: 1px solid gray;
    }
    
    /** layout container for text-field and its clear-button: take all space left of sort-button */
    table.filterable thead th > div > div {
      display: none;
      position: relative;
    }

    /** filter field in header cells */
    table.filterable thead th > div > div > input[type='text'] {
      width: 100%;
      box-sizing: border-box;
      padding: 0;
      padding-right: 12px; /* leave place for clear-button */
      font-size: inherit;
    }
    
    /** The clear-button for filter field */
    table.filterable thead th > div > div > button {
      position: absolute;
      top: 0;
      right: 0;
      border: none;
      background: transparent;
      cursor: pointer;
      font-size: inherit;
    }
    
    /** icon for clear-button */
    table.filterable thead th > div > div > button::after {
      content: "\00d7"; /* multiply */
    }

    </style>

A table that is filterable but not sortable should not display the sort-button.

A gray border should appear over the column text label button when the mouse is over, or it has the input-focus (click or TAB-key).

The nested DIV containing the textfield and the clear button should not be shown initially. Its position is set to relative to be parent for absolutely positioned children. No, CSS layout is not intuitive.

The filter textfield gets shortened by padding-right 12 pixels to leave place for the absolutely positioned clear-button right of it. Thus it can expand to 100% of the available horizontal space, the button will sit over it.

The clear-button is positioned absolute inside its parent. It gets styled to melt into the background.

The clear-button gets its label text. I prefer using UNICODE letters instead of image files that need to be managed.


Here are some general CSS class implementations useful with HTML tables:

    <style>
    /** general table styles */
    
    table.centered {
      margin-left: auto;
      margin-right: auto;
    }
    
    table.bordered  {
      border-collapse: collapse;
    }
    
    table.bordered td, table.bordered th  {
      border: 1px solid gray;
    }
    
    table.padded td, table.padded th  {
      padding: 0.6em;
    }

    table.stickyHeader thead {
      position: sticky;
      inset-block-start: 0;
      background-color: #ddd;
    }    

    </style>

If you want to horizontally center your table in its layout container, you can include the centered CSS class.

Next two statements draw a thin gray border around any cell, collapsing colliding borders. Use this by including the CSS class bordered.

The padding of cells make the table bigger, but also more user-friendly. Customize your padding and include it as CSS class padded.

The sticky value for position has been added recently, old or badly maintained browsers will not support this. It makes the table header stay visible even while the long table body gets scrolled down. I experimented with a border-bottom to separate it from the table content, but this did not work, so I gave it a gray background color instead.


That's all for HTML and CSS. Now ES6 should give life to the page. I will roll it out in Part 3 of this article series. The ES6 implementation of CSS-class "fixedColumnWidth" will be contained.




Sonntag, 4. September 2022

Sortable Filterable HTML Table with ES6, Part 1

HTML tables do not do very much. If you want to render your data sortable and filterable, you have to implement it by yourself. In this Blog I want to present the result of my efforts to do such. There is no jQuery or react or whatsoever in it, it is just HTML + CSS + ES6 and nothing else.
Mind that this is a client-side action, meaning all table-rows must reside as HTML on the browser-client already. Big paged tables normally are sorted and filtered on server-side.

In this article you'll find just the solution, I will explain the source code in further parts. If you are impatient, simply view the source code of the page (to find the start, search for 'TableHeaderClickHandler').

So what you're seeing below are the 100 highest of Austria's 3242 mountains with a topographic prominence of 100 meters. (Unfortunately the full list was too big for Google Blog:-)

  • Click into the header for sorting and filtering, or navigate with the keyboard (TAB, ENTER, ESCAPE)
  • Sort buttons are right, filter fields are left
  • Filter fields can be closed by erasing the filter input, or clicking the "X" button to the right, or pressing ESCAPE
  • The table should not resize while filtering
  • The header should be sticky on page head while scrolling (might not yet work in MS-Edge, this is the new TABLE feature position: sticky).

Gotcha: the sticky table header has a problem with the separation line down towards the table. No border-bottom style worked, thus I had to give it a background-color.