// ==UserScript== // @name Make any table sortable // @namespace https://rixx.de // @match *://*/* // @version 1.0.0 // @description Adds a small icon to the top left corner of a table, and if you click it, adds sort buttons to every column header. // @icon https://www.shareicon.net/download/2016/11/14/852384_sort.svg // @grant none // @author Tobias 'rixx' Kunze // @homepageURL https://github.com/rixx/dotfiles // @downloadURL https://raw.githubusercontent.com/rixx/userscripts/refs/heads/main/src/table-sort.user.js // ==/UserScript== const icons = { unsorted: ` `, ascending: ` `, descending: ` `, makeSortable: ` `, }; function getIcon(type) { return "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(icons[type]); } function sortTable(table, col, reverse) { const tbody = table.querySelector("tbody"); const rows = Array.from(tbody.querySelectorAll("tr")); const dir = reverse ? -1 : 1; // if the column is numeric, then sort by number, otherwise sort by text const sorted = rows.sort( (a, b) => dir * ( isNaN(a.children[col].textContent) || isNaN(b.children[col].textContent) ? a.children[col].textContent.localeCompare(b.children[col].textContent) : a.children[col].textContent - b.children[col].textContent ) ); tbody.innerHTML = ""; tbody.append(...sorted); } function handleTableClick(ev) { const th = ev.target; const table = th.closest("table"); if (!table) return; const dir = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; th.setAttribute("aria-sort", dir); // Reset all other headers table.querySelectorAll("th").forEach(col => { if (col !== th) { col.setAttribute("aria-sort", "none"); } }); const col = Array.from(th.parentNode.children).indexOf(th); sortTable(table, col, dir === "descending"); } function getSortableColumns(table) { // return th elements of columns that can be sorted if (!table) return []; const firstRow = table.querySelector("tr"); // if the table does not have colspans, then all columns are sortable if (!table.querySelectorAll("[colspan]").length) { return firstRow.querySelectorAll("th"); } // If the table has colspans, then only columns without them are sortable let excludedColumns = []; table.querySelectorAll("[colspan]").forEach(cell => { const col = Array.from(cell.parentNode.children).indexOf(cell); for (let i = 1; i < cell.getAttribute("colspan"); i++) { excludedColumns.push(col + i); } }); excludedColumns = [...new Set(excludedColumns)]; // Return all columns that are not excluded return Array.from(firstRow.querySelectorAll("th")).filter( (col, i) => !excludedColumns.includes(i) ); } function makeSortable(table) { getSortableColumns(table).forEach(col => { col.addEventListener("click", handleTableClick) col.classList.add("rixx-sortable-column"); }); table.querySelector(".rixx-sortable-button").remove(); } function handleTable(table) { if (table.classList.contains("rixx-sortable")) return; // check if the table has more than one row if (table.querySelectorAll("tr").length < 3) return; // sorting tables with rowspans is a pain, so don't if (table.querySelectorAll("[rowspan]").length) return; // RT special: some tables have multiple bodies instead of rows, so don't if (table.querySelectorAll("tbody").length > 1) return; // If there isn't a th or if the row contains mixed th/td, it's probably not what we want if (!table.querySelector("tr").querySelector("th") || table.querySelector("tr").querySelector("td")) return; if (!getSortableColumns(table).length) return; // Add a button/icon to turn the table into a sortable table const button = document.createElement("button"); button.classList.add("rixx-sortable-button"); button.addEventListener("click", () => makeSortable(table)); table.classList.add("rixx-sortable"); // prepend the button to the first cell of the first row table.querySelector("tr").querySelector("th").prepend(button); } function findTables() { document.querySelectorAll("table").forEach(handleTable); } // Start once DOM is ready document.addEventListener("DOMContentLoaded", () => { const style = document.createElement("style"); style.textContent = ` th.rixx-sortable-column { cursor: pointer; padding-right: 20px; background-repeat: no-repeat; background-position: right center; background-size: 16px 16px; background-image: url("${getIcon("unsorted")}"); } th.rixx-sortable-column[aria-sort="ascending"] { background-image: url("${getIcon("ascending")}"); } .rixx-sortable-column[aria-sort="descending"] { background-image: url("${getIcon("descending")}"); } .rixx-sortable-button { width: 20px; height: 20px; background-image: url("${getIcon("makeSortable")}"); background-repeat: no-repeat; background-color: transparent; border: none; } ` document.head.appendChild(style); findTables(); // run a second time when the page is fully loaded, hopefully setTimeout(findTables, 3000); });