# Row Drag-Drop Plugin

> Drag rows within a single grid (reorder) and across grids that share a drop zone.

The Row Drag-Drop plugin lets users rearrange rows by dragging a grip handle
or using keyboard shortcuts, and — when opted in via `dropZone` — drag rows
between separate grids. It is a strict superset of the (now deprecated)
`RowReorderPlugin`: every existing intra-grid behaviour is preserved.

- **Intra-grid** — drag a row up or down to reorder, or use `Ctrl + ↑/↓`.
- **Drag origin** — choose whether drags start from the dedicated handle
  column (`dragFrom: 'handle'`, default), from anywhere on the row
  (`dragFrom: 'row'` — handle column hidden), or from both
  (`dragFrom: 'both'`). Interactive descendants (buttons, inputs, links,
  `[contenteditable]`) inside the row are still respected and never start
  a drag.
- **Cross-grid** — set a `dropZone` to allow dragging rows to another grid
  with the same `dropZone`. Supports `move` and `copy` operations.
- **Multi-row** — when the [Selection](../selection/) plugin is loaded and
  the dragged row is part of a multi-row selection, all selected rows are
  dragged together and a count badge appears on the drag image. There is
  no `selection` config option — the behaviour is automatic.
- **Cross-window** — drag rows between separate browser windows or tabs
  on the same origin. The payload travels via `dataTransfer`, and a
  same-origin `BroadcastChannel` coordinates the source-side row removal
  for `operation: 'move'` and the source-side `row-transfer` event.

## Installation

```ts
import '@toolbox-web/grid/features/row-drag-drop';
```

The legacy `reorderRows` feature key continues to work and forwards to the
same plugin instance.

## Basic Usage

Enable the feature and a drag handle column appears automatically. Users can
drag rows to new positions or use `Ctrl + ↑/↓` to move the focused row.

#### TypeScript

```ts
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/row-drag-drop';

const grid = queryGrid('tbw-grid');
grid.gridConfig = {
  columns: [
    { field: 'priority', header: '#', type: 'number', width: 50 },
    { field: 'task', header: 'Task' },
    { field: 'status', header: 'Status' },
  ],
  features: {
    rowDragDrop: {
      dragHandlePosition: 'left',
      enableKeyboard: true,
      animation: 'flip',
    },
  },
};

// Listen for row moves (intra-grid)
grid.on('row-move', ({ fromIndex, toIndex }) => {
  console.log(`Moved row from ${fromIndex} to ${toIndex}`);
});
```

#### React

```tsx
import '@toolbox-web/grid-react/features/row-drag-drop';
import { DataGrid } from '@toolbox-web/grid-react';

function ReorderableTaskList({ tasks }) {
  return (
    <DataGrid
      rows={tasks}
      columns={[
        { field: 'priority', header: '#', type: 'number', width: 50 },
        { field: 'task', header: 'Task' },
        { field: 'status', header: 'Status' },
      ]}
      rowDragDrop
      onRowMove={(detail) => console.log('Row moved:', detail)}
    />
  );
}
```

#### Vue

```html
<script setup>
import '@toolbox-web/grid-vue/features/row-drag-drop';
import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';

const tasks = [
  { priority: 1, task: 'Review PR', status: 'Pending' },
  { priority: 2, task: 'Deploy staging', status: 'In Progress' },
];
</script>

<template>
  <TbwGrid :rows="tasks" row-drag-drop @row-move="(e) => console.log('Row moved:', e.detail)">
    <TbwGridColumn field="priority" header="#" type="number" :width="50" />
    <TbwGridColumn field="task" header="Task" />
    <TbwGridColumn field="status" header="Status" />
  </TbwGrid>
</template>
```

#### Angular

```typescript
// Feature import - enables the [rowDragDrop] input
import { GridRowDragDropDirective } from '@toolbox-web/grid-angular/features/row-drag-drop';
import { Component } from '@angular/core';
import { Grid } from '@toolbox-web/grid-angular';
import type { ColumnConfig } from '@toolbox-web/grid';

@Component({
  selector: 'app-task-list',
  imports: [Grid, GridRowDragDropDirective],
  template: `
    <tbw-grid
      [rows]="rows"
      [columns]="columns"
      [rowDragDrop]="true"
      (rowMove)="onRowMove($event)"
      style="height: 400px; display: block;">
    </tbw-grid>
  `,
})
export class TaskListComponent {
  rows = [
    { priority: 1, task: 'Review PR', status: 'Pending' },
    { priority: 2, task: 'Deploy staging', status: 'In Progress' },
  ];

  columns: ColumnConfig[] = [
    { field: 'priority', header: '#', type: 'number', width: 50 },
    { field: 'task', header: 'Task' },
    { field: 'status', header: 'Status' },
  ];

  onRowMove(detail: any) {
    console.log('Row moved:', detail);
  }
}
```

## Demos

### Default Drag-Drop

```ts
// RowDragDropDefaultDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/row-drag-drop';

const container = document.getElementById('row-drag-drop-default-demo');
if (container) {
  const sampleData = [
    { id: 1, name: 'Alice', department: 'Engineering', email: 'alice@example.com', priority: 1 },
    { id: 2, name: 'Bob', department: 'Marketing', email: 'bob@example.com', priority: 2 },
    { id: 3, name: 'Carol', department: 'Engineering', email: 'carol@example.com', priority: 3 },
    { id: 4, name: 'David', department: 'Sales', email: 'david@example.com', priority: 4 },
    { id: 5, name: 'Eve', department: 'Engineering', email: 'eve@example.com', priority: 5 },
  ];
  const columns = [
    { field: 'priority', header: '#', type: 'number', width: 50 },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
    { field: 'email', header: 'Email' },
  ];

  const grid = queryGrid('tbw-grid', container)!;

  function rebuild(dragFrom = 'handle', dragHandlePosition = 'left', enableKeyboard = true, animation = 'flip') {
    grid.gridConfig = {
      columns,
      features: { rowDragDrop: { dragFrom, dragHandlePosition, enableKeyboard, animation: animation === 'false' ? false : animation } as any },
    };
    grid.rows = sampleData;
  }

  rebuild();

  container.addEventListener('control-change', ((e: CustomEvent) => {
    const v = e.detail.allValues;
    rebuild(v.dragFrom as string, v.dragHandlePosition as string, v.enableKeyboard as boolean, v.animation as string);
  }) as EventListener);
}
```

Drag the grip handle (☰) to reorder rows. Use `Ctrl + ↑/↓` to move the
focused row with the keyboard.

### Cancelable `row-move`

```ts
// RowDragDropCancelableEventDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';

import '@toolbox-web/grid/features/row-drag-drop';

const container = document.getElementById('row-drag-drop-cancelable-event-demo');
if (container) {
  const sampleData = [
    { id: 1, name: 'Alice', department: 'Engineering', email: 'alice@example.com', priority: 1 },
    { id: 2, name: 'Bob', department: 'Marketing', email: 'bob@example.com', priority: 2 },
    { id: 3, name: 'Carol', department: 'Engineering', email: 'carol@example.com', priority: 3 },
    { id: 4, name: 'David', department: 'Sales', email: 'david@example.com', priority: 4 },
    { id: 5, name: 'Eve', department: 'Engineering', email: 'eve@example.com', priority: 5 },
  ];
  const columns = [
    { field: 'priority', header: '#', type: 'number', width: 50 },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
    { field: 'email', header: 'Email' },
  ];

  const status = document.getElementById('row-drag-drop-cancelable-status')!;

  const grid = queryGrid('tbw-grid', container);

  grid.gridConfig = {
    columns,
    features: { rowDragDrop: true },
  };
  grid.rows = sampleData;

  // Prevent moving Bob
  grid.on('row-move', (detail, e) => {
    if (detail.row.name === 'Bob') {
      e.preventDefault();
      status.textContent = '❌ Cannot move Bob!';
      status.style.background = 'var(--tbw-color-danger-light)';
    } else {
      status.textContent = `✓ Moved ${detail.row.name} from index ${detail.fromIndex} to ${detail.toIndex}`;
      status.style.background = 'var(--tbw-color-success-light)';
    }
  });
}
```

Cancel row moves by calling `event.preventDefault()` in the `row-move`
handler.

### Cross-grid transfer

The grids below share `dropZone: 'employees'` with `operation: 'move'`.
Drag any row from the left grid to the right grid (or back).

```ts
// RowDragDropCrossGridDemo.astro
import '@toolbox-web/grid';
import { queryGrid, type ColumnConfig } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/row-drag-drop';
import '@toolbox-web/grid/features/selection';

const container = document.getElementById('row-drag-drop-cross-grid-demo');
if (container) {
  const columns: ColumnConfig<any>[] = [
    { field: 'id', header: 'ID', type: 'number', width: 60 },
    { field: 'name', header: 'Name' },
    { field: 'department', header: 'Department' },
  ];

  const source = queryGrid('tbw-grid#rdd-source', container)!;
  const target = queryGrid('tbw-grid#rdd-target', container)!;

  // Enable Selection on the source grid so multi-row drag is automatic when
  // the dragged row is part of a multi-row selection. RowDragDrop has no
  // `selection` config — the behaviour is driven entirely by the presence of
  // a multi-row selection at dragstart.
  source.gridConfig = {
    columns,
    features: {
      rowDragDrop: { dropZone: 'employees', operation: 'move' },
      selection: 'row',
    },
  };
  source.rows = [
    { id: 1, name: 'Alice', department: 'Engineering' },
    { id: 2, name: 'Bob', department: 'Marketing' },
    { id: 3, name: 'Carol', department: 'Engineering' },
    { id: 4, name: 'David', department: 'Sales' },
    { id: 5, name: 'Eve', department: 'Engineering' },
  ];

  target.gridConfig = {
    columns,
    features: { rowDragDrop: { dropZone: 'employees', operation: 'move' } },
  };
  target.rows = [];
}
```

### Cross-window transfer

Grids in different browser windows (or tabs) on the same origin can also
exchange rows. The button in the demo below opens a popout window that
hosts a target grid joined to the same `dropZone`. Drag any row from the
left grid into the popout — the row is moved across windows, and the
source grid's `row-transfer` event fires once the popout confirms the drop.

```ts
// RowDragDropCrossWindowDemo.astro
import '@toolbox-web/grid';
import { queryGrid } from '@toolbox-web/grid';
import '@toolbox-web/grid/features/row-drag-drop';

const container = document.getElementById('row-drag-drop-cross-window-demo');
if (container) {
  const source = queryGrid('tbw-grid#rdd-cross-window-source', container)!;
  const status = container.querySelector('#rdd-cross-window-status') as HTMLElement;
  const btn = container.querySelector('#rdd-popout-btn') as HTMLButtonElement;

  source.gridConfig = {
    columns: [
      { field: 'id', header: 'ID', type: 'number', width: 60 },
      { field: 'name', header: 'Name' },
      { field: 'department', header: 'Department' },
    ],
    features: {
      rowDragDrop: { dropZone: 'cross-window-employees', operation: 'move' },
    },
  };
  source.rows = [
    { id: 1, name: 'Alice', department: 'Engineering' },
    { id: 2, name: 'Bob', department: 'Marketing' },
    { id: 3, name: 'Carol', department: 'Engineering' },
    { id: 4, name: 'David', department: 'Sales' },
    { id: 5, name: 'Eve', department: 'Engineering' },
  ];

  source.addEventListener('row-transfer', ((e: Event) => {
    const detail = (e as CustomEvent).detail as { rows: { name: string }[]; toGridId: string };
    status.textContent = `Sent ${detail.rows.length} row(s) to ${detail.toGridId}.`;
  }) as EventListener);

  btn.addEventListener('click', () => {
    const w = window.open(
      '/grid/row-drag-drop-popout/',
      'tbw-rdd-popout',
      'width=520,height=480,resizable=yes,scrollbars=yes',
    );
    if (!w) {
      status.textContent = 'Popup blocked. Please allow popups for this site and try again.';
    } else {
      status.textContent = 'Popout opened. Drag rows from above into the new window.';
    }
  });
}
```

The popout page itself is a tiny standalone host that registers the same
`dropZone` ([`pages/grid/row-drag-drop-popout.astro`](https://github.com/OysteinAmundsen/toolbox/blob/main/apps/docs/src/pages/grid/row-drag-drop-popout.astro)).

Under the hood the plugin sets a custom MIME type on `dataTransfer` and
opens a `BroadcastChannel('tbw-row-drag-drop')`. The target window decodes
the payload from `dataTransfer` and broadcasts a confirmation; the source
window's plugin instance listens on the same channel and — on `move` —
removes the transferred rows and emits `row-transfer` locally.

**Custom row shapes.** Rows are round-tripped through `JSON.parse(JSON.stringify(row))`
by default. If your rows contain non-JSON values (functions, `Date`, `Map`,
etc.), provide `serializeRow` / `deserializeRow` so the target reconstructs
an equivalent object:

```ts
features: {
  rowDragDrop: {
    dropZone: 'employees',
    serializeRow: (row) => ({ ...row, hiredAt: row.hiredAt.toISOString() }),
    deserializeRow: (raw) => ({ ...raw, hiredAt: new Date(raw.hiredAt) }),
  },
}
```

**Caveats.**

- Cross-window coordination requires `BroadcastChannel`; in environments
  without it (very old browsers, sandboxed iframes), only the target
  receives rows and the source is left untouched.
- Both windows must be on the same origin — `BroadcastChannel` is
  origin-scoped.
- The source's `row-drag-end` may fire with `accepted: false` before the
  remote confirmation arrives. Treat the `row-transfer` event as the
  authoritative success signal for cross-window transfers.

## Configuration

See [`RowDragDropConfig`](./Interfaces/RowDragDropConfig/) for the full list
of options and defaults. Key options:

| Option              | Type                                              | Description                                                                          |
| ------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `dropZone`          | `string`                                          | Opt-in identifier for cross-grid drops. Grids with the same value accept each other. |
| `operation`         | `'move' \| 'copy'`                                | Whether the source grid keeps the rows after a successful cross-grid drop.           |
| `canDrop`           | `(payload, targetIndex) => boolean`               | Reject specific drops on the target grid. Called synchronously during `dragover` and at drop time. |
| `canDrag`           | `(row, index) => boolean`                         | Reject drags from the source. Called once at `dragstart` with the originating row.   |
| `serializeRow`      | `(row) => unknown`                                | Override the JSON shape used in the cross-window fallback payload.                   |
| `deserializeRow`    | `(serialized) => row`                             | Inverse of `serializeRow`.                                                           |
| `enableKeyboard`    | `boolean`                                         | Keep keyboard reorder (`Ctrl + ↑/↓`) — defaults to `true`.                           |
| `dragFrom`          | `'handle' \| 'row' \| 'both'`                     | Where a drag can be initiated. `'handle'` (default) keeps the handle column; `'row'` hides the handle and lets users drag anywhere on the row; `'both'` allows either. Interactive descendants (`button`, `input`, `select`, `textarea`, `a[href]`, `[contenteditable]`) never start a drag. |
| `showDragHandle`    | `boolean`                                         | Render the drag-handle column. Defaults to `true` for `dragFrom: 'handle' \| 'both'` and `false` for `dragFrom: 'row'`. Set explicitly to override.            |
| `dragHandlePosition`| `'left' \| 'right'`                               | Pin the handle column to either side.                                                |
| `dragHandleWidth`   | `number`                                          | Pixel width of the handle column.                                                    |
| `animation`         | `'flip' \| false`                                 | FLIP animation on intra-grid reorder.                                                |
| `autoScroll`        | `boolean \| { edgeSize?: number; speed?: number; maxSpeed?: number }` | Tune the auto-scroll behaviour during drag.                          |

## Events

| Event             | Detail                                                              | Cancelable | When                                                          |
| ----------------- | ------------------------------------------------------------------- | ---------- | ------------------------------------------------------------- |
| `row-move`        | [`RowMoveDetail`](./Interfaces/RowMoveDetail/)                      | Yes        | Intra-grid reorder committed (back-compat).                   |
| `row-drag-start`  | [`RowDragStartDetail`](./Interfaces/RowDragStartDetail/)            | Yes        | A drag begins.                                                |
| `row-drag-end`    | [`RowDragEndDetail`](./Interfaces/RowDragEndDetail/)                | No         | The drag ends, regardless of whether a drop succeeded.        |
| `row-drop`        | [`RowDropDetail`](./Interfaces/RowDropDetail/)                      | Yes        | Rows are dropped on this grid from another grid.              |
| `row-transfer`    | [`RowTransferDetail`](./Interfaces/RowTransferDetail/)              | No         | Successful cross-grid transfer (fired on **both** grids).     |

## Keyboard Shortcuts

| Key          | Action                    |
| ------------ | ------------------------- |
| `Ctrl + ↑`   | Move focused row up       |
| `Ctrl + ↓`   | Move focused row down     |

## Styling

The plugin adds these CSS classes you can customise:

| Class                                  | Description                                         |
| -------------------------------------- | --------------------------------------------------- |
| `.dg-row-drag-handle`                  | The drag handle element                             |
| `.dg-row-drag-handle:hover`            | Handle hover state                                  |
| `.data-grid-row.dragging`              | Row currently being dragged                         |
| `.data-grid-row.drop-target`           | Row being hovered as drop target                    |
| `.data-grid-row.drop-before`           | Drop indicator showing insertion above              |
| `.data-grid-row.drop-after`            | Drop indicator showing insertion below              |
| `.tbw-grid--drag-source`               | The source grid during a drag (whole-grid state)    |
| `.tbw-grid--drop-target-active`        | The target grid during a valid dragover             |
| `.tbw-grid--drop-target-rejected`      | The target grid during a `canDrop`-rejected drag    |
| `.tbw-grid--auto-scrolling`            | A grid currently auto-scrolling during a drag       |

### Custom Handle Icon

Override the default grip icon via CSS:

```css
.dg-row-drag-handle::before {
  content: '⋮⋮'; /* Double vertical ellipsis */
}
```

## Notes

- **Row identification** — currently uses processed/visual row indices
  (`data-row` attribute and `payload.rowIndices`), not `getRowId()`.
  Same-window cross-grid drops also recover live object references via the
  internal `WeakRef` registry; primitive row values fall through to the
  JSON payload.
- **Data mutation** — the plugin reorders `grid.rows` in place. The
  `row-move` event provides the updated array.
- **Virtualization** — works with virtualized grids; only visible rows are
  rendered but logical indices are preserved.
- **Keyboard debounce** — rapid keyboard moves are debounced (150 ms) to
  prevent excessive rerenders.
- **Drag image** — when a drag starts the plugin clones the source row and
  uses it as the HTML5 drag image so the user sees the full row content
  follow the cursor (instead of just the handle cell). The browser applies
  its standard translucency to the snapshot — this is a native effect that
  cannot be disabled.
- **Cross-window fallback** — when the live row reference cannot be
  recovered from the same-window registry (e.g. drop into a different
  window), the row payload is decoded from `dataTransfer` JSON via
  `serializeRow`/`deserializeRow`. The source window completes its half
  of the transfer (row removal on `move`, `row-transfer` emit) when it
  receives the confirmation message on the `tbw-row-drag-drop`
  `BroadcastChannel`.

## Migration from `RowReorderPlugin`

`RowReorderPlugin` is now an alias for `RowDragDropPlugin`. Existing code
keeps working until V3 — both `reorderRows` and `rowDragDrop` feature keys
resolve to the same plugin instance, and merging conflicting configs across
the two keys will throw at attach time. To migrate:

```diff
- import { RowReorderPlugin } from '@toolbox-web/grid/plugins/reorder-rows';
+ import { RowDragDropPlugin } from '@toolbox-web/grid/plugins/row-drag-drop';

- new RowReorderPlugin(cfg);
+ new RowDragDropPlugin(cfg);
```

The legacy `canMove` callback continues to work — it is mapped internally to
`canDrop` with a synthesised intra-grid payload.

## See Also

- **[Column Reorder](../reorder-columns/)** — Drag-to-reorder columns
- **[Selection](../selection/)** — Row and cell selection (drives multi-row drag)
