Pipeline
MovableCardGrid
A card grid the reader can rearrange.
Guidelines#
In arrange mode each card gets a dashed scrim to drag and a handle with arrow buttons that move it one cell at a time.
Use it for the prospect detail page and the dashboard. Pair it with an "Arrange cards" / "Done" toggle.
What you provide:
cards: { key, title, className?, node, actions? }[].classNamesets column spans;actionsadds controls to the handle after the arrows.arranging: boolean.onMove(key, targetKey, edge: "before" | "after"): the component doesn't reorder anything itself.className: the grid, for examplegrid gap-4 sm:grid-cols-2 xl:grid-cols-3.
Behaviour:
- It creates its own
DndContextwith aPointerSensorthat starts after 6px of movement. - Arrow moves use the rendered layout. Left and right stay within the row and do nothing when the card is alone in it. Up and down go to the nearest row. A card that spans a row jumps over it.
- There is no keyboard sensor: the arrow buttons are the accessible way to move cards.
Anatomy:
- In arrange mode cells are at least
min-h-28. - The scrim is
rounded-xl border-2 border-dashed border-primary/40 bg-background/60. - The handle is centred 8px from the top,
rounded-md border bg-background px-1.5 py-0.5 shadow-sm, with aGripVerticalicon, the title intext-xs font-medium, and 24px ghost icon buttons. - A drop target gets
ring-2 ring-primary.
Don't wrap it in a DndContext: it creates its own.
Facts#
| Group | Pipeline |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.MovableCardGrid |
| Source | max-agent: src/features/prospects/ui/components/movable-card-grid.tsx |
| Import in the app | import { MovableCardGrid } from "@/features/prospects/ui/components/movable-card-grid"; |
API#
TypeScript declarations emitted from src/features/prospects/ui/components/movable-card-grid.tsx.
export type MoveEdge = "before" | "after";
export type MoveDirection = "left" | "right" | "up" | "down";
export interface MovableGridCard {
key: string;
/** Shown on the arrange-mode handle so an empty card is still nameable. */
title: string;
/** Extra classes for the grid cell (column spans). */
className?: string;
node: ReactNode;
/** Extra per-card controls on the arrange-mode handle, after the arrows. */
actions?: ReactNode;
}
export declare function MovableCardGrid({ cards, arranging, onMove, className, }: {
cards: MovableGridCard[];
arranging: boolean;
onMove: (key: string, targetKey: string, edge: MoveEdge) => void;
className?: string;
}): JSX.Element;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
function tile(title, Icon, lines) {
return h(DC.Card, { className: "gap-2 py-4" },
h(DC.CardHeader, { className: "px-4" },
h(DC.CardTitle, { className: "flex items-center gap-2 text-sm" }, h(Icon, { className: "size-4 text-muted-foreground" }), title)),
h(DC.CardContent, { className: "space-y-1 px-4 text-sm" },
lines.map(function (l) {
return h("div", { key: l[0], className: "flex justify-between gap-3" },
h("span", { className: "text-muted-foreground" }, l[0]), h("span", { className: "truncate font-medium" }, l[1]));
})));
}
var INITIAL = [
{ key: "contact", title: "Contact", node: tile("Contact", I.User, [["Name", "Claire Dubois"], ["Role", "CFO"], ["Email", "claire.dubois@northwind.com"]]) },
{ key: "company", title: "Company", node: tile("Company", I.Building2, [["Name", "Northwind Traders"], ["Size", "480 employees"], ["HQ", "Lyon, France"]]) },
{ key: "engagement", title: "Engagement", node: tile("Engagement", I.TrendingUp, [["Emails opened", "4 of 5"], ["Replies", "1"], ["LinkedIn", "Connected"]]) },
{ key: "next", title: "Next step", node: tile("Next step", I.Calendar, [["Meeting", "Thu 1 Oct, 10:00"], ["Owner", "Sarah Lindqvist"], ["Stage", "Interested"]]) }
];
function move(cards, key, targetKey, edge) {
var moving = cards.filter(function (c) { return c.key === key; })[0];
var rest = cards.filter(function (c) { return c.key !== key; });
var at = rest.findIndex(function (c) { return c.key === targetKey; });
if (!moving || at === -1) return cards;
rest.splice(edge === "after" ? at + 1 : at, 0, moving);
return rest;
}
function App() {
var s = React.useState(INITIAL), cards = s[0], setCards = s[1];
var a = React.useState(true), arranging = a[0], setArranging = a[1];
return h("div", { className: "space-y-4" },
h("div", { className: "flex items-center justify-between" },
h("p", { className: "text-sm font-semibold" }, "Claire Dubois"),
h(DC.Button, { variant: arranging ? "default" : "outline", size: "sm", className: "gap-1.5", "aria-pressed": arranging,
onClick: function () { setArranging(!arranging); } },
h(I.LayoutDashboard, { className: "h-3.5 w-3.5" }), arranging ? "Done" : "Arrange cards")),
h(DC.MovableCardGrid, {
cards: cards, arranging: arranging, className: "grid gap-4 sm:grid-cols-2",
onMove: function (key, targetKey, edge) { setCards(function (prev) { return move(prev, key, targetKey, edge); }); }
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>