Last active
October 21, 2024 12:59
-
-
Save OnurGumus/61507948a0f7c179426194b776127851 to your computer and use it in GitHub Desktop.
draggable table
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>Interactive Draggable Table with Resizable and Reorderable Columns</title> | |
<style> | |
/* General table styles */ | |
table.draggable-table { | |
border-collapse: collapse; | |
width: 100%; | |
user-select: none; | |
/* Prevent text selection during dragging */ | |
table-layout: fixed; | |
/* Enable fixed table layout for column resizing */ | |
} | |
table.draggable-table th, | |
table.draggable-table td { | |
border: 1px solid #ccc; | |
padding: 8px; | |
position: relative; | |
cursor: pointer; | |
overflow: hidden; | |
/* Prevent content overflow when resizing */ | |
word-wrap: break-word; | |
} | |
.focused { | |
outline: 2px solid blue; | |
} | |
table.draggable-table th.selected, | |
table.draggable-table td.selected { | |
background-color: #b3d4fc; | |
} | |
table.draggable-table th.sort-asc::after { | |
content: ' ▲'; | |
font-size: 12px; | |
} | |
table.draggable-table th.sort-desc::after { | |
content: ' ▼'; | |
font-size: 12px; | |
} | |
table.draggable-table th .filter-container { | |
display: flex; | |
flex-direction: column; | |
margin-top: 4px; | |
} | |
table.draggable-table th .filter-select { | |
margin-bottom: 4px; | |
padding: 2px; | |
font-size: 12px; | |
} | |
table.draggable-table th .filter-input { | |
width: 100%; | |
padding: 2px; | |
font-size: 12px; | |
box-sizing: border-box; | |
} | |
table.draggable-table th.dragging, | |
table.draggable-table td.dragging { | |
opacity: 0.5; | |
} | |
/* Resizer styles */ | |
.column-resizer { | |
position: absolute; | |
right: 0; | |
top: 0; | |
bottom: 0; | |
width: 5px; | |
cursor: col-resize; | |
user-select: none; | |
} | |
/* Grouping Area Styles */ | |
#grouping-area { | |
border: 2px dashed #ccc; | |
padding: 10px; | |
margin-bottom: 10px; | |
min-height: 40px; | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
} | |
#grouping-area.active { | |
border-color: #66afe9; | |
background-color: #e6f7ff; | |
} | |
.group-tag { | |
background-color: #d9edf7; | |
border: 1px solid #bce8f1; | |
border-radius: 4px; | |
padding: 4px 8px; | |
display: flex; | |
align-items: center; | |
gap: 4px; | |
} | |
.group-tag .remove-group { | |
cursor: pointer; | |
color: #a94442; | |
font-weight: bold; | |
} | |
/* Group Header Styles */ | |
.group-header { | |
background-color: #f5f5f5; | |
cursor: pointer; | |
font-weight: bold; | |
} | |
.group-header .toggle-group { | |
margin-right: 8px; | |
cursor: pointer; | |
font-weight: normal; | |
} | |
.hidden { | |
display: none; | |
} | |
</style> | |
</head> | |
<body> | |
<!-- Grouping Area --> | |
<div id="grouping-area"> | |
<span>Drag column headers here to group</span> | |
</div> | |
<!-- Draggable Table --> | |
<table is="draggable-table" class="draggable-table" id="myTable"> | |
<thead> | |
<tr> | |
<th draggable="true">Header 1<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 2<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 3<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 4<div class="column-resizer"></div> | |
</th> | |
</tr> | |
<tr class="filter-row"> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 1"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 2"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 3"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 4"> | |
</div> | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr> | |
<td>Apple</td> | |
<td>Red</td> | |
<td>10</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Banana</td> | |
<td>Yellow</td> | |
<td>5</td> | |
<td>No</td> | |
</tr> | |
<tr> | |
<td>Cherry</td> | |
<td>Red</td> | |
<td>20</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Date</td> | |
<td>Brown</td> | |
<td>15</td> | |
<td>No</td> | |
</tr> | |
<tr> | |
<td>Apple</td> | |
<td>Green</td> | |
<td>12</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Banana</td> | |
<td>Green</td> | |
<td>7</td> | |
<td>No</td> | |
</tr> | |
<!-- Add more rows as needed --> | |
</tbody> | |
</table> | |
<script> | |
class DraggableTable extends HTMLTableElement { | |
constructor() { | |
super(); | |
// Instantiate managers | |
this.isResizing = false; | |
this.isDraggingColumns = false; | |
this.preventClick = false; | |
this.sortState = {}; | |
this.filters = []; | |
this.groupedColumns = []; // Moved groupedColumns here | |
this.groupData = {}; // Moved groupData here | |
this.originalRows = []; | |
// Managers | |
this.selectionManager = new SelectionManager(this); | |
this.resizeManager = new ResizeManager(this); | |
this.dragManager = new DragManager(this); | |
this.sortingManager = new SortingManager(this); | |
this.filteringManager = new FilteringManager(this); | |
this.groupingManager = new GroupingManager(this); | |
this.navigationManager = new NavigationManager(this); | |
} | |
connectedCallback() { | |
// Initialize filters array based on number of columns | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const columns = headerRow.children.length; | |
this.filters = Array(columns).fill({ | |
operator: 'contains', | |
value: '' | |
}); | |
// Store original rows and initial row order | |
const tbody = this.querySelector('tbody'); | |
this.originalRows = Array.from(tbody.querySelectorAll('tr')); | |
// Assign original index to each row | |
this.originalRows.forEach((row, index) => { | |
row.originalIndex = index; | |
}); | |
// Store a copy of the initial row order | |
this.initialRowsOrder = Array.from(this.originalRows); | |
// Call connectedCallback of each manager | |
this.selectionManager.connectedCallback(); | |
this.resizeManager.connectedCallback(); | |
this.dragManager.connectedCallback(); | |
this.sortingManager.connectedCallback(); | |
this.filteringManager.connectedCallback(); | |
this.groupingManager.connectedCallback(); | |
this.navigationManager.connectedCallback(); | |
} | |
disconnectedCallback() { | |
// Call disconnectedCallback of each manager | |
this.selectionManager.disconnectedCallback(); | |
this.resizeManager.disconnectedCallback(); | |
this.dragManager.disconnectedCallback(); | |
this.sortingManager.disconnectedCallback(); | |
this.filteringManager.disconnectedCallback(); | |
this.groupingManager.disconnectedCallback(); | |
this.navigationManager.disconnectedCallback(); | |
} | |
/* Utility to Get Number of Columns */ | |
columnsCount() { | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
return headerRow.children.length; | |
} | |
/* Reorder Columns */ | |
reorderColumns(sourceIndex, targetIndex) { | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const filterRow = this.querySelector('thead tr.filter-row'); | |
const tbody = this.querySelector('tbody'); | |
// Reorder headers | |
const headerCells = Array.from(headerRow.children); | |
const filterCells = Array.from(filterRow.children); | |
const sourceHeader = headerCells[sourceIndex]; | |
const sourceFilter = filterCells[sourceIndex]; | |
headerRow.removeChild(sourceHeader); | |
filterRow.removeChild(sourceFilter); | |
if (targetIndex < headerCells.length) { | |
headerRow.insertBefore(sourceHeader, headerRow.children[targetIndex]); | |
filterRow.insertBefore(sourceFilter, filterRow.children[targetIndex]); | |
} else { | |
headerRow.appendChild(sourceHeader); | |
filterRow.appendChild(sourceFilter); | |
} | |
// Reorder cells in each row | |
const allRows = this.originalRows; | |
allRows.forEach(row => { | |
const cells = Array.from(row.children); | |
const sourceCell = cells[sourceIndex]; | |
row.removeChild(sourceCell); | |
if (targetIndex < cells.length) { | |
row.insertBefore(sourceCell, row.children[targetIndex]); | |
} else { | |
row.appendChild(sourceCell); | |
} | |
}); | |
// Update filters, sortState, and groupedColumns | |
this.updateColumnIndices(); | |
// Reapply grouping and filtering | |
this.filteringManager.applyFilters(); | |
} | |
updateColumnIndices() { | |
// Update data-col-index attributes | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const filterRow = this.querySelector('thead tr.filter-row'); | |
const headerCells = Array.from(headerRow.children); | |
const filterCells = Array.from(filterRow.children); | |
// Build mapping from original index to new index | |
const oldIndexToNewIndex = {}; | |
headerCells.forEach((th, newIndex) => { | |
const originalIndex = parseInt(th.getAttribute('data-original-index'), 10); | |
th.setAttribute('data-col-index', newIndex); | |
oldIndexToNewIndex[originalIndex] = newIndex; | |
}); | |
// Update data-col-index for filter cells and inputs | |
filterCells.forEach((th, index) => { | |
th.setAttribute('data-col-index', index); | |
const select = th.querySelector('.filter-select'); | |
const input = th.querySelector('.filter-input'); | |
if (select && input) { | |
select.setAttribute('data-col-index', index); | |
input.setAttribute('data-col-index', index); | |
} | |
}); | |
// Update filters array based on data-original-index | |
const newFilters = []; | |
headerCells.forEach((th, index) => { | |
const originalIndex = parseInt(th.getAttribute('data-original-index'), 10); | |
newFilters[index] = this.filters[originalIndex] || { | |
operator: 'contains', | |
value: '' | |
}; | |
}); | |
this.filters = newFilters; | |
// Update sortState based on data-original-index | |
const newSortState = {}; | |
for (let key in this.sortState) { | |
const originalKey = parseInt(key, 10); | |
const newKey = oldIndexToNewIndex[originalKey]; | |
if (newKey !== undefined) { | |
newSortState[newKey] = this.sortState[key]; | |
} | |
} | |
this.sortState = newSortState; | |
// Update groupedColumns based on data-original-index | |
this.groupedColumns = this.groupedColumns.map(originalIndex => oldIndexToNewIndex[originalIndex]); | |
// Re-attach event listeners for headers | |
this.sortingManager.attachHeaderEvents(); | |
this.filteringManager.attachFilterEvents(); | |
// Re-render grouping tags | |
this.groupingManager.renderGroupingTags(); | |
} | |
} | |
class SelectionManager { | |
constructor(table) { | |
this.table = table; | |
this.isMouseDown = false; | |
this.startCell = null; | |
this.endCell = null; | |
this.selectedCells = new Set(); | |
// Bind methods | |
this.handleMouseDown = this.handleMouseDown.bind(this); | |
this.handleMouseOver = this.handleMouseOver.bind(this); | |
this.handleMouseUp = this.handleMouseUp.bind(this); | |
this.handleCopy = this.handleCopy.bind(this); | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); | |
} | |
connectedCallback() { | |
this.table.addEventListener('mousedown', this.handleMouseDown); | |
this.table.addEventListener('mouseover', this.handleMouseOver); | |
document.addEventListener('mouseup', this.handleMouseUp); | |
document.addEventListener('copy', this.handleCopy); | |
// Add event listeners for Escape key and clicks outside tbody | |
document.addEventListener('keydown', this.handleKeyDown); | |
document.addEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
disconnectedCallback() { | |
this.table.removeEventListener('mousedown', this.handleMouseDown); | |
this.table.removeEventListener('mouseover', this.handleMouseOver); | |
document.removeEventListener('mouseup', this.handleMouseUp); | |
document.removeEventListener('copy', this.handleCopy); | |
// Remove event listeners | |
document.removeEventListener('keydown', this.handleKeyDown); | |
document.removeEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
handleMouseDown(event) { | |
if (this.table.isResizing) return; | |
if (event.target.tagName === 'TH' && event.target.draggable) { | |
this.table.isDraggingColumns = true; | |
return; // Prevent cell selection | |
} | |
if (this.table.isDraggingColumns) return; // Just in case | |
// Ignore clicks on filter row cells | |
const cell = event.target.closest('td, th'); | |
if (cell && cell.parentElement.classList.contains('filter-row')) { | |
return; | |
} | |
if (cell && (cell.tagName === 'TD' || cell.tagName === 'TH')) { | |
this.isMouseDown = true; | |
this.clearSelection(); | |
this.startCell = cell; | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
} | |
handleMouseOver(event) { | |
if (this.table.isResizing || this.table.isDraggingColumns) return; // Prevent selection during resizing or dragging | |
const cell = event.target.closest('td, th'); | |
if (this.isMouseDown && cell && !cell.parentElement.classList.contains('filter-row')) { | |
if (cell.tagName === 'TD' || cell.tagName === 'TH') { | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
} | |
} | |
handleMouseUp(event) { | |
if (this.isMouseDown) { | |
this.isMouseDown = false; | |
} | |
if (this.table.isDraggingColumns) { | |
this.table.isDraggingColumns = false; | |
} | |
} | |
handleCopy(event) { | |
if (this.selectedCells.size === 0) return; | |
event.preventDefault(); | |
const data = this.getSelectedData(); | |
event.clipboardData.setData('text/plain', data); | |
} | |
handleKeyDown(event) { | |
if (event.key === 'Escape') { | |
this.clearSelection(); | |
// Clear focus as well | |
if (this.table.navigationManager) { | |
this.table.navigationManager.clearFocus(); | |
} | |
} | |
} | |
handleDocumentMouseDown(event) { | |
const tbody = this.table.querySelector('tbody'); | |
if (!tbody.contains(event.target)) { | |
this.clearSelection(); | |
// Clear focus as well | |
if (this.table.navigationManager) { | |
this.table.navigationManager.clearFocus(); | |
} | |
} | |
} | |
clearSelection() { | |
this.selectedCells.forEach(cell => cell.classList.remove('selected')); | |
this.selectedCells.clear(); | |
} | |
updateSelection() { | |
if (!this.startCell || !this.endCell) return; | |
this.clearSelection(); | |
const cells = this.getCellsInRange(this.startCell, this.endCell); | |
cells.forEach(cell => { | |
cell.classList.add('selected'); | |
this.selectedCells.add(cell); | |
}); | |
} | |
getCellsInRange(start, end) { | |
const startRow = start.parentElement.rowIndex; | |
const startCol = start.cellIndex; | |
const endRow = end.parentElement.rowIndex; | |
const endCol = end.cellIndex; | |
const topRow = Math.min(startRow, endRow); | |
const bottomRow = Math.max(startRow, endRow); | |
const leftCol = Math.min(startCol, endCol); | |
const rightCol = Math.max(startCol, endCol); | |
const cells = []; | |
const rows = this.table.rows; | |
for (let r = topRow; r <= bottomRow; r++) { | |
const row = rows[r]; | |
// Skip filter row | |
if (row.classList.contains('filter-row')) continue; | |
for (let c = leftCol; c <= rightCol; c++) { | |
const cell = row.cells[c]; | |
if (cell) cells.push(cell); | |
} | |
} | |
return cells; | |
} | |
getSelectedData() { | |
if (this.selectedCells.size === 0) return ''; | |
// Organize cells by rows | |
const rowsMap = new Map(); | |
this.selectedCells.forEach(cell => { | |
const rowIndex = cell.parentElement.rowIndex; | |
if (!rowsMap.has(rowIndex)) rowsMap.set(rowIndex, []); | |
rowsMap.get(rowIndex).push(cell.innerText); | |
}); | |
// Sort rows | |
const sortedRows = Array.from(rowsMap.keys()).sort((a, b) => a - b); | |
// Create tab-separated values | |
const data = sortedRows.map(rowIndex => { | |
const cells = rowsMap.get(rowIndex); | |
return cells.join('\t'); | |
}).join('\n'); | |
return data; | |
} | |
} | |
class ResizeManager { | |
constructor(table) { | |
this.table = table; | |
this.isResizing = false; | |
this.resizingColumn = null; | |
this.startX = 0; | |
this.startWidth = 0; | |
// Bind methods | |
this.handleResizeMouseDown = this.handleResizeMouseDown.bind(this); | |
this.handleResizeMouseMove = this.handleResizeMouseMove.bind(this); | |
this.handleResizeMouseUp = this.handleResizeMouseUp.bind(this); | |
} | |
connectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
const resizer = th.querySelector('.column-resizer'); | |
if (resizer) { | |
resizer.addEventListener('mousedown', (event) => this.handleResizeMouseDown(event, th)); | |
} | |
}); | |
document.addEventListener('mousemove', this.handleResizeMouseMove); | |
document.addEventListener('mouseup', this.handleResizeMouseUp); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
const resizer = th.querySelector('.column-resizer'); | |
if (resizer) { | |
resizer.removeEventListener('mousedown', this.handleResizeMouseDown); | |
} | |
}); | |
document.removeEventListener('mousemove', this.handleResizeMouseMove); | |
document.removeEventListener('mouseup', this.handleResizeMouseUp); | |
} | |
handleResizeMouseDown(event, th) { | |
event.preventDefault(); | |
event.stopPropagation(); // Prevent the event from bubbling up to the header | |
this.isResizing = true; | |
this.table.isResizing = true; | |
this.resizingColumn = th; | |
this.startX = event.pageX; | |
this.startWidth = th.offsetWidth; | |
} | |
handleResizeMouseMove(event) { | |
if (!this.isResizing) return; | |
const deltaX = event.pageX - this.startX; | |
const newWidth = this.startWidth + deltaX; | |
if (newWidth > 30) { // Minimum column width | |
this.resizingColumn.style.width = newWidth + 'px'; | |
} | |
} | |
handleResizeMouseUp(event) { | |
if (this.isResizing) { | |
this.isResizing = false; | |
this.table.isResizing = false; | |
this.resizingColumn = null; | |
this.table.preventClick = true; // Add this line | |
} | |
} | |
} | |
class DragManager { | |
constructor(table) { | |
this.table = table; | |
this.isDraggingColumns = false; | |
// Bind methods | |
this.handleDragStart = this.handleDragStart.bind(this); | |
this.handleDragOver = this.handleDragOver.bind(this); | |
this.handleDrop = this.handleDrop.bind(this); | |
this.handleDragEnd = this.handleDragEnd.bind(this); | |
this.handleHeaderDragOver = this.handleHeaderDragOver.bind(this); | |
this.handleHeaderDrop = this.handleHeaderDrop.bind(this); | |
this.handleHeaderRowDragOver = this.handleHeaderRowDragOver.bind(this); | |
this.handleHeaderRowDrop = this.handleHeaderRowDrop.bind(this); | |
} | |
connectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.addEventListener('dragstart', this.handleDragStart); | |
th.addEventListener('dragover', this.handleHeaderDragOver); | |
th.addEventListener('drop', this.handleHeaderDrop); | |
th.addEventListener('dragend', this.handleDragEnd); | |
}); | |
// Add event listeners to the entire header row to handle drops beyond the last column | |
headerRow.parentElement.addEventListener('dragover', this.handleHeaderRowDragOver); | |
headerRow.parentElement.addEventListener('drop', this.handleHeaderRowDrop); | |
// Setup grouping area | |
this.setupGroupingArea(); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.removeEventListener('dragstart', this.handleDragStart); | |
th.removeEventListener('dragover', this.handleHeaderDragOver); | |
th.removeEventListener('drop', this.handleHeaderDrop); | |
th.removeEventListener('dragend', this.handleDragEnd); | |
}); | |
headerRow.parentElement.removeEventListener('dragover', this.handleHeaderRowDragOver); | |
headerRow.parentElement.removeEventListener('drop', this.handleHeaderRowDrop); | |
// Remove grouping area event listeners | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.removeEventListener('dragover', this.handleDragOver); | |
groupingArea.removeEventListener('drop', this.handleDrop); | |
} | |
setupGroupingArea() { | |
const groupingArea = document.getElementById('grouping-area'); | |
// Prevent default dragover behavior | |
groupingArea.addEventListener('dragover', this.handleDragOver); | |
groupingArea.addEventListener('drop', this.handleDrop); | |
// Style adjustments | |
groupingArea.style.display = 'flex'; | |
groupingArea.style.flexWrap = 'wrap'; | |
} | |
handleDragStart(event) { | |
const th = event.currentTarget; | |
const columnIndex = parseInt(th.getAttribute('data-col-index'), 10); | |
event.dataTransfer.setData('text/plain', columnIndex); | |
event.dataTransfer.effectAllowed = 'move'; | |
this.isDraggingColumns = true; | |
this.table.isDraggingColumns = true; // Set dragging flag | |
} | |
handleDragEnd(event) { | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag | |
} | |
handleDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.classList.add('active'); | |
} | |
handleDrop(event) { | |
event.preventDefault(); | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.classList.remove('active'); | |
const columnIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
if (!this.table.groupedColumns.includes(columnIndex)) { | |
this.table.groupedColumns.push(columnIndex); | |
this.table.groupingManager.renderGroupingTags(); | |
this.table.groupingManager.applyGrouping(); | |
} | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag | |
} | |
handleHeaderDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
} | |
handleHeaderDrop(event) { | |
event.preventDefault(); | |
const targetTh = event.currentTarget; | |
const targetIndex = parseInt(targetTh.getAttribute('data-col-index'), 10); | |
const sourceIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
if (sourceIndex === targetIndex) return; | |
this.table.reorderColumns(sourceIndex, targetIndex); | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag after drop | |
} | |
// Updated method to handle drag over on the header row | |
handleHeaderRowDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
} | |
// Updated method to handle drop on the header row (beyond the last column) | |
handleHeaderRowDrop(event) { | |
event.preventDefault(); | |
const sourceIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
// Determine if the drop is beyond the last column | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
const rect = headerRow.getBoundingClientRect(); | |
const dropX = event.clientX; | |
// Calculate target index based on drop position | |
let targetIndex = headerRow.children.length; | |
for (let i = 0; i < headerRow.children.length; i++) { | |
const th = headerRow.children[i]; | |
const thRect = th.getBoundingClientRect(); | |
if (dropX < thRect.left + thRect.width / 2) { | |
targetIndex = i; | |
break; | |
} | |
} | |
if (sourceIndex === targetIndex || sourceIndex === targetIndex - 1) return; | |
this.table.reorderColumns(sourceIndex, targetIndex); | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag after drop | |
} | |
} | |
class SortingManager { | |
constructor(table) { | |
this.table = table; | |
// Bind methods | |
this.handleHeaderClick = this.handleHeaderClick.bind(this); | |
} | |
connectedCallback() { | |
this.attachHeaderEvents(); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.removeEventListener('click', this.handleHeaderClick); | |
}); | |
} | |
attachHeaderEvents() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th, index) => { | |
th.setAttribute('data-col-index', index); | |
th.setAttribute('data-original-index', index); // Set original index | |
th.removeEventListener('click', this.handleHeaderClick); | |
th.addEventListener('click', this.handleHeaderClick); | |
}); | |
} | |
handleHeaderClick(event) { | |
if ( | |
event.target.closest('.filter-container') || | |
event.target.closest('.group-tag') || | |
event.target.classList.contains('column-resizer') || | |
this.table.isResizing || | |
this.table.preventClick | |
) { | |
this.table.preventClick = false; // Reset preventClick | |
return; | |
} | |
const th = event.currentTarget; | |
const columnIndex = parseInt(th.getAttribute('data-col-index'), 10); | |
// Determine the new sort direction | |
const currentSort = this.table.sortState[columnIndex] || 'none'; | |
let newSort; | |
if (currentSort === 'none') { | |
newSort = 'asc'; | |
} else if (currentSort === 'asc') { | |
newSort = 'desc'; | |
} else { | |
newSort = 'none'; | |
} | |
// Update sortState | |
if (newSort === 'none') { | |
delete this.table.sortState[columnIndex]; | |
} else { | |
this.table.sortState = {}; // Reset other sorts | |
this.table.sortState[columnIndex] = newSort; | |
} | |
// Remove existing sort indicators | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.classList.remove('sort-asc', 'sort-desc', 'sorted'); | |
}); | |
// Add sort indicator to the current column | |
const currentTh = headerRow.children[columnIndex]; | |
if (newSort !== 'none') { | |
currentTh.classList.add(newSort === 'asc' ? 'sort-asc' : 'sort-desc'); | |
currentTh.classList.add('sorted'); | |
} | |
// Perform the sorting | |
this.sortTable(columnIndex, newSort); | |
} | |
sortTable(columnIndex, direction) { | |
let rowsToDisplay; | |
// Get the rows that are currently visible after filtering | |
const visibleRows = this.table.originalRows.filter(row => row.style.display !== 'none'); | |
if (direction === 'none') { | |
// Restore the original row order using initialRowsOrder | |
rowsToDisplay = this.table.initialRowsOrder.filter(row => visibleRows.includes(row)); | |
} else { | |
// Sort the visible rows | |
rowsToDisplay = Array.from(visibleRows).sort((a, b) => { | |
const cellA = a.cells[columnIndex].innerText.trim().toLowerCase(); | |
const cellB = b.cells[columnIndex].innerText.trim().toLowerCase(); | |
// Attempt numerical comparison | |
const numA = parseFloat(cellA); | |
const numB = parseFloat(cellB); | |
let comparison = 0; | |
if (!isNaN(numA) && !isNaN(numB)) { | |
comparison = numA - numB; | |
} else { | |
if (cellA < cellB) comparison = -1; | |
if (cellA > cellB) comparison = 1; | |
} | |
return direction === 'asc' ? comparison : -comparison; | |
}); | |
} | |
// Re-apply grouping or render sorted rows | |
if (this.table.groupedColumns.length > 0) { | |
// Update originalRows with the sorted order | |
this.table.originalRows = rowsToDisplay; | |
this.table.groupingManager.applyGrouping(); | |
} else { | |
const tbody = this.table.querySelector('tbody'); | |
tbody.innerHTML = ''; | |
rowsToDisplay.forEach(row => { | |
tbody.appendChild(row); | |
}); | |
} | |
} | |
} | |
class FilteringManager { | |
constructor(table) { | |
this.table = table; | |
// Bind methods | |
this.handleFilterInput = this.handleFilterInput.bind(this); | |
} | |
connectedCallback() { | |
this.attachFilterEvents(); | |
} | |
disconnectedCallback() { | |
const filterRow = this.table.querySelector('thead tr.filter-row'); | |
filterRow.querySelectorAll('.filter-container').forEach((container) => { | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
select.removeEventListener('change', this.handleFilterInput); | |
input.removeEventListener('input', this.handleFilterInput); | |
}); | |
} | |
attachFilterEvents() { | |
const filterRow = this.table.querySelector('thead tr.filter-row'); | |
filterRow.querySelectorAll('.filter-container').forEach((container, index) => { | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
select.setAttribute('data-col-index', index); | |
input.setAttribute('data-col-index', index); | |
select.removeEventListener('change', this.handleFilterInput); | |
input.removeEventListener('input', this.handleFilterInput); | |
select.addEventListener('change', this.handleFilterInput); | |
input.addEventListener('input', this.handleFilterInput); | |
}); | |
} | |
handleFilterInput(event) { | |
const element = event.target; | |
const container = element.closest('.filter-container'); | |
const index = parseInt(element.getAttribute('data-col-index'), 10); | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
const operator = select.value; | |
const value = input.value.trim().toLowerCase(); | |
this.table.filters[index] = { | |
operator, | |
value | |
}; | |
this.applyFilters(); | |
} | |
applyFilters() { | |
// Apply filters to original rows | |
this.table.originalRows.forEach(row => { | |
let visible = true; | |
this.table.filters.forEach((filter, index) => { | |
if (filter.value) { | |
const cellText = row.cells[index].innerText.trim().toLowerCase(); | |
const filterValue = filter.value; | |
switch (filter.operator) { | |
case 'contains': | |
if (!cellText.includes(filterValue)) visible = false; | |
break; | |
case 'startswith': | |
if (!cellText.startsWith(filterValue)) visible = false; | |
break; | |
case 'endswith': | |
if (!cellText.endsWith(filterValue)) visible = false; | |
break; | |
case 'equal': | |
if (cellText !== filterValue) visible = false; | |
break; | |
case 'greaterthan': | |
const cellNumGT = parseFloat(row.cells[index].innerText.trim()); | |
const filterNumGT = parseFloat(filterValue); | |
if (isNaN(cellNumGT) || isNaN(filterNumGT) || cellNumGT <= filterNumGT) { | |
visible = false; | |
} | |
break; | |
case 'lessthan': | |
const cellNumLT = parseFloat(row.cells[index].innerText.trim()); | |
const filterNumLT = parseFloat(filterValue); | |
if (isNaN(cellNumLT) || isNaN(filterNumLT) || cellNumLT >= filterNumLT) { | |
visible = false; | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
}); | |
row.style.display = visible ? '' : 'none'; | |
}); | |
// Re-apply grouping after filtering | |
if (this.table.groupedColumns.length > 0) { | |
this.table.groupingManager.applyGrouping(); | |
} else { | |
// If not grouped, update the table body with filtered rows | |
const tbody = this.table.querySelector('tbody'); | |
tbody.innerHTML = ''; | |
this.table.originalRows.forEach(row => { | |
if (row.style.display !== 'none') { | |
tbody.appendChild(row); | |
} | |
}); | |
} | |
} | |
} | |
class NavigationManager { | |
constructor(table) { | |
this.table = table; | |
this.currentCell = null; | |
// Bind methods | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleCellClick = this.handleCellClick.bind(this); | |
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); | |
} | |
connectedCallback() { | |
// Add event listener for keydown on the table | |
this.table.addEventListener('keydown', this.handleKeyDown, true); // Use capture phase | |
// Add event listener for click on the table | |
this.table.addEventListener('click', this.handleCellClick); | |
// Add event listener for clicks outside the table | |
document.addEventListener('mousedown', this.handleDocumentMouseDown); | |
// Remove tabindex from the table to prevent it from gaining focus | |
this.table.removeAttribute('tabindex'); | |
} | |
disconnectedCallback() { | |
// Remove event listeners | |
this.table.removeEventListener('keydown', this.handleKeyDown, true); | |
this.table.removeEventListener('click', this.handleCellClick); | |
document.removeEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
handleKeyDown(event) { | |
const key = event.key; | |
if (key === 'Escape') { | |
// Clear focus when Escape key is pressed | |
this.clearFocus(); | |
// Also clear selection if SelectionManager is present | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
} | |
return; | |
} | |
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) { | |
return; | |
} | |
// Prevent default scrolling behavior | |
event.preventDefault(); | |
if (!this.currentCell) { | |
return; | |
} | |
let { | |
rowIndex, | |
cellIndex | |
} = this.getCellPosition(this.currentCell); | |
let newRowIndex = rowIndex; | |
let newCellIndex = cellIndex; | |
switch (key) { | |
case 'ArrowUp': | |
newRowIndex = this.findPreviousRowIndex(newRowIndex); | |
break; | |
case 'ArrowDown': | |
newRowIndex = this.findNextRowIndex(newRowIndex); | |
break; | |
case 'ArrowLeft': | |
newCellIndex = Math.max(cellIndex - 1, 0); | |
break; | |
case 'ArrowRight': | |
newCellIndex = Math.min(cellIndex + 1, this.table.rows[newRowIndex].cells.length - 1); | |
break; | |
} | |
const newRow = this.table.rows[newRowIndex]; | |
if (newRow) { | |
const newCell = newRow.cells[newCellIndex]; | |
if (newCell) { | |
this.currentCell = newCell; | |
this.focusCell(newCell); | |
} | |
} | |
} | |
handleCellClick(event) { | |
const cell = event.target.closest('td, th'); | |
// Ignore clicks on filter row cells | |
if (cell && cell.parentElement.classList.contains('filter-row')) { | |
return; | |
} | |
if (cell && this.table.contains(cell)) { | |
this.currentCell = cell; | |
this.focusCell(cell); | |
} | |
} | |
handleDocumentMouseDown(event) { | |
if (!this.table.contains(event.target)) { | |
this.clearFocus(); | |
// Also clear selection if SelectionManager is present | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
} | |
} | |
} | |
getCellPosition(cell) { | |
const row = cell.parentElement; | |
const rowIndex = Array.from(this.table.rows).indexOf(row); | |
const cellIndex = Array.from(row.cells).indexOf(cell); | |
return { | |
rowIndex, | |
cellIndex | |
}; | |
} | |
findPreviousRowIndex(currentIndex) { | |
let newIndex = currentIndex - 1; | |
while (newIndex >= 0) { | |
const row = this.table.rows[newIndex]; | |
if (!row.classList.contains('filter-row')) { | |
return newIndex; | |
} | |
newIndex--; | |
} | |
return currentIndex; // Return current index if no previous row found | |
} | |
findNextRowIndex(currentIndex) { | |
let newIndex = currentIndex + 1; | |
while (newIndex < this.table.rows.length) { | |
const row = this.table.rows[newIndex]; | |
if (!row.classList.contains('filter-row')) { | |
return newIndex; | |
} | |
newIndex++; | |
} | |
return currentIndex; // Return current index if no next row found | |
} | |
focusCell(cell) { | |
// Remove focus class and tabindex from all cells | |
this.table.querySelectorAll('.focused').forEach(c => { | |
c.classList.remove('focused'); | |
c.removeAttribute('tabindex'); | |
}); | |
// Add focus class to the current cell | |
cell.classList.add('focused'); | |
// Set tabindex to make the cell focusable | |
cell.setAttribute('tabindex', '0'); | |
// Focus the cell | |
cell.focus(); | |
// Integrate with SelectionManager to select the focused cell | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
this.table.selectionManager.startCell = cell; | |
this.table.selectionManager.endCell = cell; | |
this.table.selectionManager.updateSelection(); | |
} | |
// Scroll cell into view if necessary | |
cell.scrollIntoView({ | |
block: 'nearest', | |
inline: 'nearest' | |
}); | |
} | |
clearFocus() { | |
if (this.currentCell) { | |
// Remove focus class and tabindex | |
this.currentCell.classList.remove('focused'); | |
this.currentCell.removeAttribute('tabindex'); | |
this.currentCell.blur(); | |
this.currentCell = null; | |
} | |
} | |
} | |
class GroupingManager { | |
constructor(table) { | |
this.table = table; | |
// Remove these lines | |
// this.groupedColumns = table.groupedColumns; | |
// this.groupData = table.groupData; | |
// Bind methods | |
this.renderGroupingTags = this.renderGroupingTags.bind(this); | |
this.applyGrouping = this.applyGrouping.bind(this); | |
} | |
connectedCallback() { | |
this.renderGroupingTags(); | |
} | |
disconnectedCallback() { | |
// No event listeners to remove | |
} | |
renderGroupingTags() { | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.innerHTML = ''; // Clear existing tags | |
if (this.table.groupedColumns.length === 0) { | |
const placeholder = document.createElement('span'); | |
placeholder.textContent = 'Drag column headers here to group'; | |
groupingArea.appendChild(placeholder); | |
// Reset the table to ungrouped state | |
this.applyGrouping(); | |
return; | |
} | |
this.table.groupedColumns.forEach((colIndex) => { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
const th = headerRow.children[colIndex]; | |
const columnName = th.textContent.trim(); | |
const tag = document.createElement('div'); | |
tag.classList.add('group-tag'); | |
tag.setAttribute('data-col-index', colIndex); | |
const span = document.createElement('span'); | |
span.textContent = columnName; | |
tag.appendChild(span); | |
const removeBtn = document.createElement('span'); | |
removeBtn.classList.add('remove-group'); | |
removeBtn.textContent = '×'; | |
removeBtn.addEventListener('click', () => { | |
this.table.groupedColumns = this.table.groupedColumns.filter(index => index !== colIndex); | |
this.renderGroupingTags(); | |
this.applyGrouping(); | |
}); | |
tag.appendChild(removeBtn); | |
groupingArea.appendChild(tag); | |
}); | |
} | |
applyGrouping() { | |
const tbody = this.table.querySelector('tbody'); | |
// If no grouped columns, reset table | |
if (this.table.groupedColumns.length === 0) { | |
tbody.innerHTML = ''; | |
// Append only rows that match the filters (visible rows) | |
this.table.originalRows.forEach(row => { | |
if (row.style.display !== 'none') { | |
tbody.appendChild(row); | |
} | |
}); | |
return; | |
} | |
// Organize rows into groups based on groupedColumns | |
this.groupData = {}; // Use this.groupData here | |
this.table.originalRows.forEach(row => { | |
// Only include visible rows | |
if (row.style.display === 'none') return; | |
let groupKey = ''; | |
this.table.groupedColumns.forEach(colIndex => { | |
groupKey += row.cells[colIndex].innerText.trim() + '||'; | |
}); | |
if (!this.groupData[groupKey]) { | |
this.groupData[groupKey] = []; | |
} | |
this.groupData[groupKey].push(row); | |
}); | |
// Clear tbody | |
tbody.innerHTML = ''; | |
// Render grouped rows | |
Object.keys(this.groupData).forEach(groupKey => { | |
const groupRows = this.groupData[groupKey]; | |
if (groupRows.length === 0) { | |
// Skip rendering this group if no rows are visible | |
return; | |
} | |
const groupValues = groupKey.split('||').slice(0, -1); // Remove last empty string | |
const groupId = groupKey.replace(/\|\|/g, '_'); | |
// Create group header row | |
const groupHeader = document.createElement('tr'); | |
groupHeader.classList.add('group-header'); | |
groupHeader.setAttribute('data-group-id', groupId); | |
const toggleCell = document.createElement('td'); | |
toggleCell.colSpan = this.table.columnsCount(); | |
toggleCell.innerHTML = `<span class="toggle-group">▼</span> Group: ${groupValues.join(', ')}`; | |
groupHeader.appendChild(toggleCell); | |
// Add click event to toggle group | |
groupHeader.addEventListener('click', () => { | |
const toggleSpan = toggleCell.querySelector('.toggle-group'); | |
const isCollapsed = toggleSpan.textContent === '▼'; | |
toggleSpan.textContent = isCollapsed ? '▶' : '▼'; | |
const groupRows = tbody.querySelectorAll(`tr[data-parent-group-id="${groupId}"]`); | |
groupRows.forEach(gr => { | |
gr.classList.toggle('hidden', isCollapsed); | |
}); | |
}); | |
tbody.appendChild(groupHeader); | |
// Append group rows | |
groupRows.forEach(row => { | |
row.style.display = ''; // Ensure row is visible | |
row.setAttribute('data-parent-group-id', groupId); | |
tbody.appendChild(row); | |
}); | |
}); | |
} | |
} | |
// Define the custom element | |
customElements.define('draggable-table', DraggableTable, { | |
extends: 'table' | |
}); | |
</script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>Interactive Draggable Table with Resizable and Reorderable Columns</title> | |
<style> | |
/* Root variables for easy theming */ | |
:root { | |
--primary-color: #3498db; | |
--secondary-color: #2ecc71; | |
--border-color: #ccc; | |
--hover-color: rgba(52, 152, 219, 0.1); | |
--selected-color: rgba(52, 152, 219, 0.2); | |
--font-color: #333; | |
--background-color: #fff; | |
--transition-duration: 0.3s; | |
} | |
/* General table styles */ | |
table.draggable-table { | |
border-collapse: collapse; | |
width: 100%; | |
user-select: none; | |
/* Prevent text selection during dragging */ | |
table-layout: fixed; | |
/* Enable fixed table layout for column resizing */ | |
font-family: 'Inter', sans-serif; | |
color: var(--font-color); | |
background-color: var(--background-color); | |
border-radius: 8px; | |
overflow: hidden; | |
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); | |
} | |
table.draggable-table thead { | |
background: linear-gradient(to right, var(--primary-color), var(--secondary-color)); | |
color: #fff; | |
} | |
table.draggable-table th, | |
table.draggable-table td { | |
border: 1px solid var(--border-color); | |
padding: 12px; | |
position: relative; | |
cursor: pointer; | |
overflow: hidden; | |
/* Prevent content overflow when resizing */ | |
word-wrap: break-word; | |
transition: background-color var(--transition-duration), color var(--transition-duration); | |
} | |
table.draggable-table th { | |
position: sticky; | |
top: 0; | |
background: inherit; | |
z-index: 2; | |
} | |
/* Hover effects */ | |
table.draggable-table tbody tr:hover { | |
background-color: var(--hover-color); | |
} | |
table.draggable-table th:hover, | |
table.draggable-table td:hover { | |
background-color: var(--hover-color); | |
} | |
/* Focused cell */ | |
.focused { | |
outline: 2px solid var(--secondary-color); | |
outline-offset: -2px; | |
} | |
/* Selected cells */ | |
table.draggable-table th.selected, | |
table.draggable-table td.selected { | |
background-color: var(--selected-color); | |
} | |
/* Sorting indicators */ | |
table.draggable-table th.sorted::after { | |
content: ''; | |
display: inline-block; | |
margin-left: 8px; | |
border: 6px solid transparent; | |
border-top-color: var(--font-color); | |
transform: rotate(0deg); | |
transition: transform var(--transition-duration); | |
} | |
table.draggable-table th.sort-asc::after { | |
transform: rotate(180deg); | |
} | |
table.draggable-table th.sort-desc::after { | |
transform: rotate(0deg); | |
} | |
/* Filter input styles */ | |
table.draggable-table th .filter-container { | |
display: flex; | |
flex-direction: column; | |
margin-top: 4px; | |
} | |
table.draggable-table th .filter-select { | |
margin-bottom: 4px; | |
padding: 4px; | |
font-size: 12px; | |
border-radius: 4px; | |
border: 1px solid var(--border-color); | |
background-color: #fff; | |
color: var(--font-color); | |
} | |
table.draggable-table th .filter-input { | |
width: 100%; | |
padding: 6px; | |
font-size: 12px; | |
border-radius: 4px; | |
border: 1px solid var(--border-color); | |
background-color: #fff; | |
color: var(--font-color); | |
box-sizing: border-box; | |
} | |
/* Column resizing */ | |
.column-resizer { | |
position: absolute; | |
right: 0; | |
top: 0; | |
bottom: 0; | |
width: 5px; | |
cursor: col-resize; | |
user-select: none; | |
} | |
/* Draggable column indication */ | |
table.draggable-table th.dragging, | |
table.draggable-table td.dragging { | |
opacity: 0.5; | |
} | |
/* Smooth transitions */ | |
table.draggable-table th, | |
table.draggable-table td { | |
transition: background-color var(--transition-duration), color var(--transition-duration), opacity var(--transition-duration); | |
} | |
/* Grouping Area Styles */ | |
#grouping-area { | |
border: 2px dashed var(--border-color); | |
padding: 10px; | |
margin-bottom: 10px; | |
min-height: 40px; | |
display: flex; | |
align-items: center; | |
gap: 10px; | |
border-radius: 8px; | |
background-color: #fafafa; | |
transition: border-color var(--transition-duration), background-color var(--transition-duration); | |
} | |
#grouping-area.active { | |
border-color: var(--primary-color); | |
background-color: rgba(52, 152, 219, 0.05); | |
} | |
.group-tag { | |
background-color: var(--secondary-color); | |
color: #fff; | |
border-radius: 16px; | |
padding: 6px 12px; | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
font-size: 14px; | |
animation: fadeIn var(--transition-duration); | |
} | |
.group-tag .remove-group { | |
cursor: pointer; | |
color: #fff; | |
font-weight: bold; | |
} | |
.group-tag .remove-group:hover { | |
color: #e74c3c; | |
transition: color var(--transition-duration); | |
} | |
/* Group Header Styles */ | |
.group-header { | |
background-color: rgba(52, 152, 219, 0.1); | |
cursor: pointer; | |
font-weight: bold; | |
transition: background-color var(--transition-duration); | |
} | |
.group-header:hover { | |
background-color: rgba(52, 152, 219, 0.15); | |
} | |
.group-header .toggle-group { | |
margin-right: 8px; | |
cursor: pointer; | |
font-weight: normal; | |
transition: transform var(--transition-duration); | |
} | |
.group-header.collapsed .toggle-group::before { | |
content: '►'; | |
display: inline-block; | |
margin-right: 4px; | |
} | |
.group-header.expanded .toggle-group::before { | |
content: '▼'; | |
display: inline-block; | |
margin-right: 4px; | |
} | |
/* Hidden rows */ | |
.hidden { | |
display: none; | |
} | |
/* Animations */ | |
@keyframes fadeIn { | |
from { | |
opacity: 0; | |
transform: translateY(-5px); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
/* Responsive design */ | |
@media (max-width: 768px) { | |
table.draggable-table th, | |
table.draggable-table td { | |
padding: 8px; | |
} | |
.group-tag { | |
font-size: 12px; | |
padding: 4px 8px; | |
} | |
} | |
/* Scrollbar styling */ | |
table.draggable-table { | |
scrollbar-width: thin; | |
scrollbar-color: var(--primary-color) rgba(0, 0, 0, 0.1); | |
} | |
table.draggable-table::-webkit-scrollbar { | |
height: 8px; | |
width: 8px; | |
} | |
table.draggable-table::-webkit-scrollbar-track { | |
background: rgba(0, 0, 0, 0.1); | |
} | |
table.draggable-table::-webkit-scrollbar-thumb { | |
background-color: var(--primary-color); | |
border-radius: 4px; | |
border: 2px solid rgba(0, 0, 0, 0.1); | |
} | |
</style> | |
</head> | |
<body> | |
<!-- Grouping Area --> | |
<div id="grouping-area"> | |
<span>Drag column headers here to group</span> | |
</div> | |
<!-- Draggable Table --> | |
<table is="draggable-table" class="draggable-table" id="myTable"> | |
<thead> | |
<tr> | |
<th draggable="true">Header 1<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 2<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 3<div class="column-resizer"></div> | |
</th> | |
<th draggable="true">Header 4<div class="column-resizer"></div> | |
</th> | |
</tr> | |
<tr class="filter-row"> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 1"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 2"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 3"> | |
</div> | |
</th> | |
<th> | |
<div class="filter-container"> | |
<select class="filter-select"> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
</select> | |
<input type="text" class="filter-input" placeholder="Filter Header 4"> | |
</div> | |
</th> | |
</tr> | |
</thead> | |
<tbody> | |
<tr> | |
<td>Apple</td> | |
<td>Red</td> | |
<td>10</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Banana</td> | |
<td>Yellow</td> | |
<td>5</td> | |
<td>No</td> | |
</tr> | |
<tr> | |
<td>Cherry</td> | |
<td>Red</td> | |
<td>20</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Date</td> | |
<td>Brown</td> | |
<td>15</td> | |
<td>No</td> | |
</tr> | |
<tr> | |
<td>Apple</td> | |
<td>Green</td> | |
<td>12</td> | |
<td>Yes</td> | |
</tr> | |
<tr> | |
<td>Banana</td> | |
<td>Green</td> | |
<td>7</td> | |
<td>No</td> | |
</tr> | |
<!-- Add more rows as needed --> | |
</tbody> | |
</table> | |
<script> | |
class DraggableTable extends HTMLTableElement { | |
constructor() { | |
super(); | |
// Instantiate managers | |
this.isResizing = false; | |
this.isDraggingColumns = false; | |
this.preventClick = false; | |
this.sortState = {}; | |
this.filters = []; | |
this.groupedColumns = []; // Moved groupedColumns here | |
this.groupData = {}; // Moved groupData here | |
this.originalRows = []; | |
// Managers | |
this.selectionManager = new SelectionManager(this); | |
this.resizeManager = new ResizeManager(this); | |
this.dragManager = new DragManager(this); | |
this.sortingManager = new SortingManager(this); | |
this.filteringManager = new FilteringManager(this); | |
this.groupingManager = new GroupingManager(this); | |
this.navigationManager = new NavigationManager(this); | |
} | |
connectedCallback() { | |
// Initialize filters array based on number of columns | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const columns = headerRow.children.length; | |
this.filters = Array(columns).fill({ | |
operator: 'contains', | |
value: '' | |
}); | |
// Store original rows and initial row order | |
const tbody = this.querySelector('tbody'); | |
this.originalRows = Array.from(tbody.querySelectorAll('tr')); | |
// Assign original index to each row | |
this.originalRows.forEach((row, index) => { | |
row.originalIndex = index; | |
}); | |
// Store a copy of the initial row order | |
this.initialRowsOrder = Array.from(this.originalRows); | |
// Call connectedCallback of each manager | |
this.selectionManager.connectedCallback(); | |
this.resizeManager.connectedCallback(); | |
this.dragManager.connectedCallback(); | |
this.sortingManager.connectedCallback(); | |
this.filteringManager.connectedCallback(); | |
this.groupingManager.connectedCallback(); | |
this.navigationManager.connectedCallback(); | |
} | |
disconnectedCallback() { | |
// Call disconnectedCallback of each manager | |
this.selectionManager.disconnectedCallback(); | |
this.resizeManager.disconnectedCallback(); | |
this.dragManager.disconnectedCallback(); | |
this.sortingManager.disconnectedCallback(); | |
this.filteringManager.disconnectedCallback(); | |
this.groupingManager.disconnectedCallback(); | |
this.navigationManager.disconnectedCallback(); | |
} | |
/* Utility to Get Number of Columns */ | |
columnsCount() { | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
return headerRow.children.length; | |
} | |
/* Reorder Columns */ | |
reorderColumns(sourceIndex, targetIndex) { | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const filterRow = this.querySelector('thead tr.filter-row'); | |
const tbody = this.querySelector('tbody'); | |
// Reorder headers | |
const headerCells = Array.from(headerRow.children); | |
const filterCells = Array.from(filterRow.children); | |
const sourceHeader = headerCells[sourceIndex]; | |
const sourceFilter = filterCells[sourceIndex]; | |
headerRow.removeChild(sourceHeader); | |
filterRow.removeChild(sourceFilter); | |
if (targetIndex < headerCells.length) { | |
headerRow.insertBefore(sourceHeader, headerRow.children[targetIndex]); | |
filterRow.insertBefore(sourceFilter, filterRow.children[targetIndex]); | |
} else { | |
headerRow.appendChild(sourceHeader); | |
filterRow.appendChild(sourceFilter); | |
} | |
// Reorder cells in each row | |
const allRows = this.originalRows; | |
allRows.forEach(row => { | |
const cells = Array.from(row.children); | |
const sourceCell = cells[sourceIndex]; | |
row.removeChild(sourceCell); | |
if (targetIndex < cells.length) { | |
row.insertBefore(sourceCell, row.children[targetIndex]); | |
} else { | |
row.appendChild(sourceCell); | |
} | |
}); | |
// Update filters, sortState, and groupedColumns | |
this.updateColumnIndices(); | |
// Reapply grouping and filtering | |
this.filteringManager.applyFilters(); | |
} | |
updateColumnIndices() { | |
// Update data-col-index attributes | |
const headerRow = this.querySelector('thead tr:not(.filter-row)'); | |
const filterRow = this.querySelector('thead tr.filter-row'); | |
const headerCells = Array.from(headerRow.children); | |
const filterCells = Array.from(filterRow.children); | |
// Build mapping from original index to new index | |
const oldIndexToNewIndex = {}; | |
headerCells.forEach((th, newIndex) => { | |
const originalIndex = parseInt(th.getAttribute('data-original-index'), 10); | |
th.setAttribute('data-col-index', newIndex); | |
oldIndexToNewIndex[originalIndex] = newIndex; | |
}); | |
// Update data-col-index for filter cells and inputs | |
filterCells.forEach((th, index) => { | |
th.setAttribute('data-col-index', index); | |
const select = th.querySelector('.filter-select'); | |
const input = th.querySelector('.filter-input'); | |
if (select && input) { | |
select.setAttribute('data-col-index', index); | |
input.setAttribute('data-col-index', index); | |
} | |
}); | |
// Update filters array based on data-original-index | |
const newFilters = []; | |
headerCells.forEach((th, index) => { | |
const originalIndex = parseInt(th.getAttribute('data-original-index'), 10); | |
newFilters[index] = this.filters[originalIndex] || { | |
operator: 'contains', | |
value: '' | |
}; | |
}); | |
this.filters = newFilters; | |
// Update sortState based on data-original-index | |
const newSortState = {}; | |
for (let key in this.sortState) { | |
const originalKey = parseInt(key, 10); | |
const newKey = oldIndexToNewIndex[originalKey]; | |
if (newKey !== undefined) { | |
newSortState[newKey] = this.sortState[key]; | |
} | |
} | |
this.sortState = newSortState; | |
// Update groupedColumns based on data-original-index | |
this.groupedColumns = this.groupedColumns.map(originalIndex => oldIndexToNewIndex[originalIndex]); | |
// Re-attach event listeners for headers | |
this.sortingManager.attachHeaderEvents(); | |
this.filteringManager.attachFilterEvents(); | |
// Re-render grouping tags | |
this.groupingManager.renderGroupingTags(); | |
} | |
} | |
class SelectionManager { | |
constructor(table) { | |
this.table = table; | |
this.isMouseDown = false; | |
this.startCell = null; | |
this.endCell = null; | |
this.selectedCells = new Set(); | |
// Bind methods | |
this.handleMouseDown = this.handleMouseDown.bind(this); | |
this.handleMouseOver = this.handleMouseOver.bind(this); | |
this.handleMouseUp = this.handleMouseUp.bind(this); | |
this.handleCopy = this.handleCopy.bind(this); | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); | |
} | |
connectedCallback() { | |
this.table.addEventListener('mousedown', this.handleMouseDown); | |
this.table.addEventListener('mouseover', this.handleMouseOver); | |
document.addEventListener('mouseup', this.handleMouseUp); | |
document.addEventListener('copy', this.handleCopy); | |
// Add event listeners for Escape key and clicks outside tbody | |
document.addEventListener('keydown', this.handleKeyDown); | |
document.addEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
disconnectedCallback() { | |
this.table.removeEventListener('mousedown', this.handleMouseDown); | |
this.table.removeEventListener('mouseover', this.handleMouseOver); | |
document.removeEventListener('mouseup', this.handleMouseUp); | |
document.removeEventListener('copy', this.handleCopy); | |
// Remove event listeners | |
document.removeEventListener('keydown', this.handleKeyDown); | |
document.removeEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
handleMouseDown(event) { | |
if (this.table.isResizing) return; | |
if (event.target.tagName === 'TH' && event.target.draggable) { | |
this.table.isDraggingColumns = true; | |
return; // Prevent cell selection | |
} | |
if (this.table.isDraggingColumns) return; // Just in case | |
// Ignore clicks on filter row cells | |
const cell = event.target.closest('td, th'); | |
if (cell && cell.parentElement.classList.contains('filter-row')) { | |
return; | |
} | |
if (cell && (cell.tagName === 'TD' || cell.tagName === 'TH')) { | |
this.isMouseDown = true; | |
this.clearSelection(); | |
this.startCell = cell; | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
} | |
handleMouseOver(event) { | |
if (this.table.isResizing || this.table.isDraggingColumns) return; // Prevent selection during resizing or dragging | |
const cell = event.target.closest('td, th'); | |
if (this.isMouseDown && cell && !cell.parentElement.classList.contains('filter-row')) { | |
if (cell.tagName === 'TD' || cell.tagName === 'TH') { | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
} | |
} | |
handleMouseUp(event) { | |
if (this.isMouseDown) { | |
this.isMouseDown = false; | |
} | |
if (this.table.isDraggingColumns) { | |
this.table.isDraggingColumns = false; | |
} | |
} | |
handleCopy(event) { | |
if (this.selectedCells.size === 0) return; | |
event.preventDefault(); | |
const data = this.getSelectedData(); | |
event.clipboardData.setData('text/plain', data); | |
} | |
handleKeyDown(event) { | |
if (event.key === 'Escape') { | |
this.clearSelection(); | |
// Clear focus as well | |
if (this.table.navigationManager) { | |
this.table.navigationManager.clearFocus(); | |
} | |
} | |
} | |
handleDocumentMouseDown(event) { | |
const tbody = this.table.querySelector('tbody'); | |
if (!tbody.contains(event.target)) { | |
this.clearSelection(); | |
// Clear focus as well | |
if (this.table.navigationManager) { | |
this.table.navigationManager.clearFocus(); | |
} | |
} | |
} | |
clearSelection() { | |
this.selectedCells.forEach(cell => cell.classList.remove('selected')); | |
this.selectedCells.clear(); | |
} | |
updateSelection() { | |
if (!this.startCell || !this.endCell) return; | |
this.clearSelection(); | |
const cells = this.getCellsInRange(this.startCell, this.endCell); | |
cells.forEach(cell => { | |
cell.classList.add('selected'); | |
this.selectedCells.add(cell); | |
}); | |
} | |
getCellsInRange(start, end) { | |
const startRow = start.parentElement.rowIndex; | |
const startCol = start.cellIndex; | |
const endRow = end.parentElement.rowIndex; | |
const endCol = end.cellIndex; | |
const topRow = Math.min(startRow, endRow); | |
const bottomRow = Math.max(startRow, endRow); | |
const leftCol = Math.min(startCol, endCol); | |
const rightCol = Math.max(startCol, endCol); | |
const cells = []; | |
const rows = this.table.rows; | |
for (let r = topRow; r <= bottomRow; r++) { | |
const row = rows[r]; | |
// Skip filter row | |
if (row.classList.contains('filter-row')) continue; | |
for (let c = leftCol; c <= rightCol; c++) { | |
const cell = row.cells[c]; | |
if (cell) cells.push(cell); | |
} | |
} | |
return cells; | |
} | |
getSelectedData() { | |
if (this.selectedCells.size === 0) return ''; | |
// Organize cells by rows | |
const rowsMap = new Map(); | |
this.selectedCells.forEach(cell => { | |
const rowIndex = cell.parentElement.rowIndex; | |
if (!rowsMap.has(rowIndex)) rowsMap.set(rowIndex, []); | |
rowsMap.get(rowIndex).push(cell.innerText); | |
}); | |
// Sort rows | |
const sortedRows = Array.from(rowsMap.keys()).sort((a, b) => a - b); | |
// Create tab-separated values | |
const data = sortedRows.map(rowIndex => { | |
const cells = rowsMap.get(rowIndex); | |
return cells.join('\t'); | |
}).join('\n'); | |
return data; | |
} | |
} | |
class ResizeManager { | |
constructor(table) { | |
this.table = table; | |
this.isResizing = false; | |
this.resizingColumn = null; | |
this.startX = 0; | |
this.startWidth = 0; | |
// Bind methods | |
this.handleResizeMouseDown = this.handleResizeMouseDown.bind(this); | |
this.handleResizeMouseMove = this.handleResizeMouseMove.bind(this); | |
this.handleResizeMouseUp = this.handleResizeMouseUp.bind(this); | |
} | |
connectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
const resizer = th.querySelector('.column-resizer'); | |
if (resizer) { | |
resizer.addEventListener('mousedown', (event) => this.handleResizeMouseDown(event, th)); | |
} | |
}); | |
document.addEventListener('mousemove', this.handleResizeMouseMove); | |
document.addEventListener('mouseup', this.handleResizeMouseUp); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
const resizer = th.querySelector('.column-resizer'); | |
if (resizer) { | |
resizer.removeEventListener('mousedown', this.handleResizeMouseDown); | |
} | |
}); | |
document.removeEventListener('mousemove', this.handleResizeMouseMove); | |
document.removeEventListener('mouseup', this.handleResizeMouseUp); | |
} | |
handleResizeMouseDown(event, th) { | |
event.preventDefault(); | |
event.stopPropagation(); // Prevent the event from bubbling up to the header | |
this.isResizing = true; | |
this.table.isResizing = true; | |
this.resizingColumn = th; | |
this.startX = event.pageX; | |
this.startWidth = th.offsetWidth; | |
} | |
handleResizeMouseMove(event) { | |
if (!this.isResizing) return; | |
const deltaX = event.pageX - this.startX; | |
const newWidth = this.startWidth + deltaX; | |
if (newWidth > 30) { // Minimum column width | |
this.resizingColumn.style.width = newWidth + 'px'; | |
} | |
} | |
handleResizeMouseUp(event) { | |
if (this.isResizing) { | |
this.isResizing = false; | |
this.table.isResizing = false; | |
this.resizingColumn = null; | |
this.table.preventClick = true; // Add this line | |
} | |
} | |
} | |
class DragManager { | |
constructor(table) { | |
this.table = table; | |
this.isDraggingColumns = false; | |
// Bind methods | |
this.handleDragStart = this.handleDragStart.bind(this); | |
this.handleDragOver = this.handleDragOver.bind(this); | |
this.handleDrop = this.handleDrop.bind(this); | |
this.handleDragEnd = this.handleDragEnd.bind(this); | |
this.handleHeaderDragOver = this.handleHeaderDragOver.bind(this); | |
this.handleHeaderDrop = this.handleHeaderDrop.bind(this); | |
this.handleHeaderRowDragOver = this.handleHeaderRowDragOver.bind(this); | |
this.handleHeaderRowDrop = this.handleHeaderRowDrop.bind(this); | |
} | |
connectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.addEventListener('dragstart', this.handleDragStart); | |
th.addEventListener('dragover', this.handleHeaderDragOver); | |
th.addEventListener('drop', this.handleHeaderDrop); | |
th.addEventListener('dragend', this.handleDragEnd); | |
}); | |
// Add event listeners to the entire header row to handle drops beyond the last column | |
headerRow.parentElement.addEventListener('dragover', this.handleHeaderRowDragOver); | |
headerRow.parentElement.addEventListener('drop', this.handleHeaderRowDrop); | |
// Setup grouping area | |
this.setupGroupingArea(); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.removeEventListener('dragstart', this.handleDragStart); | |
th.removeEventListener('dragover', this.handleHeaderDragOver); | |
th.removeEventListener('drop', this.handleHeaderDrop); | |
th.removeEventListener('dragend', this.handleDragEnd); | |
}); | |
headerRow.parentElement.removeEventListener('dragover', this.handleHeaderRowDragOver); | |
headerRow.parentElement.removeEventListener('drop', this.handleHeaderRowDrop); | |
// Remove grouping area event listeners | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.removeEventListener('dragover', this.handleDragOver); | |
groupingArea.removeEventListener('drop', this.handleDrop); | |
} | |
setupGroupingArea() { | |
const groupingArea = document.getElementById('grouping-area'); | |
// Prevent default dragover behavior | |
groupingArea.addEventListener('dragover', this.handleDragOver); | |
groupingArea.addEventListener('drop', this.handleDrop); | |
// Style adjustments | |
groupingArea.style.display = 'flex'; | |
groupingArea.style.flexWrap = 'wrap'; | |
} | |
handleDragStart(event) { | |
const th = event.currentTarget; | |
const columnIndex = parseInt(th.getAttribute('data-col-index'), 10); | |
event.dataTransfer.setData('text/plain', columnIndex); | |
event.dataTransfer.effectAllowed = 'move'; | |
this.isDraggingColumns = true; | |
this.table.isDraggingColumns = true; // Set dragging flag | |
} | |
handleDragEnd(event) { | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag | |
} | |
handleDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.classList.add('active'); | |
} | |
handleDrop(event) { | |
event.preventDefault(); | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.classList.remove('active'); | |
const columnIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
if (!this.table.groupedColumns.includes(columnIndex)) { | |
this.table.groupedColumns.push(columnIndex); | |
this.table.groupingManager.renderGroupingTags(); | |
this.table.groupingManager.applyGrouping(); | |
} | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag | |
} | |
handleHeaderDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
} | |
handleHeaderDrop(event) { | |
event.preventDefault(); | |
const targetTh = event.currentTarget; | |
const targetIndex = parseInt(targetTh.getAttribute('data-col-index'), 10); | |
const sourceIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
if (sourceIndex === targetIndex) return; | |
this.table.reorderColumns(sourceIndex, targetIndex); | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag after drop | |
} | |
// Updated method to handle drag over on the header row | |
handleHeaderRowDragOver(event) { | |
event.preventDefault(); | |
event.dataTransfer.dropEffect = 'move'; | |
} | |
// Updated method to handle drop on the header row (beyond the last column) | |
handleHeaderRowDrop(event) { | |
event.preventDefault(); | |
const sourceIndex = parseInt(event.dataTransfer.getData('text/plain'), 10); | |
// Determine if the drop is beyond the last column | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
const rect = headerRow.getBoundingClientRect(); | |
const dropX = event.clientX; | |
// Calculate target index based on drop position | |
let targetIndex = headerRow.children.length; | |
for (let i = 0; i < headerRow.children.length; i++) { | |
const th = headerRow.children[i]; | |
const thRect = th.getBoundingClientRect(); | |
if (dropX < thRect.left + thRect.width / 2) { | |
targetIndex = i; | |
break; | |
} | |
} | |
if (sourceIndex === targetIndex || sourceIndex === targetIndex - 1) return; | |
this.table.reorderColumns(sourceIndex, targetIndex); | |
this.isDraggingColumns = false; | |
this.table.isDraggingColumns = false; // Reset dragging flag after drop | |
} | |
} | |
class SortingManager { | |
constructor(table) { | |
this.table = table; | |
// Bind methods | |
this.handleHeaderClick = this.handleHeaderClick.bind(this); | |
} | |
connectedCallback() { | |
this.attachHeaderEvents(); | |
} | |
disconnectedCallback() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.removeEventListener('click', this.handleHeaderClick); | |
}); | |
} | |
attachHeaderEvents() { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th, index) => { | |
th.setAttribute('data-col-index', index); | |
th.setAttribute('data-original-index', index); // Set original index | |
th.removeEventListener('click', this.handleHeaderClick); | |
th.addEventListener('click', this.handleHeaderClick); | |
}); | |
} | |
handleHeaderClick(event) { | |
if ( | |
event.target.closest('.filter-container') || | |
event.target.closest('.group-tag') || | |
event.target.classList.contains('column-resizer') || | |
this.table.isResizing || | |
this.table.preventClick | |
) { | |
this.table.preventClick = false; // Reset preventClick | |
return; | |
} | |
const th = event.currentTarget; | |
const columnIndex = parseInt(th.getAttribute('data-col-index'), 10); | |
// Determine the new sort direction | |
const currentSort = this.table.sortState[columnIndex] || 'none'; | |
let newSort; | |
if (currentSort === 'none') { | |
newSort = 'asc'; | |
} else if (currentSort === 'asc') { | |
newSort = 'desc'; | |
} else { | |
newSort = 'none'; | |
} | |
// Update sortState | |
if (newSort === 'none') { | |
delete this.table.sortState[columnIndex]; | |
} else { | |
this.table.sortState = {}; // Reset other sorts | |
this.table.sortState[columnIndex] = newSort; | |
} | |
// Remove existing sort indicators | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
headerRow.querySelectorAll('th').forEach((th) => { | |
th.classList.remove('sort-asc', 'sort-desc', 'sorted'); | |
}); | |
// Add sort indicator to the current column | |
const currentTh = headerRow.children[columnIndex]; | |
if (newSort !== 'none') { | |
currentTh.classList.add(newSort === 'asc' ? 'sort-asc' : 'sort-desc'); | |
currentTh.classList.add('sorted'); | |
} | |
// Perform the sorting | |
this.sortTable(columnIndex, newSort); | |
} | |
sortTable(columnIndex, direction) { | |
let rowsToDisplay; | |
// Get the rows that are currently visible after filtering | |
const visibleRows = this.table.originalRows.filter(row => row.style.display !== 'none'); | |
if (direction === 'none') { | |
// Restore the original row order using initialRowsOrder | |
rowsToDisplay = this.table.initialRowsOrder.filter(row => visibleRows.includes(row)); | |
} else { | |
// Sort the visible rows | |
rowsToDisplay = Array.from(visibleRows).sort((a, b) => { | |
const cellA = a.cells[columnIndex].innerText.trim().toLowerCase(); | |
const cellB = b.cells[columnIndex].innerText.trim().toLowerCase(); | |
// Attempt numerical comparison | |
const numA = parseFloat(cellA); | |
const numB = parseFloat(cellB); | |
let comparison = 0; | |
if (!isNaN(numA) && !isNaN(numB)) { | |
comparison = numA - numB; | |
} else { | |
if (cellA < cellB) comparison = -1; | |
if (cellA > cellB) comparison = 1; | |
} | |
return direction === 'asc' ? comparison : -comparison; | |
}); | |
} | |
// Re-apply grouping or render sorted rows | |
if (this.table.groupedColumns.length > 0) { | |
// Update originalRows with the sorted order | |
this.table.originalRows = rowsToDisplay; | |
this.table.groupingManager.applyGrouping(); | |
} else { | |
const tbody = this.table.querySelector('tbody'); | |
tbody.innerHTML = ''; | |
rowsToDisplay.forEach(row => { | |
tbody.appendChild(row); | |
}); | |
} | |
} | |
} | |
class FilteringManager { | |
constructor(table) { | |
this.table = table; | |
// Bind methods | |
this.handleFilterInput = this.handleFilterInput.bind(this); | |
} | |
connectedCallback() { | |
this.attachFilterEvents(); | |
} | |
disconnectedCallback() { | |
const filterRow = this.table.querySelector('thead tr.filter-row'); | |
filterRow.querySelectorAll('.filter-container').forEach((container) => { | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
select.removeEventListener('change', this.handleFilterInput); | |
input.removeEventListener('input', this.handleFilterInput); | |
}); | |
} | |
attachFilterEvents() { | |
const filterRow = this.table.querySelector('thead tr.filter-row'); | |
filterRow.querySelectorAll('.filter-container').forEach((container, index) => { | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
select.setAttribute('data-col-index', index); | |
input.setAttribute('data-col-index', index); | |
select.removeEventListener('change', this.handleFilterInput); | |
input.removeEventListener('input', this.handleFilterInput); | |
select.addEventListener('change', this.handleFilterInput); | |
input.addEventListener('input', this.handleFilterInput); | |
}); | |
} | |
handleFilterInput(event) { | |
const element = event.target; | |
const container = element.closest('.filter-container'); | |
const index = parseInt(element.getAttribute('data-col-index'), 10); | |
const select = container.querySelector('.filter-select'); | |
const input = container.querySelector('.filter-input'); | |
const operator = select.value; | |
const value = input.value.trim().toLowerCase(); | |
this.table.filters[index] = { | |
operator, | |
value | |
}; | |
this.applyFilters(); | |
} | |
applyFilters() { | |
// Apply filters to original rows | |
this.table.originalRows.forEach(row => { | |
let visible = true; | |
this.table.filters.forEach((filter, index) => { | |
if (filter.value) { | |
const cellText = row.cells[index].innerText.trim().toLowerCase(); | |
const filterValue = filter.value; | |
switch (filter.operator) { | |
case 'contains': | |
if (!cellText.includes(filterValue)) visible = false; | |
break; | |
case 'startswith': | |
if (!cellText.startsWith(filterValue)) visible = false; | |
break; | |
case 'endswith': | |
if (!cellText.endsWith(filterValue)) visible = false; | |
break; | |
case 'equal': | |
if (cellText !== filterValue) visible = false; | |
break; | |
case 'greaterthan': | |
const cellNumGT = parseFloat(row.cells[index].innerText.trim()); | |
const filterNumGT = parseFloat(filterValue); | |
if (isNaN(cellNumGT) || isNaN(filterNumGT) || cellNumGT <= filterNumGT) { | |
visible = false; | |
} | |
break; | |
case 'lessthan': | |
const cellNumLT = parseFloat(row.cells[index].innerText.trim()); | |
const filterNumLT = parseFloat(filterValue); | |
if (isNaN(cellNumLT) || isNaN(filterNumLT) || cellNumLT >= filterNumLT) { | |
visible = false; | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
}); | |
row.style.display = visible ? '' : 'none'; | |
}); | |
// Re-apply grouping after filtering | |
if (this.table.groupedColumns.length > 0) { | |
this.table.groupingManager.applyGrouping(); | |
} else { | |
// If not grouped, update the table body with filtered rows | |
const tbody = this.table.querySelector('tbody'); | |
tbody.innerHTML = ''; | |
this.table.originalRows.forEach(row => { | |
if (row.style.display !== 'none') { | |
tbody.appendChild(row); | |
} | |
}); | |
} | |
} | |
} | |
class NavigationManager { | |
constructor(table) { | |
this.table = table; | |
this.currentCell = null; | |
// Bind methods | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleCellClick = this.handleCellClick.bind(this); | |
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); | |
} | |
connectedCallback() { | |
// Add event listener for keydown on the table | |
this.table.addEventListener('keydown', this.handleKeyDown, true); // Use capture phase | |
// Add event listener for click on the table | |
this.table.addEventListener('click', this.handleCellClick); | |
// Add event listener for clicks outside the table | |
document.addEventListener('mousedown', this.handleDocumentMouseDown); | |
// Remove tabindex from the table to prevent it from gaining focus | |
this.table.removeAttribute('tabindex'); | |
} | |
disconnectedCallback() { | |
// Remove event listeners | |
this.table.removeEventListener('keydown', this.handleKeyDown, true); | |
this.table.removeEventListener('click', this.handleCellClick); | |
document.removeEventListener('mousedown', this.handleDocumentMouseDown); | |
} | |
handleKeyDown(event) { | |
const key = event.key; | |
if (key === 'Escape') { | |
// Clear focus when Escape key is pressed | |
this.clearFocus(); | |
// Also clear selection if SelectionManager is present | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
} | |
return; | |
} | |
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) { | |
return; | |
} | |
// Prevent default scrolling behavior | |
event.preventDefault(); | |
if (!this.currentCell) { | |
return; | |
} | |
let { | |
rowIndex, | |
cellIndex | |
} = this.getCellPosition(this.currentCell); | |
let newRowIndex = rowIndex; | |
let newCellIndex = cellIndex; | |
switch (key) { | |
case 'ArrowUp': | |
newRowIndex = this.findPreviousRowIndex(newRowIndex); | |
break; | |
case 'ArrowDown': | |
newRowIndex = this.findNextRowIndex(newRowIndex); | |
break; | |
case 'ArrowLeft': | |
newCellIndex = Math.max(cellIndex - 1, 0); | |
break; | |
case 'ArrowRight': | |
newCellIndex = Math.min(cellIndex + 1, this.table.rows[newRowIndex].cells.length - 1); | |
break; | |
} | |
const newRow = this.table.rows[newRowIndex]; | |
if (newRow) { | |
const newCell = newRow.cells[newCellIndex]; | |
if (newCell) { | |
this.currentCell = newCell; | |
this.focusCell(newCell); | |
} | |
} | |
} | |
handleCellClick(event) { | |
const cell = event.target.closest('td, th'); | |
// Ignore clicks on filter row cells | |
if (cell && cell.parentElement.classList.contains('filter-row')) { | |
return; | |
} | |
if (cell && this.table.contains(cell)) { | |
this.currentCell = cell; | |
this.focusCell(cell); | |
} | |
} | |
handleDocumentMouseDown(event) { | |
if (!this.table.contains(event.target)) { | |
this.clearFocus(); | |
// Also clear selection if SelectionManager is present | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
} | |
} | |
} | |
getCellPosition(cell) { | |
const row = cell.parentElement; | |
const rowIndex = Array.from(this.table.rows).indexOf(row); | |
const cellIndex = Array.from(row.cells).indexOf(cell); | |
return { | |
rowIndex, | |
cellIndex | |
}; | |
} | |
findPreviousRowIndex(currentIndex) { | |
let newIndex = currentIndex - 1; | |
while (newIndex >= 0) { | |
const row = this.table.rows[newIndex]; | |
if (!row.classList.contains('filter-row')) { | |
return newIndex; | |
} | |
newIndex--; | |
} | |
return currentIndex; // Return current index if no previous row found | |
} | |
findNextRowIndex(currentIndex) { | |
let newIndex = currentIndex + 1; | |
while (newIndex < this.table.rows.length) { | |
const row = this.table.rows[newIndex]; | |
if (!row.classList.contains('filter-row')) { | |
return newIndex; | |
} | |
newIndex++; | |
} | |
return currentIndex; // Return current index if no next row found | |
} | |
focusCell(cell) { | |
// Remove focus class and tabindex from all cells | |
this.table.querySelectorAll('.focused').forEach(c => { | |
c.classList.remove('focused'); | |
c.removeAttribute('tabindex'); | |
}); | |
// Add focus class to the current cell | |
cell.classList.add('focused'); | |
// Set tabindex to make the cell focusable | |
cell.setAttribute('tabindex', '0'); | |
// Focus the cell | |
cell.focus(); | |
// Integrate with SelectionManager to select the focused cell | |
if (this.table.selectionManager) { | |
this.table.selectionManager.clearSelection(); | |
this.table.selectionManager.startCell = cell; | |
this.table.selectionManager.endCell = cell; | |
this.table.selectionManager.updateSelection(); | |
} | |
// Scroll cell into view if necessary | |
cell.scrollIntoView({ | |
block: 'nearest', | |
inline: 'nearest' | |
}); | |
} | |
clearFocus() { | |
if (this.currentCell) { | |
// Remove focus class and tabindex | |
this.currentCell.classList.remove('focused'); | |
this.currentCell.removeAttribute('tabindex'); | |
this.currentCell.blur(); | |
this.currentCell = null; | |
} | |
} | |
} | |
class GroupingManager { | |
constructor(table) { | |
this.table = table; | |
// Remove these lines | |
// this.groupedColumns = table.groupedColumns; | |
// this.groupData = table.groupData; | |
// Bind methods | |
this.renderGroupingTags = this.renderGroupingTags.bind(this); | |
this.applyGrouping = this.applyGrouping.bind(this); | |
} | |
connectedCallback() { | |
this.renderGroupingTags(); | |
} | |
disconnectedCallback() { | |
// No event listeners to remove | |
} | |
renderGroupingTags() { | |
const groupingArea = document.getElementById('grouping-area'); | |
groupingArea.innerHTML = ''; // Clear existing tags | |
if (this.table.groupedColumns.length === 0) { | |
const placeholder = document.createElement('span'); | |
placeholder.textContent = 'Drag column headers here to group'; | |
groupingArea.appendChild(placeholder); | |
// Reset the table to ungrouped state | |
this.applyGrouping(); | |
return; | |
} | |
this.table.groupedColumns.forEach((colIndex) => { | |
const headerRow = this.table.querySelector('thead tr:not(.filter-row)'); | |
const th = headerRow.children[colIndex]; | |
const columnName = th.textContent.trim(); | |
const tag = document.createElement('div'); | |
tag.classList.add('group-tag'); | |
tag.setAttribute('data-col-index', colIndex); | |
const span = document.createElement('span'); | |
span.textContent = columnName; | |
tag.appendChild(span); | |
const removeBtn = document.createElement('span'); | |
removeBtn.classList.add('remove-group'); | |
removeBtn.textContent = '×'; | |
removeBtn.addEventListener('click', () => { | |
this.table.groupedColumns = this.table.groupedColumns.filter(index => index !== colIndex); | |
this.renderGroupingTags(); | |
this.applyGrouping(); | |
}); | |
tag.appendChild(removeBtn); | |
groupingArea.appendChild(tag); | |
}); | |
} | |
applyGrouping() { | |
const tbody = this.table.querySelector('tbody'); | |
// If no grouped columns, reset table | |
if (this.table.groupedColumns.length === 0) { | |
tbody.innerHTML = ''; | |
// Append only rows that match the filters (visible rows) | |
this.table.originalRows.forEach(row => { | |
if (row.style.display !== 'none') { | |
tbody.appendChild(row); | |
} | |
}); | |
return; | |
} | |
// Organize rows into groups based on groupedColumns | |
this.groupData = {}; // Use this.groupData here | |
this.table.originalRows.forEach(row => { | |
// Only include visible rows | |
if (row.style.display === 'none') return; | |
let groupKey = ''; | |
this.table.groupedColumns.forEach(colIndex => { | |
groupKey += row.cells[colIndex].innerText.trim() + '||'; | |
}); | |
if (!this.groupData[groupKey]) { | |
this.groupData[groupKey] = []; | |
} | |
this.groupData[groupKey].push(row); | |
}); | |
// Clear tbody | |
tbody.innerHTML = ''; | |
// Render grouped rows | |
Object.keys(this.groupData).forEach(groupKey => { | |
const groupRows = this.groupData[groupKey]; | |
if (groupRows.length === 0) { | |
// Skip rendering this group if no rows are visible | |
return; | |
} | |
const groupValues = groupKey.split('||').slice(0, -1); // Remove last empty string | |
const groupId = groupKey.replace(/\|\|/g, '_'); | |
// Create group header row | |
const groupHeader = document.createElement('tr'); | |
groupHeader.classList.add('group-header'); | |
groupHeader.setAttribute('data-group-id', groupId); | |
const toggleCell = document.createElement('td'); | |
toggleCell.colSpan = this.table.columnsCount(); | |
toggleCell.innerHTML = `<span class="toggle-group">▼</span> Group: ${groupValues.join(', ')}`; | |
groupHeader.appendChild(toggleCell); | |
// Add click event to toggle group | |
groupHeader.addEventListener('click', () => { | |
const toggleSpan = toggleCell.querySelector('.toggle-group'); | |
const isCollapsed = toggleSpan.textContent === '▼'; | |
toggleSpan.textContent = isCollapsed ? '▶' : '▼'; | |
const groupRows = tbody.querySelectorAll(`tr[data-parent-group-id="${groupId}"]`); | |
groupRows.forEach(gr => { | |
gr.classList.toggle('hidden', isCollapsed); | |
}); | |
}); | |
tbody.appendChild(groupHeader); | |
// Append group rows | |
groupRows.forEach(row => { | |
row.style.display = ''; // Ensure row is visible | |
row.setAttribute('data-parent-group-id', groupId); | |
tbody.appendChild(row); | |
}); | |
}); | |
} | |
} | |
// Define the custom element | |
customElements.define('draggable-table', DraggableTable, { | |
extends: 'table' | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment