Blog-Archiv

Donnerstag, 1. Dezember 2022

Prevent Double Submit on Chrome via JavaScript

Here is the continuation of my article about the browser-specific double submit problem.

Add following JavaScript to the bottom of the web page, right before the closing </body> tag:

 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
    <script>
    /**
     * Some browsers seem to launch two submit-requests on fast double click!
     * Calling this install-procedure will disable a clicked button after click,
     * this prevents any further click.
     * @param form optional, disable only submit-buttons of this form.
     *         When undefined or null, all submit-buttons of all forms in page will be disabled
     *         on submit of their form.
     * @param button optional, disable only this button. Requires a form.
     *         When undefined or null, all submit-buttons of all forms in page will be disabled
     *         on submit of their form.
     * @param installResetTimeout optional, when true,
     *         the button will be enabled again after 1 second.
     */
    function installDoubleSubmitPrevention(form, button, installResetTimeout) {
        function setButtonDisabled(btn) {
            btn.disabled = true;
            if (installResetTimeout) /* false for undefined and null */
                setTimeout(
                    () => btn.disabled = false,
                    1000
                );
        }
   
        function setFormButtonsDisabled(frm) {
            frm.querySelectorAll("input[type='submit']").forEach(
                (btn) => setButtonDisabled(btn)
            );
        }
   
        function installDoubleSubmitPreventionOnForm(frm) {
            frm.addEventListener("submit", () => setFormButtonsDisabled(frm));
        }
   
        if (form && button) {
            form.addEventListener("submit", () => setButtonDisabled(button));
        }
        else if (form) {
            installDoubleSubmitPreventionOnForm(form);
        }
        else {
            document.querySelectorAll("form").forEach(
                (frm) => installDoubleSubmitPreventionOnForm(frm)
            );
        }
    }
    
    installDoubleSubmitPrevention();
    
    </script>

On line 15, the definition of the install-function starts, which is then called on line 48. I call it without parameters, because that would manage all submit buttons in all forms by default. The documentation between line 2 and 14 tells you what you need to know about optional parameters.

Better would be to put the installDoubleSubmitPrevention function definition into a file js/preventDoubleSubmit.js and load it via a <script src="js/preventDoubleSubmit.js"></script> tag, so that you can reuse the function in many pages. In that case you would have to call installDoubleSubmitPrevention() explicitly in a second <script> tag. (For some reason you cannot put JS code into a tag that loads JS from a file!)

The install-function makes heavy use of the ES-6 lambda feature ("arrow-function"), to be seen on line 20, 27, 32, 36, 43, and thus may look short and simple, but is a little hard to read.

The setButtonDisabled function on line 16 sets the given button disabled, and optionally enables it after one second again, to be used in case the submit doesn't load a new page. (The timeout would be dismissed by the browser in case the submit loads a new page.)

The setFormButtonsDisabled function on line 25 calls the setButtonDisabled function for all submit-buttons of a given form.

The installDoubleSubmitPreventionOnForm function on line 31 installs the button-disabling on all submit-buttons of a given form.

All these nested functions exist to not duplicate code.

The install-logic starts on line 35, where the parameters are tested for existence, and according actions are taken. So you can call this function without arguments, or with a form, or with just a single button of a form. If you use the third parameter installResetTimeout and set it to true, a timeout that re-enables the button after 1 second would be installed. This can help when special treatments of the submit event were implemented on the page.
So, if you want to install the timeout on all buttons of all forms, call the function like this:

    installDoubleSubmitPrevention(undefined, undefined, true);



Ubuntu 22.04 LINUX Boots Very Slowly

Every two years Ubuntu releases a major ugrade. This year I got an incredibly slow Ubuntu 22.04 on my DELL laptop.

No one seems to be aware that MS-WINDOWS meanwhile boots incredibly fast and outperforms Ubuntu by far. See also my Blog about Ubuntu 20.04 ugrade.


First, I saw a line like this one on my boot console:

Dependency failed for SSSD SSH Service responder socket

You can view such messages after boot by entering

grep -i "Depend" /var/log/syslog

SSSD (System Security Service Daemon) seems to be not needed for isolated computer installations, and anyway, if it fails starting, I should disable it:

sudo systemctl stop sssd
sudo systemctl disable sssd


Displaying boot time consumptions is provided by systemd-analyze:

systemd-analyze blame

Output was:

41.472s plocate-updatedb.service
23.363s snapd.service
21.246s networkd-dispatcher.service
20.560s apport-autoreport.service
17.905s udisks2.service
14.792s ModemManager.service
14.678s systemd-journal-flush.service
13.575s NetworkManager-wait-online.service
13.446s accounts-daemon.service
 9.811s dev-sda8.device
 8.168s power-profiles-daemon.service
 7.840s polkit.service
 7.775s avahi-daemon.service
 7.772s NetworkManager.service
 6.895s switcheroo-control.service
 6.885s thermald.service
 6.883s systemd-logind.service
 6.750s dev-loop6.device
 .....
This command prints a list of all running units, ordered by the time they took to initialize. This information may be used to optimize boot-up times. Note that the output might be misleading as the initialization of one service might be slow simply because it waits for the initialization of another service to complete. ....
This command hence gives an impression of the performance of program code, but cannot accurately reflect latency introduced by waiting for hardware and similar events.

Unfortunately snapd.service is needed, Firefox is now a snap-application in Ubuntu 22.04. I could get rid of all snap-apps on Ubuntu 20.04, but now I have to consume it. Snap is a LINUX deployment system, similar to Docker, a snap-bundle contains all its library dependencies (trying to circumvent dependency hell in times of TeraByte disks). There is also a Snap Store on the internet.

I disabled some services that took a lot of time, or created error messages and seemed to be not needed:

sudo systemctl stop plocate-updatedb.service
sudo systemctl disable plocate-updatedb.service

sudo systemctl stop apport-autoreport.service
sudo systemctl disable apport-autoreport.service

sudo systemctl stop NetworkManager-wait-online.service
sudo systemctl mask NetworkManager-wait-online.service

sudo systemctl stop networkd-dispatcher.service
sudo systemctl disable networkd-dispatcher.service

# created errors
sudo systemctl disable ureadahead.service
sudo systemctl mask ureadahead.service

The plocate-updatedb.service took the most time. It indexes hard disk files and provides a very fast plocate command. I don't use that command and very rarely search my whole disk.

The apport-autoreport.service reports system failures to the outer world.

The NetworkManager-wait-online.service is important only when you are in a network with other computers.

The networkd-dispatcher.service is needed on computers that drive VPN or are network administrator machines.

These commands would enable a service again:

sudo systemctl enable networkd-dispatcher.service
sudo systemctl start networkd-dispatcher.service

sudo systemctl unmask NetworkManager-wait-online.service
sudo systemctl enable NetworkManager-wait-online.service


Back from booting I encountered it was not faster, but did not crash, and network is alright. In that case I will have to wait for an Ubuntu ugrade that hopefully will fix this slow boot. It is the graphical user-interface that takes a long time to build up. Lots of long and complex discussions of this issue on the web.




Dienstag, 29. November 2022

A JSP Application to Test Double Submit

JSP means "Java Server Page". This is one of the oldest ways to build web applications using the programming language Java. In a JSP page you mix XML-compatible HTML code with JSP tags and Java code nested in them.

In this Blog I would like to present a minimal JSP app. There will be no Java class, just HTML, JSP tags and nested Java code. The page will render a page hit count and a form submit history.

I started this project to explore the double submit problem that occurs with Chrome 107.0 browser, and maybe others, although not with Firefox 107.0.

Preparation

You will have to install Java and Maven. Check that both are in your PATH environment variable.

Generate Application with Maven

On a command line window, enter the following commands:

cd directory-where-you-have-your-projects
mvn archetype:generate \
  -DarchetypeArtifactId=maven-archetype-webapp \
  -DgroupId=my.web.examples \
  -DartifactId=simplewebapp \
  -DinteractiveMode=false

This will generate a new directory "simplewebapp" in current directory and put a minimal set of sources into it. You could then import this into your IDE as "Maven Project", although a simple text editor will be enough for now.

Install and Run Jetty Web Server

Then put following Maven plugin into the <build> section in pom.xml file in directory "simlewebapp":

    <plugins>
      <plugin>
        <groupId>org.eclipse.jetty</groupId>
        <artifactId>jetty-maven-plugin</artifactId>
        <version>11.0.12</version>
        <configuration>
          <scanIntervalSeconds>2</scanIntervalSeconds>
          <webApp>
            <contextPath>/simplewebapp</contextPath>
          </webApp>
          <httpConnector>
            <port>8080</port>
          </httpConnector>
        </configuration>
      </plugin>
    </plugins>

Dont forget to save that edit.
This will enable you to quickly run a web server from command line. Try it out by entering following commands:

cd simplewebapp
mvn jetty:run

You should see something like "[INFO] Started Server ...." at end of output. Now open a new tab in your web browser and enter following URL in its address line:

You should see what is in simplewebapp/src/main/webapp/index.jsp: "Hello World!".

Edit the JSP Page

In a text editor, load the file index.jsp and put following code into it:

 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
<html>
<body>
    <%
        Thread.sleep(1000); // simulate slow request processing
        
        Integer hitsCount = (Integer) application.getAttribute("hits");
        if (hitsCount == null)
            hitsCount = 0;
            
        hitsCount++;
        
        application.setAttribute("hits", hitsCount);

        String submitInput = request.getParameter("single-line-text");
        if (submitInput == null)
            submitInput = "";
        else if (submitInput.length() <= 0)
            submitInput = "&lt;empty&gt;";
            
        String submitHistory = (String) application.getAttribute("submits");
        if (submitHistory == null)
            submitHistory = "";
        
        submitHistory += " "+submitInput;
        
        application.setAttribute("submits", submitHistory);
    %>

    <p>
        Page Hits: <%= hitsCount %>
    </p>
    
    <p>
        Submitted Text: <%= submitHistory %>
    </p>
    
    <form action="http://localhost:8080/simplewebapp/" method="post">
        <input name="single-line-text">
        <input type="submit" value="Submit">
    </form>
      
</body>
</html>

Please mind the Thread.sleep(1000) on line 4. I added this to simulate slow request processing. If you want to test for double submit, you will need this.

If you are interested in the different techniques used here, please refer to the JSP documentation, explaining them would go beyond the scope of this article.

Save the edit to disk. Maybe you have to stop and restart the Jetty server. Now reload http://localhost:8080/simplewebapp/. You should see this:

Page Hits: 1

Submitted Text:


Try it out

Any time you enter something in the text field and then press the "Submit" button, you should see the "Page Hits" increase and the "Submitted Text" grow by your latest input. After entering "one", clicking "Submit" once, then entering "two" and again clicking "Submit" once, it should look like this:

Page Hits: 3

Submitted Text: one two


Test Double Submit in Different Browsers

You can provoke the double submit problem by quickly clicking the "Submit" button several times until the response is rendered. By default, the browser will refresh the page when a form submission event is triggered. Due to the Thread.sleep(1000) on line 4 of the JSP page it will take a second until the response arrives.

Doing multiple clicks in Firefox I could not achieve several submits.

But doing this in Google Chrome I could achieve it. After entering "one" and a quick double click onto "Submit" it looked like this:

Page Hits: 3

Submitted Text: one one


Resume

This proves that the double submit problem is browser-specific. We need to prevent this by adding JavaScript code, which I will present in my next Blog.




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!




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.