Drag-and-Drop Column Reorder With Merged Cells: Why You Need a Visual Grid Map
Drag-and-drop column reordering sounds simple until merged cells enter the picture.
A column in a flat table is straightforward: everything at the nth position in every row. Grab it, move it, done. But once any cell has a colspan, the concept of "the nth column" breaks down. A cell spanning two columns occupies two column positions but exists at one DOM node. Moving the column naively either moves the wrong element or skips rows where the spanning cell is the origin.
The correct approach delegates all column-position reasoning to a structure that models visual grid positions independently of the DOM.
The Visual Grid Map
A VisualGridMapper walks the table once and builds a 2D grid where every visual (row, col) position maps to a DOM node and a flag indicating whether this position is the origin of that cell or a slot occupied by its span.
// grid[row][col] = { element: <td>, isOrigin: bool }
// isOrigin: true means this position is where the element actually lives in the DOM
// isOrigin: false means this position is covered by a colspan/rowspan from another cell
This distinction is what makes column operations correct. A cell with colspan="3" appears in three column positions in the grid but has isOrigin: true in only one of them.
Drag Handle Injection
When drag mode is enabled, a dedicated handle row is injected at the top of the table, one handle per visual column:
for (let i = 0; i < mapper.maxCols; i++) {
dragRowHtml += <td class="drag-handle col-handle" data-col-index="${i}">::</td>;
}
mapper.maxCols is the total number of visual columns, accounting for all spanning. This is not the same as the number of cells in the first row. A table where the first row has 3 cells but one has colspan="2" has 4 visual columns. tr.cells.length gives 3. mapper.maxCols gives 4.
Moving a Column
On drop, moveColumn(fromIndex, toIndex) iterates every row and uses the mapper to find origin cells at the source column:
const movedElements = new Set();
for (let r = 0; r < mapper.maxRows; r++) { const fromCellData = mapper.grid[r][fromIndex];
if (fromCellData && fromCellData.isOrigin && !movedElements.has(fromCellData.element)) { movedElements.add(fromCellData.element); const movingCell = fromCellData.element; // find insertion point and move } }
The movedElements set prevents moving the same element twice. A cell with colspan="3" appears in three column positions in the mapper. Without the set, the function would attempt to move the same DOM node three times, producing the wrong result each iteration.
Finding the Insertion Point
The target column position determines where to insert:
let targetCellData = mapper.grid[r][toIndex];
if (targetCellData && targetCellData.isOrigin) { // Insert before the origin cell at the target column targetCellData.element.before(movingCell); } else if (!targetCellData) { // Target is past the end of a short row: append movingCell.closest('tr').append(movingCell); } else { // Target is covered by a colspan: scan right for the next origin for (let c = toIndex; c < mapper.maxCols; c++) { const next = mapper.grid[r][c]; if (next && next.isOrigin && next.element !== movingCell) { next.element.before(movingCell); break; } } }
The third case is what makes the system robust against horizontal spanning. When the target column position is covered by a colspan from a cell to its left, inserting before the covering cell would put the moved column in the wrong visual position. Scanning right to find the next origin cell gives the correct insertion point.
Undo
The last line of moveColumn:
if (typeof window.saveCurrentState === 'function') window.saveCurrentState();
The move is pushed onto the undo stack. Every column reorder is reversible.
Without the visual grid map, this feature either breaks tables that use spanning or produces incorrect results that are not immediately obvious. The mapper is the entire correctness property.
Source: github.com/carnworkstudios/TAFNE