Lattice Grid Buy a licence

developer guide

Row Reordering in a JavaScript Data Grid

Rows can be dragged into a new order, either from the whole row or from one handle column, and grid.rows.move() does the same from code. A move your rules do not allow is refused with a reason rather than quietly undone.

Developer guide › Editing › Row Reordering in a JavaScript Data Grid

Row reorder

rowReorder: true puts a drag handle in the first visible column and lets a user move rows with it, or with Alt+Shift+↑/↓. { column: 'name' } puts the handle somewhere else.

Reordering a list, and saving the result

createGrid(element, {
  columns,
  rows,
  rowReorder: true,
});

grid.on('row:moved', ({ key, from, to }) => {
  // rows.data() is the new order in full.
  api.saveOrder(grid.rows.data().map((r, i) => ({ id: r.id, order: i })));
});

The order is your data, not a view of it. A move reorders the array you gave the grid and tells you it happened; writing it somewhere permanent is yours, because only you know where the order lives. A grid that rearranged rows on screen and stopped there would look finished and lose the order on the next load, which is worse than not offering the feature.

If the save fails, move it back: grid.rows.move(key, from).

It refuses while a sort, filter or grouping is active, and says why out loud rather than springing the row back in silence. The reason is that dropping between two visible rows says nothing about where the row belongs in the underlying array: under a filter there may be hidden rows between them, and under a sort the displayed order is something the grid computed rather than something the data says. Rather than pick an interpretation and put the row somewhere you did not ask for, the move is declined. rows.move() returns { moved: false, reason } so you can handle it yourself. That stays the default; the two opt-ins below give a sorted or grouped drop a meaning of its own.

A manual order under a sort

rowReorder: { manualOrder: true } puts a Manual order button in the heading of the handle column (the column option, or the first visible one), also bound to Alt+Shift+M. Pressing it on a sorted grid keeps every row where the sort put it, clears the sort, and from then on a drag sets an explicit order. The button stays pressed, and the grid root carries data-row-order="manual", for as long as it is in force, so a heading with no sort arrow still says what is ordering the rows. Pressing it again returns to the sort. The same thing from code is grid.rowOrder: enter(), set(keys), exit(), manual() and get().

Hand-order a sorted triage list, save it, and bring it back

const { createHeadlessGrid, serialiseState, restoreState } = await import('../packages/core/src/index.js');

const make = () => createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'title' }, { field: 'priority', type: 'number' }],
  rows: [
    { id: 1, title: 'Login fails', priority: 1 },
    { id: 2, title: 'Slow export', priority: 2 },
    { id: 3, title: 'Typo in footer', priority: 3 },
  ],
  // The heading control, in the title column's heading. Headless, so here it is the API.
  rowReorder: { column: 'title', manualOrder: true },
});
const ids = (g) => g.rows.data().map((r) => r.id).join(',');

const grid = make();
grid.sort.set([{ col: 'priority', dir: 'asc' }]);
let saved = null;
// Entering, every move and leaving are announced as state changes, like any view state.
grid.on('state:changed', ({ sections }) => {
  if (sections.includes('rowOrder') && grid.rowOrder.manual()) saved = serialiseState(grid);
});

grid.rowOrder.enter();          // explicit: the rows stay put, the sort is cleared
grid.rows.move('3', 0);         // what a drag or Alt+Shift+Up does
const manual = ids(grid);
grid.destroy();

const again = make();
restoreState(again, saved);     // the order travels in the saved state
const restored = ids(again);
again.rowOrder.exit();          // the way back: the sort it was entered from
const back = ids(again);
again.destroy();
return `manual ${manual} | restored ${restored} | back to sorted ${back}`;

Which wins: the manual order, until you sort. While a manual order is in force it overrides the sort; any sort ends it, whether that is a heading click, sort.set, sort.clear or restoring a view that was saved sorted. rowOrder.exit() returns to the sort it was entered from. Entering is never silent: it takes the button, the shortcut or a call, it fires state:changed with rowOrder among its sections, and it is announced. Clearing the sort to enter is a sort change, so a beforeSort handler that locks a view's sort locks this too.

The data is not reordered. The order is a list of row keys, carried by state.get() as rowOrder (the keys, and the sort to return to), so serialiseState and saved views keep it and restoreState puts it back. A row the list does not name, one added since, goes after the named rows. Under a grouping, a manual order also lets a row move within its group.

Moving a row into another group

rowReorder: { betweenGroups: true } lets a row dragged onto a row or heading of another group join it, the way a card moves between kanban columns. With the keyboard, Alt+Shift+↑ from the first row of a group, or ↓ from the last, carries it across. Joining a group means taking that group's value, so the move is an edit: it goes through beforeEdit, with every grouping level that differs in one change, so a handler can veto it and grid.edit.undo() puts it back. beforeRowMove and row:moved carry the values as regrouped, and both carry the origin. Group counts and totals recompute as for any edit.

Re-categorise by dragging, with a host that can say no

const { createHeadlessGrid } = await import('../packages/core/src/index.js');

const grid = createHeadlessGrid({
  rowKey: 'id',
  columns: [{ field: 'name' }, { field: 'team', edit: { enabled: true } }],
  rows: [{ id: 1, name: 'Ann', team: 'red' }, { id: 2, name: 'Bob', team: 'blue' }],
  rowReorder: { betweenGroups: true },
});
grid.columns.group(['team']);    // blue heading, Bob, red heading, Ann

grid.rows.move('1', 1);          // Ann dropped on Bob: she joins blue
const joined = grid.rows.byKey('1').data.team;
grid.edit.undo();                // one step puts her back in red

const off = grid.on('beforeEdit', (e) => e.preventDefault('frozen'));
const vetoed = grid.rows.move('1', 1);
off();
const after = grid.rows.byKey('1').data.team;
grid.destroy();
return `${joined} | ${vetoed.reason}: ${vetoed.detail} | ${after}`;

The grouping column has to be editable (edit: { enabled: true }). A column that is not, or one grouped by a derived bucket such as a date's month, refuses the move rather than guessing a value.

What is refused, and what is said

Every refusal is announced through the grid's live region, and returned by rows.move() as reason:

reasonWhenWhat a screen reader hears
sortedA sort is in force and no manual order is.Rows cannot be reordered while the grid is sorted (with manualOrder, it adds how to order them by hand)
filteredA filter is in force: a drop between visible rows says nothing about the hidden rows between them. Always refused.Rows cannot be reordered while a filter is applied
groupedA grouping is in force, and the move is within a group without a manual order, or into another group without betweenGroups.Rows cannot be reordered while the grid is grouped
group-not-editableThe grouping column cannot be edited.The row cannot join that group: its grouping value cannot be edited
group-derivedThe group is a bucket worked out from the value.The row cannot join that group: the group is worked out from the value…
invalidThe group's value failed the column's validation.The row cannot join that group: the new value was not valid
vetoedA beforeEdit handler declined it; detail is its reason.The move was declined (reason); the row stays where it was