diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css
index f40ab05..cd09e25 100644
--- a/src/myfasthtml/assets/myfasthtml.css
+++ b/src/myfasthtml/assets/myfasthtml.css
@@ -1,5 +1,8 @@
:root {
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
+ --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
+ --datagrid-resize-zindex: 1;
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
@@ -854,8 +857,6 @@
/* Cell */
.dt2-cell {
- --color-border: color-mix(in oklab, var(--color-base-content) 20%, #0000);
- --color-resize: color-mix(in oklab, var(--color-base-content) 50%, #0000);
display: flex;
align-items: center;
justify-content: flex-start;
@@ -1053,3 +1054,17 @@
.dt2-scrollbars-horizontal.dt2-dragging {
background-color: color-mix(in oklab, var(--color-base-content) 30%, #0000);
}
+
+/* *********************************************** */
+/* ******** DataGrid Column Drag & Drop ********** */
+/* *********************************************** */
+
+/* Column being dragged - visual feedback */
+.dt2-dragging {
+ opacity: 0.5;
+}
+
+/* Column animation during swap */
+.dt2-moving {
+ transition: transform 300ms ease;
+}
diff --git a/src/myfasthtml/assets/myfasthtml.js b/src/myfasthtml/assets/myfasthtml.js
index 0ffe77b..51866a6 100644
--- a/src/myfasthtml/assets/myfasthtml.js
+++ b/src/myfasthtml/assets/myfasthtml.js
@@ -1495,7 +1495,8 @@ function updateTabs(controllerId) {
function initDataGrid(gridId) {
initDataGridScrollbars(gridId);
- makeDatagridColumnsResizable(gridId)
+ makeDatagridColumnsResizable(gridId);
+ makeDatagridColumnsMovable(gridId);
}
/**
@@ -1855,3 +1856,162 @@ function makeDatagridColumnsResizable(datagridId) {
table.dispatchEvent(resetEvent);
}
}
+
+/**
+ * Enable column reordering via drag and drop on a DataGrid.
+ * Columns can be dragged to new positions with animated transitions.
+ * @param {string} gridId - The DataGrid instance ID
+ */
+function makeDatagridColumnsMovable(gridId) {
+ const table = document.getElementById(`t_${gridId}`);
+ const headerRow = document.getElementById(`th_${gridId}`);
+
+ if (!table || !headerRow) {
+ console.error(`DataGrid elements not found for ${gridId}`);
+ return;
+ }
+
+ const moveCommandId = headerRow.dataset.moveCommandId;
+ const headerCells = headerRow.querySelectorAll('.dt2-cell:not(.dt2-col-hidden)');
+
+ let sourceColumn = null; // Column being dragged (original position)
+ let lastMoveTarget = null; // Last column we moved to (for persistence)
+ let hoverColumn = null; // Current hover target (for delayed move check)
+
+ headerCells.forEach(cell => {
+ cell.setAttribute('draggable', 'true');
+
+ // Prevent drag when clicking resize handle
+ const resizeHandle = cell.querySelector('.dt2-resize-handle');
+ if (resizeHandle) {
+ resizeHandle.addEventListener('mousedown', () => cell.setAttribute('draggable', 'false'));
+ resizeHandle.addEventListener('mouseup', () => cell.setAttribute('draggable', 'true'));
+ }
+
+ cell.addEventListener('dragstart', (e) => {
+ sourceColumn = cell.getAttribute('data-col');
+ lastMoveTarget = null;
+ hoverColumn = null;
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', sourceColumn);
+ cell.classList.add('dt2-dragging');
+ });
+
+ cell.addEventListener('dragenter', (e) => {
+ e.preventDefault();
+ const targetColumn = cell.getAttribute('data-col');
+ hoverColumn = targetColumn;
+
+ if (sourceColumn && sourceColumn !== targetColumn) {
+ // Delay to skip columns when dragging fast
+ setTimeout(() => {
+ if (hoverColumn === targetColumn) {
+ moveColumn(table, sourceColumn, targetColumn);
+ lastMoveTarget = targetColumn;
+ }
+ }, 50);
+ }
+ });
+
+ cell.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ });
+
+ cell.addEventListener('drop', (e) => {
+ e.preventDefault();
+ // Persist to server
+ if (moveCommandId && sourceColumn && lastMoveTarget) {
+ htmx.ajax('POST', '/myfasthtml/commands', {
+ headers: {"Content-Type": "application/x-www-form-urlencoded"},
+ swap: 'none',
+ values: {
+ c_id: moveCommandId,
+ source_col_id: sourceColumn,
+ target_col_id: lastMoveTarget
+ }
+ });
+ }
+ });
+
+ cell.addEventListener('dragend', () => {
+ headerCells.forEach(c => c.classList.remove('dt2-dragging'));
+ sourceColumn = null;
+ lastMoveTarget = null;
+ hoverColumn = null;
+ });
+ });
+}
+
+/**
+ * Move a column to a new position with animation.
+ * All columns between source and target shift to fill the gap.
+ * @param {HTMLElement} table - The table element
+ * @param {string} sourceColId - Column ID to move
+ * @param {string} targetColId - Column ID to move next to
+ */
+function moveColumn(table, sourceColId, targetColId) {
+ const ANIMATION_DURATION = 300; // Must match CSS transition duration
+
+ const sourceHeader = table.querySelector(`.dt2-cell[data-col="${sourceColId}"]`);
+ const targetHeader = table.querySelector(`.dt2-cell[data-col="${targetColId}"]`);
+
+ if (!sourceHeader || !targetHeader) return;
+ if (sourceHeader.classList.contains('dt2-moving')) return; // Animation in progress
+
+ const headerCells = Array.from(sourceHeader.parentNode.children);
+ const sourceIdx = headerCells.indexOf(sourceHeader);
+ const targetIdx = headerCells.indexOf(targetHeader);
+
+ if (sourceIdx === targetIdx) return;
+
+ const movingRight = sourceIdx < targetIdx;
+ const sourceCells = table.querySelectorAll(`.dt2-cell[data-col="${sourceColId}"]`);
+
+ // Collect cells that need to shift (between source and target)
+ const cellsToShift = [];
+ let shiftWidth = 0;
+ const [startIdx, endIdx] = movingRight
+ ? [sourceIdx + 1, targetIdx]
+ : [targetIdx, sourceIdx - 1];
+
+ for (let i = startIdx; i <= endIdx; i++) {
+ const colId = headerCells[i].getAttribute('data-col');
+ cellsToShift.push(...table.querySelectorAll(`.dt2-cell[data-col="${colId}"]`));
+ shiftWidth += headerCells[i].offsetWidth;
+ }
+
+ // Calculate animation distances
+ const sourceWidth = sourceHeader.offsetWidth;
+ const sourceDistance = movingRight ? shiftWidth : -shiftWidth;
+ const shiftDistance = movingRight ? -sourceWidth : sourceWidth;
+
+ // Animate source column
+ sourceCells.forEach(cell => {
+ cell.classList.add('dt2-moving');
+ cell.style.transform = `translateX(${sourceDistance}px)`;
+ });
+
+ // Animate shifted columns
+ cellsToShift.forEach(cell => {
+ cell.classList.add('dt2-moving');
+ cell.style.transform = `translateX(${shiftDistance}px)`;
+ });
+
+ // After animation: reset transforms and update DOM
+ setTimeout(() => {
+ [...sourceCells, ...cellsToShift].forEach(cell => {
+ cell.classList.remove('dt2-moving');
+ cell.style.transform = '';
+ });
+
+ // Move source column in DOM
+ table.querySelectorAll('.dt2-row').forEach(row => {
+ const sourceCell = row.querySelector(`[data-col="${sourceColId}"]`);
+ const targetCell = row.querySelector(`[data-col="${targetColId}"]`);
+ if (sourceCell && targetCell) {
+ movingRight ? targetCell.after(sourceCell) : targetCell.before(sourceCell);
+ }
+ });
+ }, ANIMATION_DURATION);
+}
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index 0b9c618..3b9f16f 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -94,6 +94,13 @@ class Commands(BaseCommands):
self._owner.set_column_width
).htmx(target=None)
+ def move_column(self):
+ return Command("MoveColumn",
+ "Move column to new position",
+ self._owner,
+ self._owner.move_column
+ ).htmx(target=None)
+
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, save_state=None, _id=None):
@@ -173,18 +180,49 @@ class DataGrid(MultipleInstance):
if col.col_id == col_id:
col.width = int(width)
break
-
+
self._state.save()
-
+
+ def move_column(self, source_col_id: str, target_col_id: str):
+ """Move column to new position. Called via Command from JS."""
+ logger.debug(f"move_column: {source_col_id=} {target_col_id=}")
+
+ # Find indices
+ source_idx = None
+ target_idx = None
+ for i, col in enumerate(self._state.columns):
+ if col.col_id == source_col_id:
+ source_idx = i
+ if col.col_id == target_col_id:
+ target_idx = i
+
+ if source_idx is None or target_idx is None:
+ logger.warning(f"move_column: column not found {source_col_id=} {target_col_id=}")
+ return
+
+ if source_idx == target_idx:
+ return
+
+ # Remove source column and insert at target position
+ col = self._state.columns.pop(source_idx)
+ # Adjust target index if source was before target
+ if source_idx < target_idx:
+ self._state.columns.insert(target_idx, col)
+ else:
+ self._state.columns.insert(target_idx, col)
+
+ self._state.save()
+
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
-
+ move_cmd = self.commands.move_column()
+
def _mk_header_name(col_def: DataGridColumnState):
return Div(
mk.label(col_def.title, name="dt2-header-title"),
cls="flex truncate cursor-default",
)
-
+
def _mk_header(col_def: DataGridColumnState):
return Div(
_mk_header_name(col_def),
@@ -194,12 +232,13 @@ class DataGrid(MultipleInstance):
data_tooltip=col_def.title,
cls="dt2-cell dt2-resizable flex",
)
-
+
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
return Div(
*[_mk_header(col_def) for col_def in self._state.columns],
cls=header_class,
- id=f"th_{self._id}"
+ id=f"th_{self._id}",
+ data_move_command_id=move_cmd.id
)
def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None):