جدول البيانات: إعادة ترتيب الصفوف
هذه الصفحة غير مترجمة بعد، وهي معروضة بالإنجليزية.
The data table lets people drag rows into a new order, by pointer, touch or keyboard. Pass enableRowReorder and onRowOrderChange: the table adds a drag handle to each row, reports every drop, and changes nothing until you store the new data.
| Step | Owner | Estimate | |
|---|---|---|---|
| Write the release notes | Lena Park | 2h | |
| Freeze the main branch | Yuki Tanaka | 15m | |
| Run the full test suite | Diego Alvarez | 1h | |
| Tag the version | Yuki Tanaka | 5m | |
| Publish the package | Sofia Reyes | 10m | |
| Announce the release | Omar Haddad | 30m |
Drag a handle, or focus one and press Space.
import { useState, type CSSProperties } from "react";
import { DataTable, type DataTableColumnDef, type RowOrderChange } from "slotsmith/data-table";
type Step = { id: string; title: string; owner: string; estimate: string };
const plan: Step[] = [
{ id: "s1", title: "Write the release notes", owner: "Lena Park", estimate: "2h" },
{ id: "s2", title: "Freeze the main branch", owner: "Yuki Tanaka", estimate: "15m" },
{ id: "s3", title: "Run the full test suite", owner: "Diego Alvarez", estimate: "1h" },
{ id: "s4", title: "Tag the version", owner: "Yuki Tanaka", estimate: "5m" },
{ id: "s5", title: "Publish the package", owner: "Sofia Reyes", estimate: "10m" },
{ id: "s6", title: "Announce the release", owner: "Omar Haddad", estimate: "30m" },
];
// Sorting is off: this table is ordered by hand. (A drop while sorted would
// keep the order on screen and clear the sort.)
const columns: DataTableColumnDef<Step>[] = [
{ accessorKey: "title", header: "Step", enableSorting: false },
{ accessorKey: "owner", header: "Owner", enableSorting: false },
{ accessorKey: "estimate", header: "Estimate", enableSorting: false },
];
// The page's own text colour, so the button reads in light and dark themes.
const resetStyle: CSSProperties = { font: "inherit", color: "inherit", background: "none", border: "1px solid", borderRadius: 4, padding: "0.125rem 0.625rem" };
export default function RowReorder() {
// The table does not reorder itself: the order lives here.
const [rows, setRows] = useState(plan);
const [change, setChange] = useState<RowOrderChange<Step> | null>(null);
return (
<>
<DataTable
data={rows}
columns={columns}
getRowId={(step) => step.id}
enableRowReorder
onRowOrderChange={(next) => {
// Store the new order in the same event; save it to a server after.
setRows(next.data);
setChange(next);
}}
enablePagination={false}
/>
<p>
<button
type="button"
style={resetStyle}
onClick={() => {
setRows(plan);
setChange(null);
}}
>
Reset
</button>{" "}
<span dir="ltr">
{change
? `Last move: { rowId: "${change.rowId}", targetId: "${change.targetId}", position: "${change.position}" }`
: "Drag a handle, or focus one and press Space."}
</span>
</p>
</>
);
}Drag a row by its handle, or focus a handle and press Space, an arrow key, then Space again. The line under the table shows the fields of the last move; Reset puts the rows back.
Storing the order
The order is yours: onRowOrderChange receives a RowOrderChange and the table shows the new order only once data changes. Store it in the same event, and save it to a server afterwards; a table that waits for the reply shows the row slide back, then jump.
rowandrowId: the row that was dragged;rowsandrowIds: every row that moved, in their new order (see several rows at once).targetandtargetId: the row it was dropped next to, always one of its siblings.position:"before"or"after"the target.data: the top-level rows in their new order.parentId,parentandsiblings: for a row in a tree, its parent and the parent’s children in their new order. For a top-level row,parentIdandparentarenullandsiblingsholds the same rows asdata.
Give rows a stable id with getRowId; rowId, targetId and the drag state use it. The library also exports moveItem(items, from, to, position), the same move on a plain list.
Tree tables
In a tree table a row moves among its siblings, and takes its expanded sub-rows with it; to move rows to other parents too, see moving across parents. For a sub-row, data is the unchanged top level: store siblings as the children of parent, as placeSiblings does here.
| Task | Owner | |
|---|---|---|
Design | Omar Haddad | |
Sketch the flows | Priya Nair | |
Review with support | Jonas Weber | |
Final mockups | Omar Haddad | |
Build | Lena Park | |
Launch | Hana Kim |
Open sub-rows move with their row.
import { useState, type CSSProperties } from "react";
import { DataTable, type DataTableColumnDef, type RowOrderChange } from "slotsmith/data-table";
type Task = { id: string; title: string; owner: string; parts?: Task[] };
const project: Task[] = [
{
id: "t1",
title: "Design",
owner: "Omar Haddad",
parts: [
{ id: "t1.1", title: "Sketch the flows", owner: "Priya Nair" },
{ id: "t1.2", title: "Review with support", owner: "Jonas Weber" },
{ id: "t1.3", title: "Final mockups", owner: "Omar Haddad" },
],
},
{
id: "t2",
title: "Build",
owner: "Lena Park",
parts: [
{ id: "t2.1", title: "Database changes", owner: "Diego Alvarez" },
{ id: "t2.2", title: "API endpoints", owner: "Lena Park" },
],
},
{ id: "t3", title: "Launch", owner: "Hana Kim" },
];
// Sorting is off: this table is ordered by hand.
const columns: DataTableColumnDef<Task>[] = [
{ accessorKey: "title", header: "Task", enableSorting: false },
{ accessorKey: "owner", header: "Owner", enableSorting: false },
];
/** Gives the moved row's parent its children in their new order; the rest of the tree is unchanged. */
function placeSiblings(tasks: Task[], change: RowOrderChange<Task>): Task[] {
return tasks.map((task) =>
task.id === change.parentId
? { ...task, parts: change.siblings }
: task.parts
? { ...task, parts: placeSiblings(task.parts, change) }
: task,
);
}
// The page's own text colour, so the button reads in light and dark themes.
const resetStyle: CSSProperties = { font: "inherit", color: "inherit", background: "none", border: "1px solid", borderRadius: 4, padding: "0.125rem 0.625rem" };
export default function RowReorderTree() {
const [rows, setRows] = useState(project);
const [change, setChange] = useState<RowOrderChange<Task> | null>(null);
return (
<>
<DataTable
data={rows}
columns={columns}
getRowId={(task) => task.id}
getSubRows={(task) => task.parts}
defaultExpanded={{ t1: true }}
enableRowReorder
onRowOrderChange={(next) => {
// A top-level row: `data` is the new order. A sub-row: `data` is
// unchanged, and `siblings` are its parent's new children.
setRows((current) => (next.parentId === null ? next.data : placeSiblings(current, next)));
setChange(next);
}}
enablePagination={false}
/>
<p>
<button
type="button"
style={resetStyle}
onClick={() => {
setRows(project);
setChange(null);
}}
>
Reset
</button>{" "}
<span dir="ltr">
{change
? `Last move: { rowId: "${change.rowId}", targetId: "${change.targetId}", parentId: ${change.parentId === null ? "null" : `"${change.parentId}"`} }`
: "Open sub-rows move with their row."}
</span>
</p>
</>
);
}Drag Design below Build: its three open sub-rows travel with it. Drag a sub-row of Design: it stays inside Design. A row with no visible sibling, such as the only open child of a parent, has its handle disabled.
Several rows at once
With row selection on, dragging a selected row drags every selected sibling with it. The selected rows close up under the dragged one as it is lifted, the other rows make room, and the group lands together, in its own order, at the gap it is dropped in; a scattered selection dropped where it started is put together there. rows and rowIds list the rows that moved, and data and siblings hold the new order as usual. Dragging a row that is not selected moves it alone. A group moves among the dragged row’s siblings, and only the selected rows that are shown move with it.
Between tables
Tables that share a reorderGroup exchange rows: drag a row out of one table and drop it in another. While the pointer is over the other table, a copy of the row follows it and a line marks where it would land. From the keyboard, a lifted row moves into the next table past its own table’s last row, and into the previous one before its first. A selected group moves together.
| Card | Owner | |
|---|---|---|
| Write the release notes | Lena Park | |
| Freeze the main branch | Yuki Tanaka | |
| Run the full test suite | Diego Alvarez |
| Card | Owner | |
|---|---|---|
| Tag the version | Yuki Tanaka |
Drag a card into the other table, or lift one with Space and press ↓ past the last row.
import { useState, type CSSProperties } from "react";
import { DataTable, type DataTableColumnDef, type RowTransfer } from "slotsmith/data-table";
type Card = { id: string; title: string; owner: string };
const backlog: Card[] = [
{ id: "c1", title: "Write the release notes", owner: "Lena Park" },
{ id: "c2", title: "Freeze the main branch", owner: "Yuki Tanaka" },
{ id: "c3", title: "Run the full test suite", owner: "Diego Alvarez" },
];
const doing: Card[] = [{ id: "c4", title: "Tag the version", owner: "Yuki Tanaka" }];
// Sorting is off: both tables are ordered by hand.
const columns: DataTableColumnDef<Card>[] = [
{ accessorKey: "title", header: "Card", enableSorting: false },
{ accessorKey: "owner", header: "Owner", enableSorting: false },
];
// The page's own text colour, so the button reads in light and dark themes.
const resetStyle: CSSProperties = { font: "inherit", color: "inherit", background: "none", border: "1px solid", borderRadius: 4, padding: "0.125rem 0.625rem" };
export default function RowReorderBetween() {
const [todo, setTodo] = useState(backlog);
const [inProgress, setInProgress] = useState(doing);
const [last, setLast] = useState<RowTransfer<Card> | null>(null);
return (
<>
<DataTable
aria-label="Backlog"
data={todo}
columns={columns}
getRowId={(card) => card.id}
enableRowReorder
// Tables with the same reorderGroup exchange rows.
reorderGroup="board"
reorderTableId="backlog"
onRowOrderChange={(change) => setTodo(change.data)}
// Called on both tables; `data` is always this table's new rows.
onRowTransfer={(transfer) => {
setTodo(transfer.data);
setLast(transfer);
}}
enablePagination={false}
/>
<DataTable
aria-label="In progress"
data={inProgress}
columns={columns}
getRowId={(card) => card.id}
enableRowReorder
reorderGroup="board"
reorderTableId="in-progress"
onRowOrderChange={(change) => setInProgress(change.data)}
onRowTransfer={(transfer) => setInProgress(transfer.data)}
enablePagination={false}
/>
<p>
<button
type="button"
style={resetStyle}
onClick={() => {
setTodo(backlog);
setInProgress(doing);
setLast(null);
}}
>
Reset
</button>{" "}
<span dir="ltr">
{last
? `Last transfer: ${last.rowIds.join(", ")} from "${last.from.tableId}" to "${last.to.tableId}" at ${last.to.index}`
: "Drag a card into the other table, or lift one with Space and press ↓ past the last row."}
</span>
</p>
</>
);
}Each table needs enableRowReorder, and reorderTableId names it in transfers. A drop calls onRowTransfer on both tables with the same RowTransfer: rows and rowIds moved, from is the table they left with its rows without them, to the table they joined with its rows with them, the index they landed at and the target they were dropped next to. data is always the receiving table’s own new rows, so each table stores transfer.data. The receiving table’s canDrop sees fromTableId and can refuse rows from other tables. A table’s only row can be dragged out, and an empty table takes rows. Only top-level rows move between tables.
Moving across parents
With reorderAcrossParents, a row of a tree can move to another parent and another depth. Drag it up or down to pick the gap between two rows, and sideways to pick the depth: one indent to the right makes it a child of the row above, one to the left takes it out a level. From the keyboard, the left and right arrow keys do the same while a row is lifted. The rows make room as before, and the lifted row is drawn at its new depth.
| Task | Owner | |
|---|---|---|
Design | Omar Haddad | |
Sketch the flows | Priya Nair | |
Review with support | Jonas Weber | |
Build | Lena Park | |
Database changes | Diego Alvarez | |
API endpoints | Lena Park | |
Launch | Hana Kim |
Drag a task sideways to move it in or out a level.
import { useState, type CSSProperties } from "react";
import { DataTable, type DataTableColumnDef, type RowOrderChange } from "slotsmith/data-table";
type Task = { id: string; title: string; owner: string; parts?: Task[] };
const project: Task[] = [
{
id: "t1",
title: "Design",
owner: "Omar Haddad",
parts: [
{ id: "t1.1", title: "Sketch the flows", owner: "Priya Nair" },
{ id: "t1.2", title: "Review with support", owner: "Jonas Weber" },
],
},
{
id: "t2",
title: "Build",
owner: "Lena Park",
parts: [
{ id: "t2.1", title: "Database changes", owner: "Diego Alvarez" },
{ id: "t2.2", title: "API endpoints", owner: "Lena Park" },
],
},
{ id: "t3", title: "Launch", owner: "Hana Kim" },
];
// Sorting is off: this table is ordered by hand.
const columns: DataTableColumnDef<Task>[] = [
{ accessorKey: "title", header: "Task", enableSorting: false },
{ accessorKey: "owner", header: "Owner", enableSorting: false },
];
// The page's own text colour, so the button reads in light and dark themes.
const resetStyle: CSSProperties = { font: "inherit", color: "inherit", background: "none", border: "1px solid", borderRadius: 4, padding: "0.125rem 0.625rem" };
export default function RowReorderAcross() {
const [rows, setRows] = useState(project);
const [change, setChange] = useState<RowOrderChange<Task> | null>(null);
return (
<>
<DataTable
data={rows}
columns={columns}
getRowId={(task) => task.id}
getSubRows={(task) => task.parts}
// How the table writes new children into a copy of a row, to build the new tree.
setSubRows={(task, parts) => ({ ...task, parts })}
defaultExpanded
enableRowReorder
reorderAcrossParents
// Launch stays a top-level task.
canDrop={(move) => move.rowId !== "t3" || move.parentId === null}
onRowOrderChange={(next) => {
// Across parents, `data` is the whole new tree.
setRows(next.data);
setChange(next);
}}
enablePagination={false}
/>
<p>
<button
type="button"
style={resetStyle}
onClick={() => {
setRows(project);
setChange(null);
}}
>
Reset
</button>{" "}
<span dir="ltr">
{change
? `Last move: { rowId: "${change.rowId}", fromParentId: ${JSON.stringify(change.fromParentId)}, parentId: ${JSON.stringify(change.parentId)}, index: ${change.index} }`
: "Drag a task sideways to move it in or out a level."}
</span>
</p>
</>
);
}Drag API endpoints under Design, or drag Sketch the flows out to the top level. Launch only moves among the top-level tasks: canDrop refuses every other place for it.
The table builds the new tree itself, so it needs setSubRows, which writes new children into a copy of a row; getSubRows reads them. A drop then reports the whole new tree as data: store it. fromParentId and fromParent say where the row came from, parentId, parent, index and siblings where it went, and position is "inside" when it became a child of the target (its first shown child, or its last when the target is collapsed). canDrop receives every place before it is offered (the row, the new parent and parentId, and the index), and the places it refuses are skipped. A row is never dropped inside its own sub-rows. Each step announces the level too, through the reorderLevel label. The library also exports moveInTree, the same move on your own nodes.
Keyboard
Each handle is a <button>, so it is in the tab order. With focus on a handle:
| Keys | Action |
|---|---|
| SpaceEnter | Lift the row. |
| ↑↓ | Move the lifted row up or down one place among its siblings (across parents: one gap). |
| ←→ | With reorderAcrossParents, move the lifted row one level out or in. |
| SpaceEnter | Drop the row in its new place. Focus stays on its handle. |
| Esc | Cancel. The row goes back where it was. |
| Tab | Cancel, and move focus on as usual. Leaving the handle any other way cancels too. |
While a row is lifted from the keyboard its handle has aria-pressed="true", and the rows scroll so the target stays in view.
Pointer and touch
The row follows the pointer and the rows it passes slide out of its way. Near the top or bottom edge of the scroll area, or of the window, the view scrolls. Letting go above, below or beside the row’s siblings cancels the drag. The handle sets touch-action: none, so a touch on it drags the row instead of scrolling the page, and a press on it never counts as a click on the row (onRowClick).
Announcements and labels
A visually hidden live region (role="status", aria-live="assertive") reads each step, with positions counted among the row’s siblings: “Row lifted. Position 1 of 6.”, “Position 2 of 6.”, “Row dropped at position 2 of 6.” or “Reordering cancelled.”. Every handle’s aria-describedby points at hidden instructions.
The six strings are labels, so a locale pack translates them and labels replaces any of them: reorderRow names each handle, reorderInstructions is the instructions, and reorderLifted, reorderMoved, reorderDropped and reorderCancelled are the announcements; the first three are functions of the position and the total.
Motion and styling
Rows slide with transforms while a row is dragged, and glide into place after a drop. When the reader prefers reduced motion nothing slides: the lifted rows are dimmed where they are, and a line marks where they will land.
Every body Row carries attributes a stylesheet can use:
data-row-id: the row’s id, always present.data-dragging: on the row being moved.data-dragging-child: on its visible sub-rows, which move with it.data-drop-position:"before"or"after", on the row it would be dropped next to.data-drop-edge:"before"or"after", on the row whose edge it would land against: the target for"before", and for"after"the last visible row of the target’s subtree. Draw a drop line here.
The fallback row already styles them. A design system’s own row needs a rule such as tr[data-dragging], tr[data-dragging-child] with an opaque background, so the rows underneath do not show through, and the handle has data-dragging too.
The drag handle
The handle is the DragHandle slot. A replacement must spread every prop it receives onto a real <button>: the pointer, key and blur handlers, the aria-* attributes, disabled and style.
reorderHandleColumnset tofalsedrops the leading handle column; place the handle in a cell of your own withDataTable.DragHandle, for example{ id: "move", header: "", cell: () => <DataTable.DragHandle /> }.slotProps.dragHandleadds props to every handle, per row. Its event handlers run after the table’s own, not instead of them, so a handler that tracks a press keeps the drag working.
Sorting and custom rows
A drop while a column is sorted means what it shows: the order on screen, with the row moved, becomes the new data, and the sort is cleared through onSortingChange, so the rows stay where they were dropped. With reorderWhileSorted set to "block", the handles are disabled instead until the sort is cleared. A table that is only ordered by hand can turn sorting off, as both demos do with enableSorting: false. With pagination, a row moves within its page.
A drag survives its rows being re-created: when a custom Row slot is defined during render, React makes every row anew on each render, and the drag finds its row and handle again (a keyboard drag keeps focus on the handle). It is still slow, and it resets any state inside the rows, so define the slot once, outside the component that renders the table; a development warning says so.
To reorder rows in markup of your own, the headless hook returns the same engine as reorder.