Column reordering
Columns and whole groups a person moves by dragging their header cell or with the keys, among their siblings, the order kept by the grid or by you.
The grid keeps the order, drags the header cell, takes the keys, holds every move to the rules and tells where a drop would land. The look is yours: the drop indicator, the dragged cell, the cursor, and any words a screen reader hears. The grid draws and says nothing.
Reorderable columns
A column or a group with reorderable: true can be moved; the others stay where they are
declared. A group moves whole, and its columns move inside it only by their own flag.
const columns: ColumnOrGroup<Person>[] = [
{ key: "id", name: "#", width: 64 }, // fixed: never dragged
{ key: "name", name: "Name", width: 180, reorderable: true },
{
key: "contact",
name: "Contact",
reorderable: true, // the group moves whole
children: [
{ key: "email", name: "Email", width: 260, reorderable: true },
{ key: "city", name: "City", width: 140, reorderable: true },
],
},
];The order
The grid keeps columnOrder: keys of columns and groups, the order siblings take. Each list of
siblings (a group's children, or the top level) is ordered on its own: the entries the order lists
take the places of the listed ones, in its order, and the others keep their place. An empty order
is the order of columns. A key that is no column or group is kept, so a column that comes back
takes its place again. Like the widths, it is controlled or not:
columnOrderwithonColumnOrderChange: a move is asked for, and only the prop applies it;defaultColumnOrder: the grid starts from it, applies moves and tellsonColumnOrderChange.
const [order, setOrder] = useState<ColumnOrder>([]);
<button onClick={() => setOrder([])}>Reset order</button>
<DataGrid.Root
columns={columns}
rows={people}
columnOrder={order}
onColumnOrderChange={setOrder}
>
{/* … */}
</DataGrid.Root>columns stays as you declared it. The grid lays its columns, its header, its windows and its
aria-colindex out in the order, and what is keyed already follows the columns: the widths, the
sort, the selection and the expanded rows. The active cell follows its column (a header cell
included), so a move never changes the cell a person is on. Controlled, an active position the
order moved stays where its column went, and onActivePositionChange tells it once. Saving the
order (to storage, to a server) is yours: it is a plain array.
The rules
- Among siblings only. A column moves among the columns of its group, a group among the entries beside it. Moving a column into another group is not a move.
- Groups whole. A group's header cell moves the group and every column under it.
- Pinned with pinned. A pinned column lands among the ones pinned where it is (at the start, or at the end), the others among the others: pinned columns always lead and trail, whatever the order says. What counts is where it lands: a pinned column before the first column that scrolls becomes the last pinned one, a column after the last pinned one becomes the first that scrolls, and the same at the end.
- Fixed entries. An entry without
reorderableis never dragged nor moved by the keys, but its siblings may land on either side of it.
A move outside the rules is refused, and a middleware can refuse or rewrite any move. To keep the
fixed # first, for instance:
import { veto } from "@fragiola/data-grid";
model.use((ctx, next) =>
ctx.command === "column-order.move" &&
ctx.payload.targetKey === "id" &&
ctx.payload.side === "before"
? veto("# stays first")
: next(),
);Dragging
A press with the primary button on a reorderable header cell reaches the grid after your own
onPointerDown (so preventDefault there keeps it from dragging). The grid does not prevent it:
until the pointer moves past a click's few pixels, it is a click, which focuses the cell and
sorts a sortable column. Past them, the cell drags, holding the pointer.
A press on a control inside the cell, or on a resize handle,
never drags, and the click that ends a drag never sorts.
While the cell drags, at most once a frame, the grid works out where it would land: beside the sibling under the pointer, before or after it by the side of its middle. It reads the column positions, not the elements, so a sibling scrolled out of the rendered columns counts, under scroll scaling too. Near either edge of the columns that scroll (between the pinned parts), the grid scrolls them, faster nearer the edge, so a far column comes into reach; it stops once the siblings the column moves among all show on that side, so a drag inside a small group never scrolls the grid away. A pinned column's drag stays over its pinned part and scrolls nothing. Right to left, the sides mirror: "before" is to the right. A scroll during the drag (the wheel, the scrollbar) works the target out again from where the pointer is, so the release lands where the indicator shows.
The columns do not move during the drag: only the state changes. The release runs one
column-order.move, one question to a controlled parent. Escape, a cancelled pointer or a lost
capture end the drag and move nothing. Escape reaches the grid after your handlers, wherever focus
is: one you prevent keeps the drag going.
On a touch screen, a drag on a header cell pans the grid unless the cell has
touch-action: none, which is yours to set (and then the header no longer pans).
The indicator
The grid tells where a drop would land and leaves the drawing to you:
- the dragged header cell carries
data-dragging; - the sibling it would land beside carries
data-drop-target,beforeorafter; - a header cell that can be moved carries
data-reorderable.
While a release would leave the entry where it is, no cell is a drop target. A line inside the target, on its side, reads as a slot; the dragged cell dimmed reads as the one in hand. The cursor is yours too.
[data-grid-part="header-cell"][data-reorderable] {
cursor: grab;
user-select: none;
}
[data-grid-part="header-cell"][data-dragging] {
cursor: grabbing;
opacity: 0.5;
}
[data-drop-target="before"] {
box-shadow: inset 3px 0 0 var(--accent);
}
[data-drop-target="after"] {
box-shadow: inset -3px 0 0 var(--accent);
}For a guide line or a ghost of your own, engine.get("column-reorder") is
{ columnKey, targetKey, side } during a drag (targetKey and side both null while a release
would move nothing), else null, and the engine's column-reorder event tells each change.
useHeaderCell reports reorderable, dragging and dropTarget, as does the state a header
cell's className and style functions receive.
Keys
On a reorderable header cell in navigation, Ctrl+Shift+← and Ctrl+Shift+→ (⌘ on a Mac) move its
column or group before the previous sibling or after the next one. The active cell follows it, and
focus stays on it. At an end, or next to the pinned columns, nothing moves, and the page does not
get the keys either. They run after your handlers, so preventDefault cancels them, and each is
one column-order.move, which a middleware can refuse. On a body cell, or a header cell that
does not move, they move the active cell as the arrows alone do. See
Keyboard and accessibility.
Announcements
The grid announces nothing: what a screen reader hears after a move is your text, in a live region
of yours. Tell it from what the grid shows, not by working the order out again: the model's header
before and after the change (state.header.rows, the pinned columns first, as on screen) tells
which column moved and where, in your columns' names.
const [order, setOrder] = useState<ColumnOrder>([]);
const [message, setMessage] = useState("");
const gridRef = useDataGridRef<Person>();
const moving = useRef(false);
useEffect(
() =>
gridRef.current?.model.subscribe(({ before, after }) => {
if (!moving.current || after.header === before.header) return;
moving.current = false;
// "Moved Email after City.", from the header's rows and the columns' names
setMessage(describeMove(before.header.rows, after.header.rows) ?? "");
}),
[gridRef],
);
<span role="status">{message}</span>
<DataGrid.Root
columns={columns}
rows={people}
gridRef={gridRef}
columnOrder={order}
onColumnOrderChange={(next) => {
moving.current = true;
setOrder(next);
}}
>
{/* … */}
</DataGrid.Root>The example writes describeMove in a few lines (announce.ts),
and follows the root its ref holds (gridRef.subscribe).
role="status" is a polite live region: it speaks once the person is done.
The commands
| command | does |
|---|---|
column-order.set { columnOrder } | replaces the order (keys, each once) |
column-order.move { columnKey, targetKey, side } | moves a reorderable column or group before or after one of its siblings, within the rules; one landing where it is commits nothing |
column-order.reset | gives every column and group its declared place back |
model.run("column-order.move", { columnKey: "email", targetKey: "city", side: "after" });A move naming a key that is no column or group fails (not_found); one that is not reorderable,
not a sibling, or landing across the pinned columns' edge is refused (refused). Reads:
get("column-order"), get("columns") (the columns in their order) and get("column-entries")
(as declared). Reach them with
useDataGrid() or a gridRef.
What it shows
| on | attribute | when |
|---|---|---|
| header cell | data-reorderable | its column or group can be moved |
| header cell | data-dragging | a drag is moving it |
| header cell | data-drop-target | before or after: a drop would land on that side of it |
Moving a column into another group, pinning by dragging, columns moving live during the drag and reordering rows are not part of it.
Column resizing
Columns a person resizes with a handle you render, dragged or moved with the arrows, within limits you set, the widths kept by the grid or by you.
Automatic widths
Flex columns that share the width the grid has left and follow it, and columns fitted to their content at the start, on a double click or from a button.