Columns Reordering Implementation in Vanilla-Grid

This document explains how column reordering is implemented in Vanilla-Grid, including mouse drag-and-drop, touch hold-to-reorder, boundary normalization, validation rules, visual indicators, and localStorage persistence.

File location (May 2026): the reorder logic lives in src/vanilla-grid/features/columns-reorder.feature.js as a prototype extension of VanillaGridColumnsFeature.


1. Feature Scope

Column reordering supports:

The feature is controlled by options:


2. Initialization and Event Wiring

In constructor initialization:

Listener wiring (delegated):

Document-level listeners are attached only during active drag phases:

All are removed on end/cancel and in destroy() (which also removes the delegated header listeners).


3. Desktop Reorder Flow (Mouse)

3.1 Drag start

_onHeaderDragStart(e, columnIndex):

  1. Guard conditions:
    • reorderableColumns must be true
    • Source column must exist
    • Drag must not start from .vn-grid-col-resizer
  2. Initializes drag state:
    • _dragColumnFromIndex = columnIndex
    • _dragColumnOverIndex = columnIndex
    • _dragColumnBeforeTarget = true
  3. Applies visual state:
    • clears old indicators
    • marks source header with .vn-grid-col-dragging
    • creates drag ghost (.vn-grid-col-drag-ghost)
  4. Configures dataTransfer and drag image
  5. Attaches document-level drag listeners

3.2 Drag over

_onDocDragOver(e) (document-level, active only during a drag):

  1. Resolves the target column via _resolveDocDragTarget(e) — fast path e.target.closest('th') + stamped colIndex (no rect walk); fallback for pointers outside the cells but within a ±30 px vertical margin of the header: _findColumnIndexFromClientX rect hit-test
  2. Computes dropBefore using _computeDropBeforeWithDeadZone(rect, clientX)
  3. Special-cases selection column (__vgSelection__): force drop-after
  4. Canonicalizes target via _normalizeDropTarget(columnIndex, dropBefore)
  5. Validates move with _wouldReorderColumn(from, to, dropBefore)
  6. If valid:
    • e.preventDefault() (enables drop)
    • sets dropEffect='move'
    • updates indicator classes with _setHeaderDropIndicator(...)
  7. Marks _didColumnDrag when hovering a column other than the source (so the click that follows dragend does not also sort)

3.3 Drop

_onDocDrop(e) (document-level, active only during a drag):

  1. Resolves the target with the same _resolveDocDragTarget(e) fast path
  2. Recomputes and normalizes target; validates with _wouldReorderColumn
  3. e.preventDefault() + stopPropagation(), then executes reorderColumn(fromIndex, toIndex, { dropBefore })
  4. If frozen columns exist, reapplies frozen grouping and re-renders

3.4 Drag end

_onHeaderDragEnd():


4. Touch Reorder Flow

Touch reorder is initiated from _onHeaderPointerDown when:

4.1 Hold activation

On touch pointerdown:

If hold timer completes, _activateTouchReorder():

4.2 Move handling

_onTouchReorderPointerMove:

4.3 Release and cancel


5. Drop Target Computation

5.1 Edge-strip dead zone

_computeDropBeforeWithDeadZone(rect, clientX) implements edge-based intent detection:

Edge strip width:

edgeStrip = min(reorderMarkerDeadZonePx, rect.width * 0.40)

This prevents noisy flips near the center while keeping narrow columns usable.

5.2 Canonical boundary normalization

_normalizeDropTarget(index, dropBefore) converts equivalent boundaries into one canonical representation:

This prevents duplicate marker positions for the same insertion boundary.


6. Validation Rules (_wouldReorderColumn)

A reorder is allowed only when all conditions pass:

  1. fromIndex and toIndex are valid
  2. Target is not selection checkbox column (__vgSelection__)
  3. Frozen/unfrozen parity matches:
    • frozen columns can only be dropped in frozen group
    • unfrozen columns can only be dropped in unfrozen group
  4. dropBefore is only allowed on the first column of the dragged group
  5. Computed insertIndex differs from fromIndex

The final insertion index is computed as:

insertIndex = toIndex + (dropBefore ? 0 : 1)
if (fromIndex < insertIndex) insertIndex -= 1

7. Data Mutation and State Preservation (reorderColumn)

reorderColumn(fromIndex, toIndex, { dropBefore }) performs:

  1. Input validation
  2. Snapshot of current columns (oldColumns)
  3. Snapshot sort mapping by column key (sortByKey)
  4. In-place array move (splice remove + insert)
  5. Rebuild sortColumnsState by keys against new order
  6. Recompute legacy sortState
  7. refreshHeaderLayout()
  8. persistColumnOrderToStorage()
  9. renderVisibleRows(true) if pool/data is already present

This ensures sorting remains semantically attached to column keys after reorder.


8. Persistence Model

Column order persistence uses localStorage.

8.1 Storage key resolution

resolveColumnOrderStorageKey():

vanilla-grid:column-order:{pathname}:{scope}

Where scope is first available of:

  1. gridId option (auto-populated from the <vanilla-grid> host element id)
  2. bodyTable.id
  3. viewport.id
  4. headerTable.id
  5. 'default'

Custom override is supported via persistence.columnOrder.storageKey option.

8.2 Save

persistColumnOrderToStorage() stores an ordered key array:

['name', 'city', 'weight', ...]

Also caches it in savedColumnOrderKeys.

8.3 Load and apply

Saved order is applied in setColumns() before freeze regrouping and header refresh.


9. Visual Feedback and CSS Hooks

Base CSS classes used during reorder:

Marker pseudo-element visuals are configurable via theme variables on .vn-grid-header-table th:

This keeps behavior in core JS while allowing theme-level visual tuning.


10. Interactions with Other Features

10.1 Selection column

10.2 Frozen columns

10.3 Sorting

10.4 Header resize


11. Failure-Safe and Cleanup Behavior


12. Example Configuration

const grid = new VanillaGrid({
  columns: {
    reorderable: true,
    touchReorder: true,
    touchReorderHoldDelay: 220,
    reorderMarkerDeadZonePx: 20
  },
  persistence: {
    columnOrder: { enabled: true, storageKey: 'my-grid:column-order' }
  }
});

For strict session-only behavior (no persistence):

persistence: { columnOrder: { enabled: false } }