v1.0

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

PropTypeDefaultDescription
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—
disabledboolean—
classNamestring—