SortableList
Preview
Basic
Loading…
Preview
Code
ts
import SortableList from '@/components/table/SortableList';src/components/table/SortableList.tsx
AI prompt
text
Build a drag-to-reorder list component in React + TypeScript on the browser's native HTML5 drag-and-drop (no drag library), generic over the item type, where only an explicit grip starts a drag.
## Look
- Unstyled `ul role="list"` (`className` goes on it); each item is an `li` wrapping whatever `renderItem` returns. The row being dragged gets `opacity-40`.
- The caller draws the row and its grip. Typical row: an opaque card `mb-1.5 flex items-center gap-2 px-3 py-2 text-xs` with a "⠿" (or GripVertical) grip in slate-400, `cursor-grab select-none`.
## Behaviour
- Grip opt-in: `renderItem` receives `handleProps` (`onPointerDown`, `onPointerUp`) to spread on the grip. An `li` is `draggable` only while the pointer is down on its own grip — rows often contain inputs, and a fully draggable row swallows text selection.
- Live reordering: on `dragenter` over another row, move the dragged item to that row's index and call `onReorder(next)`. What you see mid-drag is what gets committed, so `onReorder` fires many times per drag — keep it a setState and persist from a separate Save.
- Cancel restores: remember the order at `dragstart`; `drop` marks the drag as landed; on `dragend` (which always fires), if nothing dropped (Escape, released outside a row) call `onReorder` with the original order.
- Groups: several lists can be on screen. Keep the in-flight drag `{ group, id }` in module scope (only one drag exists per document, and `dataTransfer` is unreadable during dragover). A list only accepts rows with its own `group`: `dragover` calls `preventDefault()` and sets `dropEffect = 'move'` for same-group drags only; cross-group drags are ignored rather than reparenting the row.
- `dragstart` must call `dataTransfer.setData('text/plain', id)` (Firefox won't start a drag otherwise) and set `effectAllowed = 'move'`.
- `disabled` stops any row becoming draggable.
## API
`items: T[]`, `getId(item): string | number`, `onReorder(next: T[])`, `renderItem(item, { isDragging, handleProps })`, `group: string`, `disabled` (false), `className`.
## Demo
Five teams — Engineering, Research, Design, Support, Operations — as small cards with a ⠿ grip, reordered by dragging the grip, in group "teams".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
/* Origin: bonus-adjustment (96S2), verbatim. */
import { useCallback, useRef, useState, type ReactNode } from 'react';
/**
* Drag-to-reorder list on the browser's own HTML5 drag events — no drag library.
*
* Rows reorder LIVE as you drag over them, so what you see mid-drag is what gets
* committed. `onReorder` therefore fires repeatedly during a drag: keep it to a
* setState and persist from a separate Save step.
*
* Several lists can be on screen at once (one per group in the menu tree). A row
* only drops into the list sharing its `group` key — dragging across groups is
* ignored rather than silently reparenting the row.
*/
/**
* Which row is in flight. Module scope is right: a document can only have one
* drag at a time, and it lets a list reject a foreign row during `dragover`,
* where the DataTransfer payload is deliberately unreadable in most browsers.
*/
let activeDrag: { group: string; id: string } | null = null;
export default function SortableList<T>({
items,
getId,
onReorder,
renderItem,
group,
disabled = false,
className,
}: {
items: T[];
getId: (item: T) => string | number;
onReorder: (next: T[]) => void;
/**
* `handleProps` must be spread onto whatever should start a drag. Rows here
* contain inputs, so making the whole row draggable would swallow text
* selection — the grip handle opts in explicitly.
*/
renderItem: (
item: T,
state: {
isDragging: boolean;
handleProps: { onPointerDown: () => void; onPointerUp: () => void };
},
) => ReactNode;
group: string;
disabled?: boolean;
className?: string;
}) {
const [draggingId, setDraggingId] = useState<string | null>(null);
// A row is `draggable` only once the pointer goes down on its handle.
const [armedId, setArmedId] = useState<string | null>(null);
// The order when the drag began, so a cancelled drag (Escape, or a release
// outside any row) can be restored — otherwise the live reordering leaves the
// row wherever it happened to be hovering.
const orderBefore = useRef<T[] | null>(null);
const didDrop = useRef(false);
const onDragStart = useCallback(
(id: string) => {
activeDrag = { group, id };
orderBefore.current = items;
didDrop.current = false;
setDraggingId(id);
},
[group, items],
);
const onDragEnter = useCallback(
(overId: string) => {
if (!activeDrag || activeDrag.group !== group) return;
if (activeDrag.id === overId) return;
const from = items.findIndex((i) => String(getId(i)) === activeDrag!.id);
const to = items.findIndex((i) => String(getId(i)) === overId);
if (from === -1 || to === -1) return;
const next = [...items];
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
onReorder(next);
},
[group, items, getId, onReorder],
);
const onDragEnd = useCallback(() => {
// `dragend` always fires, drop or not — so this is where a cancelled drag
// gets undone.
if (!didDrop.current && orderBefore.current) onReorder(orderBefore.current);
activeDrag = null;
orderBefore.current = null;
setDraggingId(null);
setArmedId(null);
}, [onReorder]);
return (
<ul className={className} role="list">
{items.map((item) => {
const id = String(getId(item));
const isDragging = draggingId === id;
return (
<li
key={id}
draggable={!disabled && armedId === id}
onDragStart={(e) => {
// Firefox refuses to start a drag unless something is set here.
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
onDragStart(id);
}}
onDragEnter={() => onDragEnter(id)}
onDragOver={(e) => {
// Without preventDefault the browser treats this as a non-drop
// target and shows the "no entry" cursor.
if (activeDrag?.group === group) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
}}
onDrop={(e) => {
if (activeDrag?.group !== group) return;
e.preventDefault();
// The list is already in its final order — flag it so dragend
// doesn't revert.
didDrop.current = true;
}}
onDragEnd={onDragEnd}
className={isDragging ? 'opacity-40' : undefined}
>
{renderItem(item, {
isDragging,
handleProps: {
onPointerDown: () => {
if (!disabled) setArmedId(id);
},
onPointerUp: () => setArmedId(null),
},
})}
</li>
);
})}
</ul>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | T[] | — | |
getId* | (item: T) => string | number | — | |
onReorder* | (next: T[]) => void | — | |
renderItem* | ( item: T, state: { isDragging: boolean; handleProps: { onPointerDown: () => void; onPointerUp: () => void }; }, ) => ReactNode | — | `handleProps` must be spread onto whatever should start a drag. Rows here contain inputs, so making the whole row draggable would swallow text selection — the grip handle opts in explicitly. |
group* | string | — | |
disabled | boolean | — | |
className | string | — |