Example: Enhanced table with sorting and filtering with UIBUILDER and vanilla HTML/JavaScript

This example comes from my own home dashboard. I get a weekly email from a service called OutdoorActive (I use their mobile app for walks and bike rides) that contains 3 recommended walks each week from the local area (within 10-20mi I think).

I have a node-red flow that extracts the data from the emails (using a function node and an IMAP library) and puts the data into JSON. That JSON is sent to the front end when needed and is converted into an HTML table.

I use UIBUILDER's front-end router library to create a home dashboard Single Page App (SPA) and all the front-end code for the walks tab is in a single file. This WAS the output prior to me implementing a more sophisticated version.

Now, I've added links to Google Maps, improved the details display and added both a column sort and a filter feature:

No extra libraries were needed for this. Indeed, I tried several of them and it was harder to make them work as I wanted than it was to simply add the code - especially with the help of one of the more advanced GitHub Copilot models (Claude Sonnet 4). I get an allowance for advanced models each month and I'd only used a small % this month despite working on some other complex changes. Even now, I've only used 14%.

Anyway, here is the HTML fragment that is the route file for this particular page.

<style>
    #walkTbl {
        width: 100%;
    }

    /* Custom search input styling */
    .search-container {
        position: relative;
        display: inline-block;
        margin-bottom: 1rem;
    }

    #walkSearch {
        padding: 0.5rem;
        padding-right: 2.5rem;
        border: 1px solid hsl(0, 0%, 80%);
        border-radius: 0.25rem;
        width: 300px;
    }

    .clear-search-btn {
        position: absolute;
        right: 0.5rem;
        top: 50%;
        transform: translateY(-50%);
        background: none;
        border: none;
        font-size: 1.2em;
        cursor: pointer;
        color: hsl(0, 0%, 60%);
        padding: 0;
        display: none;
    }

    .clear-search-btn:hover {
        color: hsl(0, 0%, 30%);
    }

    .clear-search-btn.visible {
        display: block;
    }

    /* Sortable column headers */
    .sortable-header {
        cursor: pointer;
        user-select: none;
        position: relative;
        padding-right: 1.5rem;
    }

    .sortable-header:hover {
        background-color: hsl(0, 0%, 90%);
    }

    .sort-indicator {
        position: absolute;
        right: 0.25rem;
        top: 50%;
        transform: translateY(-50%);
        font-size: 0.75em;
        color: hsl(0, 0%, 60%);
    }

    .sort-indicator.asc::after {
        content: '▲';
        color: hsl(210, 100%, 50%);
    }

    .sort-indicator.desc::after {
        content: '▼';
        color: hsl(210, 100%, 50%);
    }

    .sort-indicator.none::after {
        content: '▲▼';
        opacity: 0.3;
    }

    /* Table cell alignment */
    #walkTbl td {
        text-align: right;
    }

    #walkTbl td:first-child {
        text-align: left;
    }

    #walkTbl td:last-child {
        text-align: center;
    }

    td.walk-description {
        text-align: left !important;
    }
</style>

<template id="walk-template">
    <tr>
        <td>
            <details name="walk-details">
                <summary>
                    <a href="{{routeLink}}" data-route="{{route}}" target="_blank">{{route}}</a>
                </summary>
            </details>
        </td>
        <td>{{fromme}}</td>
        <td title="Length of the walk (mi)">{{distance}}</td>
        <td title="How long will the walk take?">{{time}}</td>
        <td title="How difficult is the walk?">{{difficulty}}</td>
    </tr>
    <tr class="details-row" style="display: none;">
        <td colspan="5" class="walk-description">{{description}}</td>
    </tr>
</template>

<div class="search-container">
    <input type="text" id="walkSearch" placeholder="Search walks..." />
    <button type="button" class="clear-search-btn" id="clearSearch" title="Clear search">❌</button>
</div>

<table id="walkTbl">
    <thead>
        <tr>
            <th class="sortable-header" data-column="walk">
                Walk
                <span class="sort-indicator none"></span>
            </th>
            <th class="sortable-header" data-column="fromMe" title="Straight-line distance from me to start of walk (mi)">
                🎯
                <span class="sort-indicator none"></span>
            </th>
            <th class="sortable-header" data-column="distance" title="How long is the walk? (mi)">
                ➡️
                <span class="sort-indicator none"></span>
            </th>
            <th class="sortable-header" data-column="time" title="How long does the walk take? h:m">
                ⌛
                <span class="sort-indicator none"></span>
            </th>
            <th class="sortable-header" data-column="difficulty" title="How difficult is the walk?">
                🚶‍➡️
                <span class="sort-indicator none"></span>
            </th>
        </tr>
    </thead>
    <tbody id="walks">
    </tbody>
</table>

<script>
    'use strict'

    // Only ever run this once per page load
    if (!window.walkSetup) {
        console.log('Running initial setup for walks route - wont be run again until page reload')
        const walkSetup = window.walkSetup = {}

        // Store table data and current sort state
        walkSetup.tableData = []
        walkSetup.currentSort = { column: null, direction: 'none', }

        /** Sort table data by column
         * @param {string} column - Column name to sort by
         * @param {string} direction - Sort direction: 'asc', 'desc', or 'none'
         */
        walkSetup.sortTable = function(column, direction) {
            if (direction === 'none') {
                walkSetup.renderTable(walkSetup.originalData)
                return
            }

            const sortedData = [...walkSetup.tableData].sort((a, b) => {
                let aVal, bVal

                switch (column) {
                    case 'walk':
                        aVal = a.walk.title.toLowerCase()
                        bVal = b.walk.title.toLowerCase()
                        break
                    case 'fromMe':
                        aVal = a.fromMe === 'N/A' ? Infinity : parseFloat(a.fromMe)
                        bVal = b.fromMe === 'N/A' ? Infinity : parseFloat(b.fromMe)
                        break
                    case 'distance':
                        aVal = a.distance === 'N/A' ? Infinity : parseFloat(a.distance)
                        bVal = b.distance === 'N/A' ? Infinity : parseFloat(b.distance)
                        break
                    case 'time':
                        // Parse time strings like "2:30" or "1:15"
                        aVal = a.time === 'N/A' ? Infinity : walkSetup.parseTimeToMinutes(a.time)
                        bVal = b.time === 'N/A' ? Infinity : walkSetup.parseTimeToMinutes(b.time)
                        break
                    case 'difficulty':
                        aVal = a.difficulty === 'N/A' ? '' : a.difficulty.toLowerCase()
                        bVal = b.difficulty === 'N/A' ? '' : b.difficulty.toLowerCase()
                        break
                    default:
                        return 0
                }

                if (typeof aVal === 'string') {
                    return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal)
                }
                return direction === 'asc' ? aVal - bVal : bVal - aVal
            })

            walkSetup.renderTable(sortedData)
        }

        /** Parse time string to minutes for sorting
         * @param {string} timeStr - Time string like "2:30" or "1:15"
         * @returns {number} Total minutes
         */
        walkSetup.parseTimeToMinutes = function(timeStr) {
            if (!timeStr || timeStr === 'N/A') return Infinity
            const parts = timeStr.split(':')
            if (parts.length !== 2) return Infinity
            const hours = parseInt(parts[0]) || 0
            const minutes = parseInt(parts[1]) || 0
            return hours * 60 + minutes
        }

        /** Render table with given data
         * @param {Array} data - Array of route data objects
         */
        walkSetup.renderTable = function(data) {
            const outEl = document.getElementById('walks')
            if (!outEl) throw new Error('Walks table body not found')

            outEl.innerHTML = ''

            const template = document.getElementById('walk-template')
            if (!template) throw new Error('Template not found')

            data.forEach((routeItem) => {
                // Create a new row for each route by cloning the template content
                const clone = template.content.cloneNode(true)

                // replace the placeholders in the template with actual data
                const link = clone.querySelector('a')
                link.setAttribute('href', routeItem.walk.link)
                link.textContent = routeItem.walk.title
                link.dataset.route = routeItem.walk.route

                // Set up the description in the details row
                const descriptionDiv = clone.querySelector('.walk-description')
                descriptionDiv.textContent = routeItem.walk.description

                // add a link to the Google Maps location and make the link text the distance
                if (routeItem.lat && routeItem.lon) {
                    clone.querySelector('td:nth-child(2)').innerHTML = `<a href="https://www.google.com/maps/search/?q=${routeItem.lat}%2C${routeItem.lon}" target="_blank" title="Miles from me. Click to see on Google Maps">${routeItem.fromMe}</a>`
                } else {
                    clone.querySelector('td:nth-child(2)').textContent = routeItem.fromMe
                }

                clone.querySelector('td:nth-child(3)').textContent = routeItem.distance
                clone.querySelector('td:nth-child(4)').textContent = routeItem.time
                clone.querySelector('td:nth-child(5)').textContent = routeItem.difficulty

                // Add click handler for details toggle
                const details = clone.querySelector('details')
                const detailsRow = clone.querySelector('.details-row')
                const firstCell = clone.querySelector('td:first-child')
    
                // Function to toggle details
                const toggleDetails = () => {
                    if (details.open) {
                        details.open = false
                        detailsRow.style.display = 'none'
                    } else {
                        details.open = true
                        detailsRow.style.display = 'table-row'
                    }
                }

                // Handle details element toggle event
                details.addEventListener('toggle', function() {
                    if (this.open) {
                        detailsRow.style.display = 'table-row'
                    } else {
                        detailsRow.style.display = 'none'
                    }
                })

                // Make the entire first cell clickable for toggling details
                firstCell.style.cursor = 'pointer'
                firstCell.addEventListener('click', function(event) {
                    // Don't toggle if the link was clicked
                    if (event.target === link || link.contains(event.target)) {
                        return
                    }
                    event.preventDefault()
                    toggleDetails()
                })

                // Prevent the cell click when clicking the link
                link.addEventListener('click', function(event) {
                    event.stopPropagation()
                })

                // Set initial state (details collapsed by default)
                details.open = false
                detailsRow.style.display = 'none'

                // Append the cloned rows to the table body
                outEl.appendChild(clone)
            })
        }

        /** Initialize sorting event listeners */
        walkSetup.initSorting = function() {
            const headers = document.querySelectorAll('.sortable-header')
            headers.forEach((header) => {
                header.addEventListener('click', function() {
                    const column = this.getAttribute('data-column')
                    const indicator = this.querySelector('.sort-indicator')

                    // Clear other indicators
                    headers.forEach((h) => {
                        if (h !== this) {
                            h.querySelector('.sort-indicator').className = 'sort-indicator none'
                        }
                    })

                    // Cycle through sort states: none -> asc -> desc -> none
                    let newDirection
                    if (walkSetup.currentSort.column === column) {
                        switch (walkSetup.currentSort.direction) {
                            case 'none':
                                newDirection = 'asc'
                                break
                            case 'asc':
                                newDirection = 'desc'
                                break
                            case 'desc':
                                newDirection = 'none'
                                break
                        }
                    } else {
                        newDirection = 'asc'
                    }

                    walkSetup.currentSort = { column, direction: newDirection, }
                    indicator.className = `sort-indicator ${newDirection}`
                    walkSetup.sortTable(column, newDirection)
                })
            })
        }

        /** Initialize search/filter functionality */
        walkSetup.initSearch = function() {
            const searchInput = document.getElementById('walkSearch')
            const clearBtn = document.getElementById('clearSearch')
            if (!searchInput || !clearBtn) return

            /** Toggle clear button visibility based on input content
             * @param {string} value - The current input value
             */
            const toggleClearButton = (value) => {
                if (value.trim()) {
                    clearBtn.classList.add('visible')
                } else {
                    clearBtn.classList.remove('visible')
                }
            }

            searchInput.addEventListener('input', function() {
                const searchTerm = this.value.toLowerCase().trim()
                toggleClearButton(this.value)
                walkSetup.filterTable(searchTerm)
            })

            clearBtn.addEventListener('click', function() {
                searchInput.value = ''
                searchInput.focus()
                toggleClearButton('')
                walkSetup.filterTable('')
            })

            // Initialize clear button state
            toggleClearButton(searchInput.value)
        }

        /** Filter table based on search term
         * @param {string} searchTerm - The search term to filter by
         */
        walkSetup.filterTable = function(searchTerm) {
            if (!searchTerm) {
                // If no search term, show all data (with current sort applied)
                const dataToShow = walkSetup.currentSort.direction === 'none'
                    ? walkSetup.originalData
                    : walkSetup.tableData
                walkSetup.renderTable(dataToShow)
                return
            }

            // Filter data based on search term
            const filteredData = walkSetup.tableData.filter((item) => {
                // Search in walk title
                if (item.walk.title.toLowerCase().includes(searchTerm)) return true
    
                // Search in walk description
                if (item.walk.description.toLowerCase().includes(searchTerm)) return true
    
                // Search in fromMe (distance from me)
                if (item.fromMe.toString().toLowerCase()
                    .includes(searchTerm)) return true
    
                // Search in distance
                if (item.distance.toString().toLowerCase()
                    .includes(searchTerm)) return true
    
                // Search in time
                if (item.time.toString().toLowerCase()
                    .includes(searchTerm)) return true
    
                // Search in difficulty
                if (item.difficulty.toString().toLowerCase()
                    .includes(searchTerm)) return true
    
                return false
            })

            walkSetup.renderTable(filteredData)
        }

        // Initialize sorting and search when DOM is ready
        const initializeFeatures = () => {
            walkSetup.initSorting()
            walkSetup.initSearch()
        }

        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', initializeFeatures)
        } else {
            initializeFeatures()
        }

        /** Calculate the distance between two geographic coordinates using the Haversine formula
         * @param {number} lat1 - Latitude of the first point in degrees
         * @param {number} lon1 - Longitude of the first point in degrees
         * @param {number} lat2 - Latitude of the second point in degrees
         * @param {number} lon2 - Longitude of the second point in degrees
         * @param {string} [unit] - Unit of measurement ('km' for kilometers [default], 'mi' for miles, 'm' for meters)
         * @returns {number} Distance between the two points in the specified unit
         * @throws {Error} If coordinates are invalid
         *
         * @example
         * // Calculate distance between London and Paris
         * const distance = calculateDistance(51.5074, -0.1278, 48.8566, 2.3522)
         * console.log(`Distance: ${distance.toFixed(2)} km`)
         */
        walkSetup.calculateDistance = function calculateDistance(lat1, lon1, lat2, lon2, unit = 'km') {
            lat1 = parseFloat(lat1)
            lon1 = parseFloat(lon1)
            lat2 = parseFloat(lat2)
            lon2 = parseFloat(lon2)
            // Validate input parameters
            if (typeof lat1 !== 'number' || typeof lon1 !== 'number'
                || typeof lat2 !== 'number' || typeof lon2 !== 'number') {
                throw new Error('All coordinates must be numbers')
            }

            if (lat1 < -90 || lat1 > 90 || lat2 < -90 || lat2 > 90) {
                throw new Error('Latitude must be between -90 and 90 degrees')
            }

            if (lon1 < -180 || lon1 > 180 || lon2 < -180 || lon2 > 180) {
                throw new Error('Longitude must be between -180 and 180 degrees')
            }

            // Earth's radius in kilometers
            const earthRadiusKm = 6371

            // Convert degrees to radians
            const lat1Rad = lat1 * Math.PI / 180
            const lon1Rad = lon1 * Math.PI / 180
            const lat2Rad = lat2 * Math.PI / 180
            const lon2Rad = lon2 * Math.PI / 180

            // Calculate differences
            const deltaLat = lat2Rad - lat1Rad
            const deltaLon = lon2Rad - lon1Rad

            // Haversine formula
            const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2)
                + Math.cos(lat1Rad) * Math.cos(lat2Rad)
                * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2)

            const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

            // Distance in kilometers
            const distanceKm = earthRadiusKm * c

            // Convert to requested unit
            switch (unit.toLowerCase()) {
                case 'km':
                    return distanceKm
                case 'mi':
                    return distanceKm * 0.621371
                case 'm':
                    return distanceKm * 1000
                default:
                    throw new Error('Unit must be "km", "mi", or "m"')
            }
        }

        /** Listener function to handle the 'sendRoutes' message from uibuilder
         * @param {object} msg - The message object containing route data
         */
        walkSetup.rcvRoutes = uibuilder.onTopic('sendRoutes', (msg) => {
            console.log('New Routes:', msg.payload)
            // Amended slightly for sharing 😊
            const mylat = 53
            const mylon = -1

            const routes = walkSetup.routes = msg.payload
            const tableData = []

            Object.keys(routes).forEach((route) => {
                const routeData = routes[route]

                if ('text' in routeData.title) routeData.title.text = routeData.title.text.trim()
                // Entries with no title text are regions rather than routes so ignore them
                if (!routeData.title.text) return

                const routeLink = routeData.title.href ?? '#'
                const routeTitle = routeData.title.text

                const time = (routeData.details.time ?? 'N/A').replace(/ h$/, '')

                // How far from me?
                let fromMe
                let lat, lon
                if (routeData.details.positions) {
                    // Calculate distance if positions are provided
                    [lat, lon] = routeData.details.positions[1].split(', ') // lat, lon
                    fromMe = Number(routeData.details.distance = walkSetup.calculateDistance(
                        mylat, mylon, lat, lon, 'mi'
                    ).toFixed(1))
                } else {
                    fromMe = 'N/A'
                }

                const distance = routeData.details.distance
                    ? Number((parseFloat(routeData.details.distance) * 0.621371).toFixed(1)) // converted from km to mi
                    : 'N/A'

                // Add row data for rendering
                tableData.push({
                    walk: {
                        title: routeTitle,
                        link: routeLink,
                        route: route,
                        description: routeData.details.description || 'No description provided',
                    },
                    fromMe: fromMe,
                    distance: distance,
                    time: time,
                    difficulty: routeData.details.difficulty || 'N/A',
                    lat: lat,
                    lon: lon,
                })
            })

            // Store data for sorting
            walkSetup.tableData = tableData
            walkSetup.originalData = [...tableData]

            // Render the table
            walkSetup.renderTable(tableData)
        })
    }
</script>