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):