|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export class EnhancedTable { |
|
|
constructor(containerId, options = {}) { |
|
|
this.container = document.getElementById(containerId); |
|
|
this.options = { |
|
|
columns: options.columns || [], |
|
|
data: options.data || [], |
|
|
sortable: options.sortable !== false, |
|
|
filterable: options.filterable !== false, |
|
|
paginated: options.paginated !== false, |
|
|
pageSize: options.pageSize || 10, |
|
|
emptyMessage: options.emptyMessage || 'No data available', |
|
|
onRowClick: options.onRowClick || null, |
|
|
...options |
|
|
}; |
|
|
|
|
|
this.currentPage = 1; |
|
|
this.sortColumn = null; |
|
|
this.sortDirection = 'asc'; |
|
|
this.filterQuery = ''; |
|
|
this.filteredData = []; |
|
|
|
|
|
this.init(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
init() { |
|
|
if (!this.container) { |
|
|
console.error('[EnhancedTable] Container not found'); |
|
|
return; |
|
|
} |
|
|
|
|
|
this.filterData(); |
|
|
this.render(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setData(data) { |
|
|
this.options.data = data || []; |
|
|
this.currentPage = 1; |
|
|
this.filterData(); |
|
|
this.render(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
filterData() { |
|
|
if (!this.filterQuery) { |
|
|
this.filteredData = [...this.options.data]; |
|
|
} else { |
|
|
const query = this.filterQuery.toLowerCase(); |
|
|
this.filteredData = this.options.data.filter(row => { |
|
|
return this.options.columns.some(col => { |
|
|
const value = this.getCellValue(row, col.field); |
|
|
return String(value).toLowerCase().includes(query); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
if (this.sortColumn) { |
|
|
this.applySorting(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
applySorting() { |
|
|
const column = this.options.columns.find(col => col.field === this.sortColumn); |
|
|
if (!column) return; |
|
|
|
|
|
this.filteredData.sort((a, b) => { |
|
|
const aVal = this.getCellValue(a, this.sortColumn); |
|
|
const bVal = this.getCellValue(b, this.sortColumn); |
|
|
|
|
|
let comparison = 0; |
|
|
|
|
|
if (typeof aVal === 'number' && typeof bVal === 'number') { |
|
|
comparison = aVal - bVal; |
|
|
} else { |
|
|
comparison = String(aVal).localeCompare(String(bVal)); |
|
|
} |
|
|
|
|
|
return this.sortDirection === 'asc' ? comparison : -comparison; |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getCellValue(row, field) { |
|
|
if (typeof field === 'function') { |
|
|
return field(row); |
|
|
} |
|
|
return row[field]; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
render() { |
|
|
if (!this.container) return; |
|
|
|
|
|
const html = ` |
|
|
${this.options.filterable ? this.renderFilterBar() : ''} |
|
|
<div class="table-wrapper"> |
|
|
${this.filteredData.length === 0 ? this.renderEmpty() : this.renderTable()} |
|
|
</div> |
|
|
${this.options.paginated ? this.renderPagination() : ''} |
|
|
`; |
|
|
|
|
|
this.container.innerHTML = html; |
|
|
this.attachEventListeners(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderFilterBar() { |
|
|
return ` |
|
|
<div class="table-filter-bar"> |
|
|
<div class="search-wrapper"> |
|
|
<svg class="search-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
|
<circle cx="11" cy="11" r="8"></circle> |
|
|
<path d="m21 21-4.35-4.35"></path> |
|
|
</svg> |
|
|
<input |
|
|
type="text" |
|
|
class="table-search-input" |
|
|
placeholder="Search..." |
|
|
value="${this.filterQuery}" |
|
|
data-action="filter" |
|
|
> |
|
|
</div> |
|
|
<div class="table-info"> |
|
|
Showing ${this.filteredData.length} of ${this.options.data.length} items |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderTable() { |
|
|
const start = (this.currentPage - 1) * this.options.pageSize; |
|
|
const end = this.options.paginated ? start + this.options.pageSize : this.filteredData.length; |
|
|
const pageData = this.filteredData.slice(start, end); |
|
|
|
|
|
return ` |
|
|
<table class="enhanced-table"> |
|
|
<thead> |
|
|
<tr> |
|
|
${this.options.columns.map(col => this.renderHeaderCell(col)).join('')} |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody> |
|
|
${pageData.map((row, index) => this.renderRow(row, start + index)).join('')} |
|
|
</tbody> |
|
|
</table> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderHeaderCell(column) { |
|
|
const sortable = this.options.sortable && column.sortable !== false; |
|
|
const isSorted = this.sortColumn === column.field; |
|
|
const sortIcon = isSorted |
|
|
? (this.sortDirection === 'asc' ? '↑' : '↓') |
|
|
: ''; |
|
|
|
|
|
return ` |
|
|
<th |
|
|
class="${sortable ? 'sortable' : ''} ${isSorted ? 'sorted' : ''}" |
|
|
data-field="${column.field}" |
|
|
data-action="${sortable ? 'sort' : ''}" |
|
|
style="${column.width ? `width: ${column.width}` : ''}" |
|
|
> |
|
|
<div class="th-content"> |
|
|
<span>${column.label}</span> |
|
|
${sortable ? `<span class="sort-icon">${sortIcon}</span>` : ''} |
|
|
</div> |
|
|
</th> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderRow(row, index) { |
|
|
const clickable = this.options.onRowClick ? 'clickable' : ''; |
|
|
|
|
|
return ` |
|
|
<tr class="${clickable}" data-index="${index}" data-action="${this.options.onRowClick ? 'row-click' : ''}"> |
|
|
${this.options.columns.map(col => this.renderCell(row, col)).join('')} |
|
|
</tr> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderCell(row, column) { |
|
|
const value = this.getCellValue(row, column.field); |
|
|
const formatted = column.formatter ? column.formatter(value, row) : value; |
|
|
|
|
|
return ` |
|
|
<td class="${column.className || ''}"> |
|
|
${formatted} |
|
|
</td> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderEmpty() { |
|
|
return ` |
|
|
<div class="table-empty-state"> |
|
|
<div class="empty-icon">📋</div> |
|
|
<div class="empty-message">${this.options.emptyMessage}</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
renderPagination() { |
|
|
const totalPages = Math.ceil(this.filteredData.length / this.options.pageSize); |
|
|
|
|
|
if (totalPages <= 1) return ''; |
|
|
|
|
|
const pages = this.getPaginationPages(totalPages); |
|
|
|
|
|
return ` |
|
|
<div class="table-pagination"> |
|
|
<button |
|
|
class="pagination-btn" |
|
|
data-action="prev-page" |
|
|
${this.currentPage === 1 ? 'disabled' : ''} |
|
|
> |
|
|
← Previous |
|
|
</button> |
|
|
|
|
|
<div class="pagination-pages"> |
|
|
${pages.map(page => { |
|
|
if (page === '...') { |
|
|
return '<span class="pagination-ellipsis">...</span>'; |
|
|
} |
|
|
return ` |
|
|
<button |
|
|
class="pagination-page ${page === this.currentPage ? 'active' : ''}" |
|
|
data-action="goto-page" |
|
|
data-page="${page}" |
|
|
> |
|
|
${page} |
|
|
</button> |
|
|
`; |
|
|
}).join('')} |
|
|
</div> |
|
|
|
|
|
<button |
|
|
class="pagination-btn" |
|
|
data-action="next-page" |
|
|
${this.currentPage === totalPages ? 'disabled' : ''} |
|
|
> |
|
|
Next → |
|
|
</button> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getPaginationPages(totalPages) { |
|
|
const delta = 2; |
|
|
const pages = []; |
|
|
|
|
|
for (let i = 1; i <= totalPages; i++) { |
|
|
if ( |
|
|
i === 1 || |
|
|
i === totalPages || |
|
|
(i >= this.currentPage - delta && i <= this.currentPage + delta) |
|
|
) { |
|
|
pages.push(i); |
|
|
} else if (pages[pages.length - 1] !== '...') { |
|
|
pages.push('...'); |
|
|
} |
|
|
} |
|
|
|
|
|
return pages; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
attachEventListeners() { |
|
|
this.container.addEventListener('click', (e) => { |
|
|
const action = e.target.closest('[data-action]')?.dataset.action; |
|
|
|
|
|
if (action === 'sort') { |
|
|
this.handleSort(e); |
|
|
} else if (action === 'prev-page') { |
|
|
this.handlePrevPage(); |
|
|
} else if (action === 'next-page') { |
|
|
this.handleNextPage(); |
|
|
} else if (action === 'goto-page') { |
|
|
this.handleGotoPage(e); |
|
|
} else if (action === 'row-click') { |
|
|
this.handleRowClick(e); |
|
|
} |
|
|
}); |
|
|
|
|
|
this.container.addEventListener('input', (e) => { |
|
|
if (e.target.dataset.action === 'filter') { |
|
|
this.handleFilter(e); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleSort(e) { |
|
|
const th = e.target.closest('th'); |
|
|
const field = th.dataset.field; |
|
|
|
|
|
if (this.sortColumn === field) { |
|
|
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; |
|
|
} else { |
|
|
this.sortColumn = field; |
|
|
this.sortDirection = 'asc'; |
|
|
} |
|
|
|
|
|
this.filterData(); |
|
|
this.render(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleFilter(e) { |
|
|
this.filterQuery = e.target.value; |
|
|
this.currentPage = 1; |
|
|
this.filterData(); |
|
|
this.render(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handlePrevPage() { |
|
|
if (this.currentPage > 1) { |
|
|
this.currentPage--; |
|
|
this.render(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleNextPage() { |
|
|
const totalPages = Math.ceil(this.filteredData.length / this.options.pageSize); |
|
|
if (this.currentPage < totalPages) { |
|
|
this.currentPage++; |
|
|
this.render(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleGotoPage(e) { |
|
|
const page = parseInt(e.target.dataset.page); |
|
|
if (page && page !== this.currentPage) { |
|
|
this.currentPage = page; |
|
|
this.render(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
handleRowClick(e) { |
|
|
const row = e.target.closest('tr'); |
|
|
const index = parseInt(row.dataset.index); |
|
|
const data = this.filteredData[index]; |
|
|
|
|
|
if (this.options.onRowClick && data) { |
|
|
this.options.onRowClick(data, index); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
destroy() { |
|
|
if (this.container) { |
|
|
this.container.innerHTML = ''; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
export default EnhancedTable; |
|
|
|