/** * DataTable Component with Pagination * Contract: js-browser-architecture-contract_v2.yaml, html-tables-contract_v1.0.yaml * Principles: DRY, KISS, OOP, SRP, YAGNI */ import { domAdapter } from "../adapters/domAdapter.js"; import { eventAdapter } from "../adapters/eventAdapter.js"; class DataTable { constructor(tableId, options, deps) { this.deps = deps; this.cleanupFunctions = []; this.table = domAdapter.getElementById(tableId); if (this.table === null) { return; } this.tbody = domAdapter.querySelector(this.table, "tbody"); this.headers = domAdapter.querySelectorAll(this.table, "th[data-sort]"); this.rows = []; this.sortColumn = null; this.sortDirection = "asc"; this.searchInput = options.searchInput !== undefined ? domAdapter.getElementById(options.searchInput) : null; this.filters = options.filters !== undefined ? options.filters : {}; // Pagination this.pageSize = options.pageSize !== undefined ? options.pageSize : 20; this.currentPage = 1; this.paginationContainer = null; this.setup(); } setup() { this.cacheRows(); this.bindSorting(); this.bindSearch(); this.bindFilters(); this.createPaginationUI(); this.render(); } cacheRows() { const rows = domAdapter.querySelectorAll(this.tbody, "tr"); this.rows = Array.from(rows).map((row) => { const cells = domAdapter.querySelectorAll(row, "td"); return { element: row, data: Array.from(cells).map((td) => domAdapter.getTextContent(td).trim().toLowerCase() ), raw: Array.from(cells).map((td) => domAdapter.getTextContent(td).trim() ), sortValues: Array.from(cells).map((td) => domAdapter.getAttribute(td, "data-sort-value") ) }; }); } bindSorting() { this.headers.forEach((header, index) => { domAdapter.setStyle(header, "cursor", "pointer"); const cleanup = eventAdapter.on( header, "click", () => this.sort(index, domAdapter.getAttribute(header, "data-sort")), this.deps, "DATATABLE_SORT", "datatable" ); this.cleanupFunctions.push(cleanup); }); } sort(columnIndex, columnName) { const direction = this.sortColumn === columnName && this.sortDirection === "asc" ? "desc" : "asc"; this.sortColumn = columnName; this.sortDirection = direction; this.headers.forEach((h) => { domAdapter.removeClass(h, "sort-asc"); domAdapter.removeClass(h, "sort-desc"); }); const activeHeader = domAdapter.querySelector( this.table, `th[data-sort="${columnName}"]` ); if (activeHeader !== null) { domAdapter.addClass(activeHeader, `sort-${direction}`); } this.rows.sort((a, b) => { const valA = a.raw[columnIndex] !== undefined ? a.raw[columnIndex] : ""; const valB = b.raw[columnIndex] !== undefined ? b.raw[columnIndex] : ""; const numA = parseFloat(valA); const numB = parseFloat(valB); if (!isNaN(numA) && !isNaN(numB)) { return direction === "asc" ? numA - numB : numB - numA; } return direction === "asc" ? valA.localeCompare(valB, "de") : valB.localeCompare(valA, "de"); }); this.currentPage = 1; this.render(); } bindSearch() { if (this.searchInput === null) { return; } const cleanup = eventAdapter.on( this.searchInput, "input", () => { this.currentPage = 1; this.applyFilters(); }, this.deps, "DATATABLE_SEARCH", "datatable" ); this.cleanupFunctions.push(cleanup); } bindFilters() { Object.entries(this.filters).forEach(([id, _columnIndex]) => { const select = domAdapter.getElementById(id); if (select !== null) { const cleanup = eventAdapter.on( select, "change", () => { this.currentPage = 1; this.applyFilters(); }, this.deps, "DATATABLE_FILTER", "datatable" ); this.cleanupFunctions.push(cleanup); } }); } applyFilters() { const searchTerm = this.searchInput !== null ? this.searchInput.value.toLowerCase() : ""; this.rows.forEach((row) => { let visible = true; if (searchTerm !== "") { visible = row.data.some((cell) => cell.includes(searchTerm)); } Object.entries(this.filters).forEach(([id, columnIndex]) => { const select = domAdapter.getElementById(id); if (select !== null && select.value !== "") { visible = visible && row.data[columnIndex] === select.value.toLowerCase(); } }); row.visible = visible; }); this.render(); } createPaginationUI() { this.paginationContainer = domAdapter.createElement("div"); domAdapter.addClass(this.paginationContainer, "pagination"); this.table.parentNode.insertBefore( this.paginationContainer, this.table.nextSibling ); } getVisibleRows() { return this.rows.filter((row) => row.visible !== false); } getTotalPages() { const visibleRows = this.getVisibleRows(); return Math.ceil(visibleRows.length / this.pageSize); } goToPage(page) { const totalPages = this.getTotalPages(); if (page < 1) { this.currentPage = 1; } else if (page > totalPages) { this.currentPage = totalPages; } else { this.currentPage = page; } this.render(); } render() { domAdapter.setInnerHTML(this.tbody, ""); const visibleRows = this.getVisibleRows(); const totalPages = this.getTotalPages(); if (this.currentPage > totalPages && totalPages > 0) { this.currentPage = totalPages; } const startIndex = (this.currentPage - 1) * this.pageSize; const endIndex = startIndex + this.pageSize; const pageRows = visibleRows.slice(startIndex, endIndex); if (pageRows.length === 0) { const emptyRow = domAdapter.createElement("tr"); domAdapter.setInnerHTML( emptyRow, `Keine Einträge gefunden` ); domAdapter.appendChild(this.tbody, emptyRow); } else { pageRows.forEach((row) => domAdapter.appendChild(this.tbody, row.element)); } this.renderPagination(visibleRows.length, totalPages); } renderPagination(totalItems, totalPages) { // Clear previous pagination domAdapter.setInnerHTML(this.paginationContainer, ""); // Don't show pagination if only one page if (totalPages <= 1) { return; } // Info text const startItem = (this.currentPage - 1) * this.pageSize + 1; const endItem = Math.min(this.currentPage * this.pageSize, totalItems); const info = domAdapter.createElement("span"); domAdapter.addClass(info, "pagination__info"); domAdapter.setTextContent(info, `${startItem}-${endItem} von ${totalItems}`); domAdapter.appendChild(this.paginationContainer, info); // Buttons container const buttons = domAdapter.createElement("span"); domAdapter.addClass(buttons, "pagination__buttons"); // Previous button const prevBtn = domAdapter.createElement("button"); domAdapter.addClass(prevBtn, "pagination__btn"); domAdapter.setTextContent(prevBtn, "←"); if (this.currentPage === 1) { domAdapter.setAttribute(prevBtn, "disabled", "true"); } const prevCleanup = eventAdapter.on( prevBtn, "click", () => this.goToPage(this.currentPage - 1), this.deps, "DATATABLE_PAGE_PREV", "datatable" ); this.cleanupFunctions.push(prevCleanup); domAdapter.appendChild(buttons, prevBtn); // Page numbers const maxButtons = 5; let startPage = Math.max(1, this.currentPage - Math.floor(maxButtons / 2)); const endPage = Math.min(totalPages, startPage + maxButtons - 1); startPage = Math.max(1, endPage - maxButtons + 1); for (let i = startPage; i <= endPage; i++) { const pageBtn = domAdapter.createElement("button"); domAdapter.addClass(pageBtn, "pagination__btn"); if (i === this.currentPage) { domAdapter.addClass(pageBtn, "pagination__btn--active"); } domAdapter.setTextContent(pageBtn, String(i)); const page = i; const pageCleanup = eventAdapter.on( pageBtn, "click", () => this.goToPage(page), this.deps, "DATATABLE_PAGE", "datatable" ); this.cleanupFunctions.push(pageCleanup); domAdapter.appendChild(buttons, pageBtn); } // Next button const nextBtn = domAdapter.createElement("button"); domAdapter.addClass(nextBtn, "pagination__btn"); domAdapter.setTextContent(nextBtn, "→"); if (this.currentPage === totalPages) { domAdapter.setAttribute(nextBtn, "disabled", "true"); } const nextCleanup = eventAdapter.on( nextBtn, "click", () => this.goToPage(this.currentPage + 1), this.deps, "DATATABLE_PAGE_NEXT", "datatable" ); this.cleanupFunctions.push(nextCleanup); domAdapter.appendChild(buttons, nextBtn); domAdapter.appendChild(this.paginationContainer, buttons); } refresh() { this.cacheRows(); this.applyFilters(); } dispose() { this.cleanupFunctions.forEach((cleanup) => cleanup()); this.cleanupFunctions = []; if (this.paginationContainer !== null && this.paginationContainer.parentNode !== null) { this.paginationContainer.parentNode.removeChild(this.paginationContainer); } } } /** * Initialize DataTable module with dependencies. * @param {Object} deps - Injected dependencies * @param {Object} deps.clock - Clock interface { now_epoch_ms_utc: () => number } * @param {Object} deps.logger - Logger interface * @param {Object} deps.ui - UI interface { showMessage, disableFeature, offerRetry } * @param {string} deps.runId - Unique run identifier * @returns {Object} DataTable factory */ export function init(deps) { return { /** * Creates a new DataTable instance. * @param {string} tableId - ID of the table element * @param {Object} options - Configuration options * @param {string} [options.searchInput] - ID of search input element * @param {Object} [options.filters] - Filter configuration { selectId: columnIndex } * @param {number} [options.pageSize=20] - Number of rows per page * @returns {DataTable} DataTable instance */ create: (tableId, options = {}) => new DataTable(tableId, options, deps) }; }