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.




Keine Kommentare: