Appearance
Row dragging
Columns with rowDrag: true show a drag handle. Dragging a row shows a drop indicator between the rows, the grid scrolls automatically near the edges. Escape cancels dragging.
The grid does not change the data itself. It emits row-drop with the dragged row, the target row and the position ("before" or "after"), so the application decides what happens (reorder, move to another group, save the order on the server, …).
ts
type RowDropEvent<T> = {
row: Row<T>; // dragged row
target: Item; // row (or group row) under the mouse
position: "before" | "after";
};TIP
Reordering rows by dragging makes most sense without sorting. Disable sorting or clear it (api.clearSort()) when the user starts to reorder. Always set a rowId, otherwise selection and expanded state move to other rows after reordering.
Reordering a list
The most common case: the user sorts a flat list manually (tasks, priorities, playlist). applyRowDrop(rows, event) returns a reordered copy of a flat array.
Source
vue
<template>
<Datagrid
:columns="columns"
:rows="tasks"
:options="options"
@row-drop="tasks = applyRowDrop(tasks, $event)"
/>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, applyRowDrop, type ColumnConfig, type GridOptions } from "@datagrid/vue-ui";
type Task = { id: number; title: string; priority: string; done: boolean };
const tasks = ref<Array<Task>>(
["Plan sprint", "Write specs", "Review PR", "Fix bug #42", "Update docs", "Release 1.0", "Celebrate"].map(
(title, i) => ({ id: i + 1, title, priority: ["high", "medium", "low"][i % 3], done: i < 2 })
)
);
const columns: Array<ColumnConfig<Task>> = [
// the column with rowDrag shows the drag handle
{ field: "title", text: "Task", rowDrag: true, width: 240 },
{ field: "priority", text: "Priority" },
{ field: "done", text: "Done", type: "boolean", flex: 1 },
];
const options: GridOptions<Task> = { rowId: (data) => data.id };
</script>tsx
import { useState } from "react";
import { Datagrid, applyRowDrop, type ColumnConfig, type GridOptions } from "@datagrid/react-ui";
type Task = { id: number; title: string; priority: string; done: boolean };
const initialTasks: Array<Task> = [
"Plan sprint", "Write specs", "Review PR", "Fix bug #42", "Update docs", "Release 1.0", "Celebrate",
].map((title, i) => ({ id: i + 1, title, priority: ["high", "medium", "low"][i % 3], done: i < 2 }));
const columns: Array<ColumnConfig<Task>> = [
// the column with rowDrag shows the drag handle
{ field: "title", text: "Task", rowDrag: true, width: 240 },
{ field: "priority", text: "Priority" },
{ field: "done", text: "Done", type: "boolean", flex: 1 },
];
const options: GridOptions<Task> = { rowId: (data) => data.id };
export default function RowDrag() {
const [tasks, setTasks] = useState(initialTasks);
return (
<Datagrid
columns={columns}
rows={tasks}
options={options}
onRowDrop={(e) => setTasks((tasks) => applyRowDrop(tasks, e))}
/>
);
}Rows that can't be moved
rowDrag can also be a function to allow dragging only for some rows: rowDrag: ({ data }) => !data.locked. Locked rows show no handle but are still drop targets, so validate the new order in the event handler and ignore drops that are not allowed. Values that depend on the order (here the start times) are calculated again after each drop.
Source
vue
<template>
<div class="demo">
<div class="demo-log">{{ log || "Reorder the talks, the start times are calculated again. Opening and closing can't be moved." }}</div>
<Datagrid :columns="columns" :rows="agenda" :options="options" @row-drop="onRowDrop" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, applyRowDrop, type ColumnConfig, type GridOptions, type RowDropEvent } from "@datagrid/vue-ui";
type Talk = { id: number; title: string; speaker: string; minutes: number; locked?: boolean; start?: string };
// calculates the start time of each talk from the order and the durations
function withStartTimes(talks: Array<Talk>): Array<Talk> {
let time = 9 * 60;
return talks.map((talk) => {
const start = `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`;
time += talk.minutes;
return { ...talk, start };
});
}
const agenda = ref(
withStartTimes([
{ id: 1, title: "Opening", speaker: "Anna Weber", minutes: 15, locked: true },
{ id: 2, title: "State of the project", speaker: "Ben Fischer", minutes: 45 },
{ id: 3, title: "Performance deep dive", speaker: "Clara Koch", minutes: 30 },
{ id: 4, title: "Coffee break", speaker: "", minutes: 15 },
{ id: 5, title: "Accessibility in data grids", speaker: "David Meyer", minutes: 30 },
{ id: 6, title: "Live coding: custom editors", speaker: "Emma Wagner", minutes: 45 },
{ id: 7, title: "Q & A", speaker: "", minutes: 20 },
{ id: 8, title: "Closing", speaker: "Anna Weber", minutes: 10, locked: true },
])
);
const log = ref("");
function onRowDrop(e: RowDropEvent<Talk>) {
const reordered = applyRowDrop(agenda.value, e);
// nothing may be moved before the opening or after the closing
if (!reordered[0].locked || !reordered[reordered.length - 1].locked) {
log.value = "The agenda must start with the opening and end with the closing.";
return;
}
agenda.value = withStartTimes(reordered);
log.value = `Moved "${e.row.data.title}"`;
}
const columns: Array<ColumnConfig<Talk>> = [
// only talks which are not locked can be dragged
{ field: "title", text: "Talk", width: 260, rowDrag: ({ data }) => !data.locked },
{ field: "start", text: "Start", width: 80 },
{ field: "minutes", text: "Minutes", type: "number", width: 100 },
{ field: "speaker", text: "Speaker", flex: 1 },
];
const options: GridOptions<Talk> = { rowId: (data) => data.id };
</script>tsx
import { useState } from "react";
import { Datagrid, applyRowDrop, type ColumnConfig, type GridOptions, type RowDropEvent } from "@datagrid/react-ui";
type Talk = { id: number; title: string; speaker: string; minutes: number; locked?: boolean; start?: string };
// calculates the start time of each talk from the order and the durations
function withStartTimes(talks: Array<Talk>): Array<Talk> {
let time = 9 * 60;
return talks.map((talk) => {
const start = `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`;
time += talk.minutes;
return { ...talk, start };
});
}
const initialAgenda = withStartTimes([
{ id: 1, title: "Opening", speaker: "Anna Weber", minutes: 15, locked: true },
{ id: 2, title: "State of the project", speaker: "Ben Fischer", minutes: 45 },
{ id: 3, title: "Performance deep dive", speaker: "Clara Koch", minutes: 30 },
{ id: 4, title: "Coffee break", speaker: "", minutes: 15 },
{ id: 5, title: "Accessibility in data grids", speaker: "David Meyer", minutes: 30 },
{ id: 6, title: "Live coding: custom editors", speaker: "Emma Wagner", minutes: 45 },
{ id: 7, title: "Q & A", speaker: "", minutes: 20 },
{ id: 8, title: "Closing", speaker: "Anna Weber", minutes: 10, locked: true },
]);
const columns: Array<ColumnConfig<Talk>> = [
// only talks which are not locked can be dragged
{ field: "title", text: "Talk", width: 260, rowDrag: ({ data }) => !data.locked },
{ field: "start", text: "Start", width: 80 },
{ field: "minutes", text: "Minutes", type: "number", width: 100 },
{ field: "speaker", text: "Speaker", flex: 1 },
];
const options: GridOptions<Talk> = { rowId: (data) => data.id };
export default function RowDragLocked() {
const [agenda, setAgenda] = useState(initialAgenda);
const [log, setLog] = useState("");
function onRowDrop(e: RowDropEvent<Talk>) {
const reordered = applyRowDrop(agenda, e);
// nothing may be moved before the opening or after the closing
if (!reordered[0].locked || !reordered[reordered.length - 1].locked) {
setLog("The agenda must start with the opening and end with the closing.");
return;
}
setAgenda(withStartTimes(reordered));
setLog(`Moved "${e.row.data.title}"`);
}
return (
<div className="demo">
<div className="demo-log">
{log || "Reorder the talks, the start times are calculated again. Opening and closing can't be moved."}
</div>
<Datagrid columns={columns} rows={agenda} options={options} onRowDrop={onRowDrop} />
</div>
);
}Moving rows between groups
With row grouping the target can be a data row or a group row (isRow(e.target) / isGroup(e.target)). Change the grouped value of the dragged row to move it into the group of the target – like a simple kanban board.
Source
vue
<template>
<div class="demo">
<div class="demo-log">{{ log || "Drag a task into another status group." }}</div>
<Datagrid
:columns="columns"
:rows="tasks"
:options="options"
v-model:columnGroups="groups"
@row-drop="onRowDrop"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Datagrid, isRow, type ColumnConfig, type GridOptions, type Group, type RowDropEvent } from "@datagrid/vue-ui";
import { createTasks, type Task } from "./data";
const tasks = ref(createTasks(16));
const groups = ref(["status"]);
const log = ref("");
function onRowDrop(e: RowDropEvent<Task>) {
// the target is a task (row) or a status group (group row)
const target = isRow(e.target) ? e.target.data : undefined;
const status = (target ? target.status : (e.target as Group).key) as Task["status"];
const moved = { ...e.row.data, status, done: status === "done" };
// remove the task and insert it before / after the target (or at the end of the group)
const list = tasks.value.filter((task) => task.id !== moved.id);
const index = target ? list.findIndex((task) => task.id === target.id) + (e.position === "after" ? 1 : 0) : list.length;
list.splice(index, 0, moved);
tasks.value = list;
log.value = `"${moved.title}" → ${status}`;
}
const columns: Array<ColumnConfig<Task>> = [
{ field: "title", text: "Task", rowDrag: true, width: 260 },
{ field: "status", text: "Status", groupable: true },
{ field: "priority", text: "Priority" },
{ field: "assignee", text: "Assignee", flex: 1 },
];
const options: GridOptions<Task> = {
rowId: (data) => data.id,
groupDefaultExpanded: -1,
groupShowCount: true,
};
</script>tsx
import { useState } from "react";
import { Datagrid, isRow, type ColumnConfig, type GridOptions, type Group, type RowDropEvent } from "@datagrid/react-ui";
import { createTasks, type Task } from "../data";
const initialTasks = createTasks(16);
const columns: Array<ColumnConfig<Task>> = [
{ field: "title", text: "Task", rowDrag: true, width: 260 },
{ field: "status", text: "Status", groupable: true },
{ field: "priority", text: "Priority" },
{ field: "assignee", text: "Assignee", flex: 1 },
];
const options: GridOptions<Task> = {
rowId: (data) => data.id,
groupDefaultExpanded: -1,
groupShowCount: true,
};
export default function RowDragGroups() {
const [tasks, setTasks] = useState(initialTasks);
const [groups, setGroups] = useState(["status"]);
const [log, setLog] = useState("");
function onRowDrop(e: RowDropEvent<Task>) {
// the target is a task (row) or a status group (group row)
const target = isRow(e.target) ? e.target.data : undefined;
const status = (target ? target.status : (e.target as Group).key) as Task["status"];
const moved = { ...e.row.data, status, done: status === "done" };
// remove the task and insert it before / after the target (or at the end of the group)
const list = tasks.filter((task) => task.id !== moved.id);
const index = target ? list.findIndex((task) => task.id === target.id) + (e.position === "after" ? 1 : 0) : list.length;
list.splice(index, 0, moved);
setTasks(list);
setLog(`"${moved.title}" → ${status}`);
}
return (
<div className="demo">
<div className="demo-log">{log || "Drag a task into another status group."}</div>
<Datagrid
columns={columns}
rows={tasks}
options={options}
columnGroups={groups}
onColumnGroupsChange={setGroups}
onRowDrop={onRowDrop}
/>
</div>
);
}Moving rows in a tree
For tree data the event contains only the position relative to the target row, so the application defines the rules. Here dropping on a folder moves the item into the folder, dropping on a file places it before or after the file. e.target.parent contains the parent row of the target if you need it.
Source
vue
<template>
<div class="demo">
<div class="demo-log">{{ log || "Drop a file on a folder to move it into the folder, drop it on a file to place it next to it." }}</div>
<Datagrid
:columns="columns"
:rows="files"
:options="options"
v-model:expandedItems="expanded"
@row-drop="onRowDrop"
/>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef } from "vue";
import { Datagrid, type ColumnConfig, type GridOptions, type ItemUid, type Row, type RowDropEvent } from "@datagrid/vue-ui";
type Item = { id: string; name: string; size?: number; children?: Array<Item> };
const files = shallowRef<Array<Item>>([
{
id: "docs", name: "docs", children: [
{ id: "docs/guide.md", name: "guide.md", size: 12 },
{ id: "docs/api.md", name: "api.md", size: 30 },
],
},
{
id: "images", name: "images", children: [
{ id: "images/logo.svg", name: "logo.svg", size: 4 },
{ id: "images/archive", name: "archive", children: [] },
],
},
{ id: "notes.txt", name: "notes.txt", size: 1 },
{ id: "photo.jpg", name: "photo.jpg", size: 2400 },
{ id: "budget.xlsx", name: "budget.xlsx", size: 85 },
]);
const expanded = ref<Array<ItemUid>>(["docs", "images"]);
const log = ref("");
const isFolder = (item: Item) => item.children !== undefined;
const contains = (item: Item, id: string): boolean => !!item.children?.some((child) => child.id === id || contains(child, id));
// returns a new tree without the item
const remove = (list: Array<Item>, id: string): Array<Item> =>
list.filter((item) => item.id !== id).map((item) => (item.children ? { ...item, children: remove(item.children, id) } : item));
// returns a new tree with the item inserted before, after or into the target
const insert = (list: Array<Item>, node: Item, targetId: string, where: "before" | "after" | "into"): Array<Item> =>
list.flatMap((item) => {
if (item.id === targetId) {
if (where === "into") return [{ ...item, children: [...item.children!, node] }];
return where === "before" ? [node, item] : [item, node];
}
return item.children ? [{ ...item, children: insert(item.children, node, targetId, where) }] : [item];
});
function onRowDrop(e: RowDropEvent<Item>) {
const node = e.row.data;
const target = (e.target as Row<Item>).data;
// a folder can't be moved into itself
if (contains(node, target.id)) return;
const where = isFolder(target) ? "into" : e.position;
files.value = insert(remove(files.value, node.id), node, target.id, where);
if (where === "into" && !expanded.value.includes(target.id)) {
expanded.value = [...expanded.value, target.id];
}
log.value = where === "into" ? `${node.name} → ${target.name}/` : `${node.name} ${where} ${target.name}`;
}
const columns: Array<ColumnConfig<Item>> = [
{ field: "name", text: "Name", rowDrag: true, width: 280 },
{
field: "size",
text: "Size",
type: "number",
flex: 1,
valueFormatter: ({ value }) => (value === undefined ? "" : `${value} KB`),
},
];
const options: GridOptions<Item> = {
// ids must not change when an item is moved (expanded state)
rowId: (data) => data.id,
};
</script>tsx
import { useState } from "react";
import { Datagrid, type ColumnConfig, type GridOptions, type ItemUid, type Row, type RowDropEvent } from "@datagrid/react-ui";
type Item = { id: string; name: string; size?: number; children?: Array<Item> };
const initialFiles: Array<Item> = [
{
id: "docs", name: "docs", children: [
{ id: "docs/guide.md", name: "guide.md", size: 12 },
{ id: "docs/api.md", name: "api.md", size: 30 },
],
},
{
id: "images", name: "images", children: [
{ id: "images/logo.svg", name: "logo.svg", size: 4 },
{ id: "images/archive", name: "archive", children: [] },
],
},
{ id: "notes.txt", name: "notes.txt", size: 1 },
{ id: "photo.jpg", name: "photo.jpg", size: 2400 },
{ id: "budget.xlsx", name: "budget.xlsx", size: 85 },
];
const isFolder = (item: Item) => item.children !== undefined;
const contains = (item: Item, id: string): boolean => !!item.children?.some((child) => child.id === id || contains(child, id));
// returns a new tree without the item
const remove = (list: Array<Item>, id: string): Array<Item> =>
list.filter((item) => item.id !== id).map((item) => (item.children ? { ...item, children: remove(item.children, id) } : item));
// returns a new tree with the item inserted before, after or into the target
const insert = (list: Array<Item>, node: Item, targetId: string, where: "before" | "after" | "into"): Array<Item> =>
list.flatMap((item) => {
if (item.id === targetId) {
if (where === "into") return [{ ...item, children: [...item.children!, node] }];
return where === "before" ? [node, item] : [item, node];
}
return item.children ? [{ ...item, children: insert(item.children, node, targetId, where) }] : [item];
});
const columns: Array<ColumnConfig<Item>> = [
{ field: "name", text: "Name", rowDrag: true, width: 280 },
{
field: "size",
text: "Size",
type: "number",
flex: 1,
valueFormatter: ({ value }) => (value === undefined ? "" : `${value} KB`),
},
];
const options: GridOptions<Item> = {
// ids must not change when an item is moved (expanded state)
rowId: (data) => data.id,
};
export default function RowDragTree() {
const [files, setFiles] = useState(initialFiles);
const [expanded, setExpanded] = useState<Array<ItemUid>>(["docs", "images"]);
const [log, setLog] = useState("");
function onRowDrop(e: RowDropEvent<Item>) {
const node = e.row.data;
const target = (e.target as Row<Item>).data;
// a folder can't be moved into itself
if (contains(node, target.id)) return;
const where = isFolder(target) ? "into" : e.position;
setFiles(insert(remove(files, node.id), node, target.id, where));
if (where === "into" && !expanded.includes(target.id)) {
setExpanded([...expanded, target.id]);
}
setLog(where === "into" ? `${node.name} → ${target.name}/` : `${node.name} ${where} ${target.name}`);
}
return (
<div className="demo">
<div className="demo-log">
{log || "Drop a file on a folder to move it into the folder, drop it on a file to place it next to it."}
</div>
<Datagrid
columns={columns}
rows={files}
options={options}
expandedItems={expanded}
onExpandedItemsChange={setExpanded}
onRowDrop={onRowDrop}
/>
</div>
);
}