Last active
October 25, 2024 09:41
-
-
Save OnurGumus/cec22dea3bb9ec90f5b6b3de201399ed to your computer and use it in GitHub Desktop.
grid
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
/* Define the typography layer */ | |
@layer typography { | |
/* Define the fonts layer */ | |
@layer fonts { | |
/* Example of @font-face declarations */ | |
@font-face { | |
font-family: "Inter"; | |
src: url("/fonts/Inter-Regular.woff2") format("woff2"), | |
url("/fonts/Inter-Regular.woff") format("woff"); | |
font-weight: 400; | |
font-style: normal; | |
font-display: swap; | |
} | |
@font-face { | |
font-family: "Inter"; | |
src: url("/fonts/Inter-Bold.woff2") format("woff2"), | |
url("/fonts/Inter-Bold.woff") format("woff"); | |
font-weight: 600; | |
font-style: normal; | |
font-display: swap; | |
} | |
/* Add more font-face declarations as needed */ | |
} | |
/* Base Typography Styles */ | |
body { | |
font-family: "Inter", sans-serif; | |
font-size: 1rem; | |
line-height: 1.5; | |
font-weight: 400; | |
/* Avoid color properties here */ | |
} | |
h1, h2, h3, h4, h5, h6 { | |
font-family: "Inter", sans-serif; | |
font-weight: 600; | |
line-height: 1.2; | |
/* Avoid color properties here */ | |
} | |
p, span, a, button { | |
font-family: "Inter", sans-serif; | |
font-size: 1rem; | |
line-height: 1.5; | |
/* Avoid color properties here */ | |
} | |
/* Additional typography styles can be added here */ | |
} | |
/* Define the animation layer */ | |
@layer animation { | |
/* Keyframes */ | |
@keyframes fadeIn { | |
from { | |
opacity: 0; | |
transform: translateY(-0.3125rem); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
/* Animation Properties */ | |
.group-tag { | |
animation: fadeIn var(--transition-duration); | |
} | |
/* Transition Properties */ | |
table.draggable-table th, | |
table.draggable-table td { | |
transition: | |
background-color var(--transition-duration), | |
color var(--transition-duration), | |
opacity var(--transition-duration); | |
} | |
table.draggable-table th.sorted::after { | |
transition: transform var(--transition-duration); | |
} | |
.toggle-group { | |
transition: transform var(--transition-duration); | |
} | |
#grouping-area { | |
transition: | |
border-color var(--transition-duration), | |
background-color var(--transition-duration); | |
} | |
th.sorted::after, | |
th.sort-asc::after, | |
th.sort-desc::after { | |
transition: transform var(--transition-duration); | |
} | |
/* Additional transition properties can be added here as needed */ | |
} | |
/* Combine all layers in the desired order */ | |
@layer typography, animation; | |
/* Root Variables */ | |
:root { | |
/* Color variables excluded from typography and fonts layers */ | |
--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 { | |
touch-action: none; | |
border-collapse: collapse; | |
inline-size: 100%; | |
user-select: none; | |
-webkit-user-select: none; /* Prevent text selection during dragging */ | |
table-layout: fixed; /* Enable fixed table layout for column resizing */ | |
color: var(--font-color); | |
background-color: var(--background-color); | |
border-radius: 0.5rem; | |
overflow: hidden; | |
box-shadow: 0 0.25rem 1.25rem rgba(0, 0, 0, 0.1); | |
scrollbar-width: thin; | |
scrollbar-color: var(--primary-color) rgba(0, 0, 0, 0.1); | |
/* Scrollbar styling for WebKit browsers */ | |
&::-webkit-scrollbar { | |
height: 0.5rem; | |
width: 0.5rem; | |
} | |
&::-webkit-scrollbar-track { | |
background: rgba(0, 0, 0, 0.1); | |
} | |
&::-webkit-scrollbar-thumb { | |
background-color: var(--primary-color); | |
border-radius: 0.25rem; | |
border: 0.125rem solid rgba(0, 0, 0, 0.1); | |
} | |
thead { | |
background: linear-gradient(to right, var(--primary-color), var(--secondary-color)); | |
color: #fff; | |
touch-action: none; | |
} | |
tbody { | |
tr:hover { | |
background-color: var(--hover-color); | |
} | |
} | |
th, | |
td { | |
touch-action: none; | |
border: 0.0625rem solid var(--border-color); | |
padding: 0.75rem; | |
position: relative; | |
cursor: pointer; | |
overflow: hidden; /* Prevent content overflow when resizing */ | |
word-wrap: break-word; | |
/* Transition properties moved to animation layer */ | |
&:hover { | |
background-color: var(--hover-color); | |
} | |
&.selected { | |
background-color: var(--selected-color); | |
} | |
&.dragging { | |
opacity: 0.5; | |
} | |
} | |
th { | |
position: sticky; | |
top: 0; | |
background: inherit; | |
z-index: 2; | |
&.sorted::after { | |
content: ""; | |
display: inline-block; | |
margin-inline-start: 0.5rem; | |
border: 0.375rem solid transparent; | |
border-top-color: var(--font-color); | |
transform: rotate(0deg); | |
/* Transition property moved to animation layer */ | |
} | |
&.sort-asc::after { | |
transform: rotate(180deg); | |
} | |
&.sort-desc::after { | |
transform: rotate(0deg); | |
} | |
.filter-container { | |
display: flex; | |
flex-direction: column; | |
margin-block-start: 0.25rem; | |
.filter-select { | |
margin-block-end: 0.25rem; | |
padding: 0.25rem; | |
font-size: 0.75rem; | |
border-radius: 0.25rem; | |
border: 0.0625rem solid var(--border-color); | |
background-color: #fff; | |
color: var(--font-color); | |
} | |
.filter-input { | |
inline-size: 100%; | |
padding: 0.375rem; | |
font-size: 0.75rem; | |
border-radius: 0.25rem; | |
border: 0.0625rem solid var(--border-color); | |
background-color: #fff; | |
color: var(--font-color); | |
box-sizing: border-box; | |
} | |
} | |
} | |
} | |
/* Focused cell */ | |
.focused { | |
outline: 0.125rem solid var(--secondary-color); | |
outline-offset: -0.125rem; | |
} | |
/* Column resizing */ | |
.column-resizer { | |
position: absolute; | |
inset-inline-end: 0; | |
inset-block-start: 0; | |
inset-block-end: 0; | |
inline-size: 0.3125rem; | |
cursor: col-resize; | |
user-select: none; | |
} | |
/* Grouping Area Styles */ | |
#grouping-area { | |
border: 0.125rem dashed var(--border-color); | |
padding: 0.625rem; | |
margin-block-end: 0.625rem; | |
min-block-size: 2.5rem; | |
display: flex; | |
align-items: center; | |
gap: 0.625rem; | |
border-radius: 0.5rem; | |
background-color: #fafafa; | |
/* Transition properties moved to animation layer */ | |
&.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: 1rem; | |
padding: 0.375rem 0.75rem; | |
display: flex; | |
align-items: center; | |
gap: 0.5rem; | |
font-size: 0.875rem; | |
/* Animation property moved to animation layer */ | |
.remove-group { | |
cursor: pointer; | |
color: #fff; | |
font-weight: bold; | |
&:hover { | |
color: #e74c3c; | |
/* Transition property moved to animation layer */ | |
} | |
} | |
} | |
} | |
/* Group Header Styles */ | |
.group-header { | |
background-color: rgba(52, 152, 219, 0.1); | |
cursor: pointer; | |
font-weight: bold; | |
/* Transition property moved to animation layer */ | |
&:hover { | |
background-color: rgba(52, 152, 219, 0.15); | |
} | |
.toggle-group { | |
margin-inline-end: 0.5rem; | |
cursor: pointer; | |
font-weight: normal; | |
/* Transition property moved to animation layer */ | |
&.collapsed::before { | |
content: "►"; | |
display: inline-block; | |
margin-inline-end: 0.25rem; | |
} | |
&.expanded::before { | |
content: "▼"; | |
display: inline-block; | |
margin-inline-end: 0.25rem; | |
} | |
} | |
} | |
/* Hidden rows */ | |
.hidden { | |
display: none; | |
} | |
/* Responsive design */ | |
@media (max-width: 48rem) { /* 768px */ | |
table.draggable-table { | |
th, | |
td { | |
padding: 0.5rem; | |
} | |
.group-tag { | |
font-size: 0.75rem; | |
padding: 0.25rem 0.5rem; | |
} | |
} | |
} |
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
<script src="//unpkg.com/@ungap/custom-elements/es.js"></script> | |
<!-- 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="">-- Select Operator --</option> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="notequal">Not Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
<option value="greaterequal">Greater or Equal Than</option> | |
<option value="lessequal">Less or Equal Than</option> | |
<option value="regex">Regex</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="">-- Select Operator --</option> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="notequal">Not Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
<option value="greaterequal">Greater or Equal Than</option> | |
<option value="lessequal">Less or Equal Than</option> | |
<option value="regex">Regex</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="">-- Select Operator --</option> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="notequal">Not Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
<option value="greaterequal">Greater or Equal Than</option> | |
<option value="lessequal">Less or Equal Than</option> | |
<option value="regex">Regex</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="">-- Select Operator --</option> | |
<option value="contains">Contains</option> | |
<option value="startswith">Starts With</option> | |
<option value="endswith">Ends With</option> | |
<option value="equal">Equal</option> | |
<option value="notequal">Not Equal</option> | |
<option value="greaterthan">Greater Than</option> | |
<option value="lessthan">Less Than</option> | |
<option value="greaterequal">Greater or Equal Than</option> | |
<option value="lessequal">Less or Equal Than</option> | |
<option value="regex">Regex</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> | |
<h2> Features </h2> | |
<ul> | |
<li>Filter data</li> | |
<li>Resize columns</li> | |
<li>Re order columns</li> | |
<li>Multi column group by</li> | |
<li>Sort asc, desc, unsort</li> | |
<li>Navigate focus with arrow keys</li> | |
<li>Render from server</li> | |
<li>Add remove data with plain DOM API</li> | |
<li>Cell selection with dragging or SHIFT Arrow keys</li> | |
<li>Copy selection and paste to excel</li> | |
<li>Mobile and touch friendly</li> | |
<li>Modular code & easy to customize</li> | |
<li>Cross browser </li> | |
</ul> | |
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
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.isPointerDown = false; | |
this.startCell = null; | |
this.endCell = null; | |
this.selectedCells = new Set(); | |
// Bind methods to maintain the correct 'this' context | |
this.handlePointerDown = this.handlePointerDown.bind(this); | |
this.handlePointerMove = this.handlePointerMove.bind(this); | |
this.handlePointerUp = this.handlePointerUp.bind(this); | |
this.handleCopy = this.handleCopy.bind(this); | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleDocumentPointerDown = this.handleDocumentPointerDown.bind(this); | |
} | |
connectedCallback() { | |
// Use pointer events instead of mouse events | |
this.table.addEventListener("pointerdown", this.handlePointerDown); | |
// No need to attach pointermove to the table | |
document.addEventListener("pointerup", this.handlePointerUp); | |
document.addEventListener("copy", this.handleCopy); | |
// Add event listeners for Escape key and clicks outside tbody | |
document.addEventListener("keydown", this.handleKeyDown); | |
document.addEventListener("pointerdown", this.handleDocumentPointerDown); | |
} | |
disconnectedCallback() { | |
this.table.removeEventListener("pointerdown", this.handlePointerDown); | |
document.removeEventListener("pointermove", this.handlePointerMove); | |
document.removeEventListener("pointerup", this.handlePointerUp); | |
document.removeEventListener("copy", this.handleCopy); | |
// Remove event listeners | |
document.removeEventListener("keydown", this.handleKeyDown); | |
document.removeEventListener("pointerdown", this.handleDocumentPointerDown); | |
} | |
handlePointerDown(event) { | |
// Only primary button (usually left button) | |
if (event.button !== 0) return; | |
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.isPointerDown = true; | |
this.clearSelection(); | |
this.startCell = cell; | |
this.endCell = cell; | |
this.updateSelection(); | |
// Attach pointermove listener to the document for global tracking | |
document.addEventListener("pointermove", this.handlePointerMove); | |
} | |
} | |
handlePointerMove(event) { | |
if (this.table.isResizing || this.table.isDraggingColumns) return; // Prevent selection during resizing or dragging | |
if (!this.isPointerDown) return; | |
// Get the element under the pointer | |
const element = document.elementFromPoint(event.clientX, event.clientY); | |
const cell = element ? element.closest("td, th") : null; | |
if ( | |
cell && | |
!cell.parentElement.classList.contains("filter-row") && | |
(cell.tagName === "TD" || cell.tagName === "TH") | |
) { | |
if (cell !== this.endCell) { | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
} | |
} | |
handlePointerUp(event) { | |
if (this.isPointerDown) { | |
this.isPointerDown = false; | |
// Remove pointermove listener as the drag operation is complete | |
document.removeEventListener("pointermove", this.handlePointerMove); | |
} | |
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(); | |
} | |
} | |
} | |
handleDocumentPointerDown(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(); | |
// Do not reset this.startCell and this.endCell here | |
} | |
setStartAndEndCell(cell) { | |
this.clearSelection(); | |
this.startCell = cell; | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
setEndCell(cell) { | |
if (!this.startCell) { | |
this.setStartAndEndCell(cell); | |
return; | |
} | |
this.endCell = cell; | |
this.updateSelection(); | |
} | |
updateSelection() { | |
if (!this.startCell || !this.endCell) return; | |
// Remove previous selection without clearing startCell and endCell | |
this.selectedCells.forEach((cell) => cell.classList.remove("selected")); | |
this.selectedCells.clear(); | |
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 to maintain the correct 'this' context | |
this.handleResizePointerDown = this.handleResizePointerDown.bind(this); | |
this.handleResizePointerMove = this.handleResizePointerMove.bind(this); | |
this.handleResizePointerUp = this.handleResizePointerUp.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) { | |
// Use pointerdown instead of mousedown | |
resizer.addEventListener("pointerdown", this.handleResizePointerDown); | |
} | |
}); | |
} | |
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("pointerdown", this.handleResizePointerDown); | |
} | |
}); | |
// Clean up any remaining event listeners on the document | |
document.removeEventListener("pointermove", this.handleResizePointerMove); | |
document.removeEventListener("pointerup", this.handleResizePointerUp); | |
document.removeEventListener("pointercancel", this.handleResizePointerUp); | |
} | |
handleResizePointerDown(event) { | |
event.preventDefault(); | |
event.stopPropagation(); // Prevent the event from bubbling up to the header | |
this.isResizing = true; | |
this.table.isResizing = true; | |
// Identify the column being resized | |
this.resizingColumn = event.target.closest("th"); | |
if (!this.resizingColumn) return; | |
this.startX = event.pageX; | |
this.startWidth = this.resizingColumn.offsetWidth; | |
// Capture the pointer to ensure all subsequent pointer events are received | |
this.resizingColumn.setPointerCapture(event.pointerId); | |
// Add event listeners for pointermove and pointerup to the document | |
document.addEventListener("pointermove", this.handleResizePointerMove); | |
document.addEventListener("pointerup", this.handleResizePointerUp); | |
document.addEventListener("pointercancel", this.handleResizePointerUp); | |
} | |
handleResizePointerMove(event) { | |
if (!this.isResizing) return; | |
const deltaX = event.pageX - this.startX; | |
const newWidth = this.startWidth + deltaX; | |
if (newWidth > 30) { // Ensure a minimum column width | |
this.resizingColumn.style.width = `${newWidth}px`; | |
} | |
} | |
handleResizePointerUp(event) { | |
if (this.isResizing) { | |
this.isResizing = false; | |
this.table.isResizing = false; | |
this.resizingColumn = null; | |
this.table.preventClick = true; // Retain this line as per original functionality | |
// Release pointer capture | |
event.target.releasePointerCapture(event.pointerId); | |
// Remove the event listeners from the document | |
document.removeEventListener("pointermove", this.handleResizePointerMove); | |
document.removeEventListener("pointerup", this.handleResizePointerUp); | |
document.removeEventListener("pointercancel", this.handleResizePointerUp); | |
} | |
} | |
} | |
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(); | |
// Initialize filters array if not present | |
if (!this.table.filters) { | |
this.table.filters = []; | |
} | |
// Update the filter for the specific column | |
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 && filter.value) { | |
const cellText = row.cells[index].innerText.trim(); | |
const filterValue = filter.value; | |
switch (filter.operator) { | |
case "contains": | |
if (!cellText.toLowerCase().includes(filterValue.toLowerCase())) { | |
visible = false; | |
} | |
break; | |
case "startswith": | |
if (!cellText.toLowerCase().startsWith(filterValue.toLowerCase())) { | |
visible = false; | |
} | |
break; | |
case "endswith": | |
if (!cellText.toLowerCase().endsWith(filterValue.toLowerCase())) { | |
visible = false; | |
} | |
break; | |
case "equal": | |
if (cellText.toLowerCase() !== filterValue.toLowerCase()) { | |
visible = false; | |
} | |
break; | |
case "notequal": | |
if (cellText.toLowerCase() === filterValue.toLowerCase()) { | |
visible = false; | |
} | |
break; | |
case "greaterthan": | |
const cellNumGT = parseFloat(cellText); | |
const filterNumGT = parseFloat(filterValue); | |
if ( | |
isNaN(cellNumGT) || | |
isNaN(filterNumGT) || | |
cellNumGT <= filterNumGT | |
) { | |
visible = false; | |
} | |
break; | |
case "lessthan": | |
const cellNumLT = parseFloat(cellText); | |
const filterNumLT = parseFloat(filterValue); | |
if ( | |
isNaN(cellNumLT) || | |
isNaN(filterNumLT) || | |
cellNumLT >= filterNumLT | |
) { | |
visible = false; | |
} | |
break; | |
case "greaterequal": | |
const cellNumGE = parseFloat(cellText); | |
const filterNumGE = parseFloat(filterValue); | |
if ( | |
isNaN(cellNumGE) || | |
isNaN(filterNumGE) || | |
cellNumGE < filterNumGE | |
) { | |
visible = false; | |
} | |
break; | |
case "lessequal": | |
const cellNumLE = parseFloat(cellText); | |
const filterNumLE = parseFloat(filterValue); | |
if ( | |
isNaN(cellNumLE) || | |
isNaN(filterNumLE) || | |
cellNumLE > filterNumLE | |
) { | |
visible = false; | |
} | |
break; | |
case "regex": | |
try { | |
const regex = new RegExp(filterValue, 'i'); // 'i' for case-insensitive | |
if (!regex.test(cellText)) { | |
visible = false; | |
} | |
} catch (e) { | |
console.error("Invalid regex pattern:", filterValue); | |
visible = false; | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
}); | |
row.style.display = visible ? "" : "none"; | |
}); | |
// Re-apply grouping after filtering | |
if (this.table.groupedColumns && 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; | |
// History buffer | |
this.history = []; | |
this.currentIndex = -1; // Points to the current position in history | |
this.maxHistory = 1000; // Increased history size to 1000 | |
// Bind methods | |
this.handleKeyDown = this.handleKeyDown.bind(this); | |
this.handleCellClick = this.handleCellClick.bind(this); | |
this.handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); | |
} | |
connectedCallback() { | |
// Add event listeners | |
this.table.addEventListener("keydown", this.handleKeyDown, true); // Use capture phase | |
this.table.addEventListener("click", this.handleCellClick); | |
document.addEventListener("mousedown", this.handleDocumentMouseDown); | |
// Ensure the table can receive focus by setting tabindex | |
if (!this.table.hasAttribute("tabindex")) { | |
this.table.setAttribute("tabindex", "0"); | |
} | |
// Make all cells focusable by setting tabindex | |
this.makeCellsFocusable(); | |
} | |
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; | |
const isShift = event.shiftKey; // Detect if Shift key is pressed | |
const isCtrl = event.ctrlKey || event.metaKey; // Support Command key on Mac | |
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; | |
} | |
// Handle Undo/Redo | |
if (isCtrl && key === "ArrowLeft") { | |
event.preventDefault(); | |
this.undo(); | |
return; | |
} | |
if (isCtrl && key === "ArrowRight") { | |
event.preventDefault(); | |
this.redo(); | |
return; | |
} | |
// Only handle arrow keys for navigation | |
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) { | |
return; | |
} | |
// Prevent default scrolling behavior | |
event.preventDefault(); | |
if (!this.currentCell) { | |
// If no cell is currently focused, focus the first visible cell in tbody | |
const firstVisibleCell = this.findFirstVisibleCell(); | |
if (firstVisibleCell) { | |
this.setCurrentCell(firstVisibleCell); | |
} | |
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.setCurrentCell(newCell, isShift); | |
} | |
} | |
} | |
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.setCurrentCell(cell, event.shiftKey); | |
} | |
} | |
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(); | |
} | |
} | |
} | |
setCurrentCell(cell, isShift = false) { | |
if (this.currentCell !== cell) { | |
this.currentCell = cell; | |
this.focusCell(cell); | |
if (this.table.selectionManager) { | |
if (isShift && this.table.selectionManager.startCell) { | |
// If Shift is held, update the endCell to extend the selection | |
this.table.selectionManager.setEndCell(cell); | |
} else { | |
// If Shift is not held, clear selection and set new start and end cells | |
this.table.selectionManager.setStartAndEndCell(cell); | |
} | |
} | |
// Update history only if cell is in tbody and visible | |
if (this.isCellInTbody(cell) && this.isCellVisible(cell)) { | |
this.addToHistory(cell); | |
} | |
} | |
} | |
addToHistory(cell) { | |
// If we've undone some steps and then navigate, truncate the redo history | |
if (this.currentIndex < this.history.length - 1) { | |
this.history = this.history.slice(0, this.currentIndex + 1); | |
} | |
// Avoid adding duplicate consecutive entries | |
if (this.history[this.history.length - 1] !== cell) { | |
this.history.push(cell); | |
this.currentIndex++; | |
} | |
// Ensure the history doesn't exceed maxHistory | |
if (this.history.length > this.maxHistory) { | |
this.history.shift(); // Remove the oldest entry | |
this.currentIndex--; | |
} | |
} | |
undo() { | |
// If current cell is not in tbody or is hidden, jump to the last visible cell in history | |
if (!this.isCellInTbody(this.currentCell) || !this.isCellVisible(this.currentCell)) { | |
for (let i = this.history.length - 1; i >= 0; i--) { | |
const cell = this.history[i]; | |
if (this.isCellVisible(cell)) { | |
this.currentIndex = i; | |
this.currentCell = cell; | |
this.focusCell(cell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(cell); | |
} | |
return; | |
} | |
} | |
// If no visible cell found, focus the first visible cell | |
const firstVisibleCell = this.findFirstVisibleCell(); | |
if (firstVisibleCell) { | |
this.currentIndex = this.history.indexOf(firstVisibleCell); | |
this.currentCell = firstVisibleCell; | |
this.focusCell(firstVisibleCell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(firstVisibleCell); | |
} | |
} | |
return; | |
} | |
// Current cell is in tbody and visible, move back to find the previous visible cell | |
for (let i = this.currentIndex - 1; i >= 0; i--) { | |
const cell = this.history[i]; | |
if (this.isCellVisible(cell)) { | |
this.currentIndex = i; | |
this.currentCell = cell; | |
this.focusCell(cell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(cell); | |
} | |
return; | |
} | |
} | |
// No previous visible cell found, focus the first visible cell | |
const firstVisibleCell = this.findFirstVisibleCell(); | |
if (firstVisibleCell) { | |
this.currentIndex = this.history.indexOf(firstVisibleCell); | |
this.currentCell = firstVisibleCell; | |
this.focusCell(firstVisibleCell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(firstVisibleCell); | |
} | |
} | |
} | |
redo() { | |
// If current cell is not in tbody or is hidden, jump to the first visible cell in history | |
if (!this.isCellInTbody(this.currentCell) || !this.isCellVisible(this.currentCell)) { | |
for (let i = 0; i < this.history.length; i++) { | |
const cell = this.history[i]; | |
if (this.isCellVisible(cell)) { | |
this.currentIndex = i; | |
this.currentCell = cell; | |
this.focusCell(cell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(cell); | |
} | |
return; | |
} | |
} | |
// If no visible cell found, do nothing | |
return; | |
} | |
// Current cell is in tbody and visible, move forward to find the next visible cell | |
for (let i = this.currentIndex + 1; i < this.history.length; i++) { | |
const cell = this.history[i]; | |
if (this.isCellVisible(cell)) { | |
this.currentIndex = i; | |
this.currentCell = cell; | |
this.focusCell(cell); | |
if (this.table.selectionManager) { | |
this.table.selectionManager.setStartAndEndCell(cell); | |
} | |
return; | |
} | |
} | |
// No next visible cell found, do nothing | |
} | |
clearFocus() { | |
if (this.currentCell) { | |
// Remove focus class and tabindex | |
this.currentCell.classList.remove("focused"); | |
this.currentCell.removeAttribute("tabindex"); | |
this.currentCell.blur(); | |
this.currentCell = null; | |
} | |
} | |
focusCell(cell) { | |
if (!cell) return; // Prevent errors if cell is undefined | |
// 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(); | |
// Scroll cell into view if necessary | |
cell.scrollIntoView({ | |
block: "nearest", | |
inline: "nearest" | |
}); | |
} | |
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 | |
} | |
makeCellsFocusable() { | |
// Make all cells focusable by setting tabindex | |
const cells = this.table.querySelectorAll("td, th"); | |
cells.forEach((cell) => { | |
cell.setAttribute("tabindex", "-1"); // Initially not focusable via tab | |
}); | |
} | |
isCellInTbody(cell) { | |
const tbody = this.table.querySelector("tbody"); | |
return cell && tbody.contains(cell); | |
} | |
isCellVisible(cell) { | |
return cell && this.table.contains(cell) && cell.offsetParent !== null; | |
} | |
findFirstVisibleCell() { | |
for (let i = 0; i < this.history.length; i++) { | |
const cell = this.history[i]; | |
if (this.isCellVisible(cell)) { | |
return cell; | |
} | |
} | |
// Optionally, find the first visible cell in tbody | |
const tbody = this.table.querySelector("tbody"); | |
if (tbody) { | |
const firstRow = tbody.querySelector("tr:not(.filter-row)"); | |
if (firstRow) { | |
const firstCell = firstRow.cells[0]; | |
if (this.isCellVisible(firstCell)) { | |
return firstCell; | |
} | |
} | |
} | |
return null; | |
} | |
} | |
class GroupingManager { | |
constructor(table) { | |
this.table = table; | |
// 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 = ""; | |
// Remove 'hidden' class from all rows to ensure they are visible | |
this.table.originalRows.forEach((row) => { | |
row.classList.remove("hidden"); // **Added Line** | |
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 based on filters | |
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" | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment