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";
}
| |