From ec2b4365583b73fde73a841b15d22b7bef2888a3 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Wed, 15 Apr 2026 16:08:07 +0200 Subject: [PATCH] Allow sorting of tables in messages by clicking on the table header Closes #6656 --- ts/WoltLabSuite/Core/Bootstrap.ts | 3 + .../Component/Message/MessageTableSort.ts | 147 ++++++++++++++++++ .../files/js/WoltLabSuite/Core/Bootstrap.js | 27 ++-- .../Component/Message/MessageTableSort.js | 120 ++++++++++++++ .../output/node/HtmlOutputNodeTable.class.php | 18 +++ .../install/files/style/ui/tabularBox.scss | 17 +- 6 files changed, 317 insertions(+), 15 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Component/Message/MessageTableSort.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTableSort.js diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 40f81ef079f..f7eac678b0f 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -159,6 +159,9 @@ export function setup(options: BoostrapOptions): void { whenFirstSeen(".messageTabMenu", () => { void import("./Component/Message/MessageTabMenu").then(({ setup }) => setup()); }); + whenFirstSeen(".htmlContent .sortableTable", () => { + void import("./Component/Message/MessageTableSort").then(({ setup }) => setup()); + }); whenFirstSeen("[data-edit-avatar]", () => { void import("./Component/User/Avatar").then(({ setup }) => setup()); }); diff --git a/ts/WoltLabSuite/Core/Component/Message/MessageTableSort.ts b/ts/WoltLabSuite/Core/Component/Message/MessageTableSort.ts new file mode 100644 index 00000000000..476844c8c31 --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/Message/MessageTableSort.ts @@ -0,0 +1,147 @@ +/** + * Adds client-side sorting to tables in message content that have header cells (). + * Clicking a header cell sorts the table rows by that column, toggling between + * ascending and descending order. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { wheneverFirstSeen } from "WoltLabSuite/Core/Helper/Selector"; + +function getHeaderCells(table: HTMLTableElement): HTMLTableCellElement[] | null { + const thead = table.querySelector(":scope > thead"); + if (thead) { + const ths = thead.querySelectorAll(":scope > tr > th"); + if (ths.length > 0) { + return Array.from(ths); + } + } + + return null; +} + +function hasComplexLayout(table: HTMLTableElement): boolean { + const cells = table.querySelectorAll("td, th"); + for (const cell of cells) { + if ( + (cell instanceof HTMLTableCellElement && cell.rowSpan > 1) || + (cell instanceof HTMLTableCellElement && cell.colSpan > 1) + ) { + return true; + } + } + return false; +} + +function getBodyRows(table: HTMLTableElement, headerCells: HTMLTableCellElement[]): HTMLTableRowElement[] { + const headerRow = headerCells[0].closest("tr")!; + const rows: HTMLTableRowElement[] = []; + + const allRows = table.querySelectorAll(":scope > tbody > tr, :scope > tr"); + for (const row of allRows) { + if (row !== headerRow) { + rows.push(row); + } + } + + return rows; +} + +function getCellValue(row: HTMLTableRowElement, columnIndex: number): string { + const cell = row.cells[columnIndex]; + if (!cell) { + return ""; + } + return (cell.textContent || "").trim(); +} + +function createComparator(order: "ASC" | "DESC"): (a: string, b: string) => number { + const multiplier = order === "ASC" ? 1 : -1; + + return (a: string, b: string): number => { + const numA = parseFloat(a); + const numB = parseFloat(b); + + if (!isNaN(numA) && !isNaN(numB) && String(numA) === a && String(numB) === b) { + return (numA - numB) * multiplier; + } + + return a.localeCompare(b) * multiplier; + }; +} + +function sortByColumn( + table: HTMLTableElement, + headerCells: HTMLTableCellElement[], + columnIndex: number, + order: "ASC" | "DESC", +): void { + const rows = getBodyRows(table, headerCells); + const container = rows[0]?.parentElement; + if (!container || rows.length === 0) { + return; + } + + const comparator = createComparator(order); + + rows.sort((rowA, rowB) => { + const valueA = getCellValue(rowA, columnIndex); + const valueB = getCellValue(rowB, columnIndex); + return comparator(valueA, valueB); + }); + + for (const row of rows) { + container.appendChild(row); + } +} + +function initTable(table: HTMLTableElement): void { + if (hasComplexLayout(table)) { + return; + } + + const headerCells = getHeaderCells(table); + if (!headerCells) { + return; + } + + headerCells.forEach((th, columnIndex) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "messageTableSort__trigger"; + + while (th.firstChild) { + button.appendChild(th.firstChild); + } + th.appendChild(button); + th.setAttribute("aria-sort", "none"); + + button.addEventListener("click", () => { + let newOrder: "ASC" | "DESC"; + if (th.classList.contains("ASC")) { + newOrder = "DESC"; + } else { + newOrder = "ASC"; + } + + for (const sibling of headerCells) { + sibling.classList.remove("ASC", "DESC"); + sibling.setAttribute("aria-sort", "none"); + } + + th.classList.add(newOrder); + th.setAttribute("aria-sort", newOrder === "ASC" ? "ascending" : "descending"); + + sortByColumn(table, headerCells, columnIndex, newOrder); + }); + }); +} + +export function setup(): void { + wheneverFirstSeen(".htmlContent .sortableTable", (table: HTMLTableElement) => { + initTable(table); + }); +} diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js index df687c4eecf..335113f277a 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Bootstrap.js @@ -126,40 +126,43 @@ define(["require", "exports", "tslib", "./Core", "./Date/Picker", "./Devtools", (0, LazyLoader_1.whenFirstSeen)(".messageTabMenu", () => { void new Promise((resolve_2, reject_2) => { require(["./Component/Message/MessageTabMenu"], resolve_2, reject_2); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); + (0, LazyLoader_1.whenFirstSeen)(".htmlContent .sortableTable", () => { + void new Promise((resolve_3, reject_3) => { require(["./Component/Message/MessageTableSort"], resolve_3, reject_3); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + }); (0, LazyLoader_1.whenFirstSeen)("[data-edit-avatar]", () => { - void new Promise((resolve_3, reject_3) => { require(["./Component/User/Avatar"], resolve_3, reject_3); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_4, reject_4) => { require(["./Component/User/Avatar"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-pagination", () => { - void new Promise((resolve_4, reject_4) => { require(["./Ui/Pagination/JumpToPage"], resolve_4, reject_4); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_5, reject_5) => { require(["./Ui/Pagination/JumpToPage"], resolve_5, reject_5); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-google-maps", () => { - void new Promise((resolve_5, reject_5) => { require(["./Component/GoogleMaps/woltlab-core-google-maps"], resolve_5, reject_5); }).then(tslib_1.__importStar); + void new Promise((resolve_6, reject_6) => { require(["./Component/GoogleMaps/woltlab-core-google-maps"], resolve_6, reject_6); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("[data-google-maps-geocoding]", () => { - void new Promise((resolve_6, reject_6) => { require(["./Component/GoogleMaps/Geocoding"], resolve_6, reject_6); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_7, reject_7) => { require(["./Component/GoogleMaps/Geocoding"], resolve_7, reject_7); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file", () => { - void new Promise((resolve_7, reject_7) => { require(["./Component/File/woltlab-core-file"], resolve_7, reject_7); }).then(tslib_1.__importStar); + void new Promise((resolve_8, reject_8) => { require(["./Component/File/woltlab-core-file"], resolve_8, reject_8); }).then(tslib_1.__importStar); }); (0, LazyLoader_1.whenFirstSeen)("woltlab-core-file-upload", () => { - void new Promise((resolve_8, reject_8) => { require(["./Component/File/woltlab-core-file"], resolve_8, reject_8); }).then(tslib_1.__importStar); - void new Promise((resolve_9, reject_9) => { require(["./Component/File/Upload"], resolve_9, reject_9); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_9, reject_9) => { require(["./Component/File/woltlab-core-file"], resolve_9, reject_9); }).then(tslib_1.__importStar); + void new Promise((resolve_10, reject_10) => { require(["./Component/File/Upload"], resolve_10, reject_10); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)(".activityPointsDisplay", () => { - void new Promise((resolve_10, reject_10) => { require(["./Component/User/ActivityPointList"], resolve_10, reject_10); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_11, reject_11) => { require(["./Component/User/ActivityPointList"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("[data-fancybox]", () => { - void new Promise((resolve_11, reject_11) => { require(["./Component/Image/Viewer"], resolve_11, reject_11); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_12, reject_12) => { require(["./Component/Image/Viewer"], resolve_12, reject_12); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)(".jsImageViewer", () => { console.warn("The class `jsImageViewer` is deprecated. Use the attribute `data-fancybox` instead."); - void new Promise((resolve_12, reject_12) => { require(["./Component/Image/Viewer"], resolve_12, reject_12); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); + void new Promise((resolve_13, reject_13) => { require(["./Component/Image/Viewer"], resolve_13, reject_13); }).then(tslib_1.__importStar).then(({ setupLegacy }) => setupLegacy()); }); (0, LazyLoader_1.whenFirstSeen)(".jsEnablesOptions", () => { - void new Promise((resolve_13, reject_13) => { require(["./Component/Option/Enable"], resolve_13, reject_13); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_14, reject_14) => { require(["./Component/Option/Enable"], resolve_14, reject_14); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); (0, LazyLoader_1.whenFirstSeen)("[data-edit-cover-photo]", () => { - void new Promise((resolve_14, reject_14) => { require(["./Component/User/CoverPhoto"], resolve_14, reject_14); }).then(tslib_1.__importStar).then(({ setup }) => setup()); + void new Promise((resolve_15, reject_15) => { require(["./Component/User/CoverPhoto"], resolve_15, reject_15); }).then(tslib_1.__importStar).then(({ setup }) => setup()); }); // Move the reCAPTCHA widget overlay to the `pageOverlayContainer` // when widget form elements are placed in a dialog. diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTableSort.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTableSort.js new file mode 100644 index 00000000000..08a56283b6a --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Component/Message/MessageTableSort.js @@ -0,0 +1,120 @@ +/** + * Adds client-side sorting to tables in message content that have header cells (). + * Clicking a header cell sorts the table rows by that column, toggling between + * ascending and descending order. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +define(["require", "exports", "WoltLabSuite/Core/Helper/Selector"], function (require, exports, Selector_1) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.setup = setup; + function getHeaderCells(table) { + const thead = table.querySelector(":scope > thead"); + if (thead) { + const ths = thead.querySelectorAll(":scope > tr > th"); + if (ths.length > 0) { + return Array.from(ths); + } + } + return null; + } + function hasComplexLayout(table) { + const cells = table.querySelectorAll("td, th"); + for (const cell of cells) { + if ((cell instanceof HTMLTableCellElement && cell.rowSpan > 1) || + (cell instanceof HTMLTableCellElement && cell.colSpan > 1)) { + return true; + } + } + return false; + } + function getBodyRows(table, headerCells) { + const headerRow = headerCells[0].closest("tr"); + const rows = []; + const allRows = table.querySelectorAll(":scope > tbody > tr, :scope > tr"); + for (const row of allRows) { + if (row !== headerRow) { + rows.push(row); + } + } + return rows; + } + function getCellValue(row, columnIndex) { + const cell = row.cells[columnIndex]; + if (!cell) { + return ""; + } + return (cell.textContent || "").trim(); + } + function createComparator(order) { + const multiplier = order === "ASC" ? 1 : -1; + return (a, b) => { + const numA = parseFloat(a); + const numB = parseFloat(b); + if (!isNaN(numA) && !isNaN(numB) && String(numA) === a && String(numB) === b) { + return (numA - numB) * multiplier; + } + return a.localeCompare(b) * multiplier; + }; + } + function sortByColumn(table, headerCells, columnIndex, order) { + const rows = getBodyRows(table, headerCells); + const container = rows[0]?.parentElement; + if (!container || rows.length === 0) { + return; + } + const comparator = createComparator(order); + rows.sort((rowA, rowB) => { + const valueA = getCellValue(rowA, columnIndex); + const valueB = getCellValue(rowB, columnIndex); + return comparator(valueA, valueB); + }); + for (const row of rows) { + container.appendChild(row); + } + } + function initTable(table) { + if (hasComplexLayout(table)) { + return; + } + const headerCells = getHeaderCells(table); + if (!headerCells) { + return; + } + headerCells.forEach((th, columnIndex) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "messageTableSort__trigger"; + while (th.firstChild) { + button.appendChild(th.firstChild); + } + th.appendChild(button); + th.setAttribute("aria-sort", "none"); + button.addEventListener("click", () => { + let newOrder; + if (th.classList.contains("ASC")) { + newOrder = "DESC"; + } + else { + newOrder = "ASC"; + } + for (const sibling of headerCells) { + sibling.classList.remove("ASC", "DESC"); + sibling.setAttribute("aria-sort", "none"); + } + th.classList.add(newOrder); + th.setAttribute("aria-sort", newOrder === "ASC" ? "ascending" : "descending"); + sortByColumn(table, headerCells, columnIndex, newOrder); + }); + }); + } + function setup() { + (0, Selector_1.wheneverFirstSeen)(".htmlContent .sortableTable", (table) => { + initTable(table); + }); + } +}); diff --git a/wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeTable.class.php b/wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeTable.class.php index e2e52076e51..65b8cc1e591 100644 --- a/wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeTable.class.php +++ b/wcfsetup/install/files/lib/system/html/output/node/HtmlOutputNodeTable.class.php @@ -51,6 +51,8 @@ public function process(array $elements, AbstractHtmlNodeProcessor $htmlNodeProc } } + $this->flagTableAsSortable($element, $htmlNodeProcessor); + // check if table is not contained within another table $parent = $element; while ($parent = $parent->parentNode) { @@ -67,4 +69,20 @@ public function process(array $elements, AbstractHtmlNodeProcessor $htmlNodeProc } } } + + private function flagTableAsSortable(\DOMElement $tableElement, AbstractHtmlNodeProcessor $htmlNodeProcessor): void + { + $tableHeaders = $htmlNodeProcessor->getXPath()->query('.//thead', $tableElement); + if ($tableHeaders->count() === 0) { + return; + } + + $class = $tableElement->getAttribute('class'); + if ($class) { + $class .= " "; + } + $class .= "sortableTable"; + + $tableElement->setAttribute('class', $class); + } } diff --git a/wcfsetup/install/files/style/ui/tabularBox.scss b/wcfsetup/install/files/style/ui/tabularBox.scss index 3adba20a1fc..9e905eb095f 100644 --- a/wcfsetup/install/files/style/ui/tabularBox.scss +++ b/wcfsetup/install/files/style/ui/tabularBox.scss @@ -151,20 +151,31 @@ html:not(.touch) .tabularListRow:not(.tabularListRowHead):hover { color: var(--wcfTabularBoxHeadlineActive); } + > .messageTableSort__trigger { + display: block; + + &:hover { + color: var(--wcfTabularBoxHeadlineActive); + } + } + &.ASC, &.DESC { - > a::after { + > a::after, + > .messageTableSort__trigger::after { display: inline-block; margin-left: 5px; } } - &.ASC > a::after { + &.ASC > a::after, + &.ASC > .messageTableSort__trigger::after { // 2191 = UPWARDS ARROW content: "\2191"; } - &.DESC > a::after { + &.DESC > a::after, + &.DESC > .messageTableSort__trigger::after { // 2193 = DOWNWARDS ARROW content: "\2193"; }