molecule

SortableList

A composable drag-and-drop system using @dnd-kit. Supports up to 3 levels of nesting, item and group drag handles, external drag sources (DragSource), and per-group callbacks for reorder, cross-group moves, and external drops.

Overview

The SortableList system provides composable drag-and-drop reordering for nested structures up to 3 levels deep. Groups can be reordered within the list, items can be reordered within a group, items can be moved between groups, and external DragSource elements can be dropped into groups to create new items.

Component Hierarchy

SortableList
  SortableGroup (level 1)
    SortableGroupHandle      — dedicated group drag handle
    SortableItem (level 2)
      SortableItemHandle     — dedicated item drag handle (optional)
      SortableGroup (level 3 — nested, depth=1)
        SortableItem (level 3 items)

DragSource                   — external drag element (side panel)
Step 1: Identity
Driver's License
Photo ID
Step 2: Licenses
Medical License
DEA Registration
Board Certification
Step 3: Training
HIPAA Training

Props

SortableList

PropTypeDefaultDescription
id string Required. Unique ID for this list
items string[] Required. Ordered array of group IDs
children ReactNode SortableGroup children
onReorder (items: string[]) => void Called with reordered group IDs when groups are reordered
renderDragOverlay (activeId: string, sourceData?: Record<string, unknown>) => ReactNode Render a custom drag overlay. Receives sourceData for DragSource drags.

SortableGroup

PropTypeDefaultDescription
id string Required. Unique ID for this group
index number Required. Position index within the parent list
items string[] Required. Ordered array of child item IDs
children ReactNode Group content: handles, items, and free consumer children (title rows, toggles, etc.)
depth number auto Current depth (0 = root, 1 = nested). Auto-detected if omitted. Throws in dev if > 1.
onReorderItems (items: string[]) => void Called with new item order when items within this group are reordered
onReceiveItem (itemId: string, fromGroupId: string, insertIndex: number) => void Called when an existing item arrives from another group
onReceiveExternal (sourceId: string, data: Record<string, unknown>, insertIndex: number) => void Called when a DragSource is dropped into this group

SortableItem

PropTypeDefaultDescription
id string Required. Unique identifier for this item
index number Required. Position index within the parent container
children ReactNode Content to render. May include a SortableItemHandle to isolate drag activation.

SortableGroupHandle / SortableItemHandle

PropTypeDefaultDescription
children ReactNode Required. Handle content — typically an Icon provided by the consumer

SortableGroupHandle must be inside a SortableGroup. SortableItemHandle must be inside a SortableItem. Both throw if used outside their parent context.

DragSource

PropTypeDefaultDescription
sourceId string Required. Type ID of the element (not the instance)
data Record<string, unknown> Required. Arbitrary data copied when creating the item at the destination
used boolean false If true, applies a "used" visual indicator (reduced opacity). Element remains draggable.
children ReactNode Required. Draggable content

Drag-and-Drop Behavior

DragOverlay

Always visible during drag. If the consumer passes renderDragOverlay, that function is used. Otherwise, the library generates a default overlay by cloning the active element's DOM node.

DragSource (side panel → main area)

  1. User drags a DragSource element from the side panel.
  2. DragOverlay shows a visual copy following the cursor.
  3. When entering a SortableGroup, insertion position is detected.
  4. On drop, onReceiveExternal(sourceId, data, insertIndex) fires on the target group.
  5. The consumer generates a new unique ID and adds the item to state.
  6. The original DragSource is NOT moved — it creates a copy.

Reorder within a group

Drag a SortableItem (from its handle if present, or the whole item). On drop over another item in the same group, onReorderItems(newOrder) fires.

Cross-container move

Drag a SortableItem outside its current group and into another. On drop, onReceiveItem(itemId, fromGroupId, insertIndex) fires on the target group. The consumer removes from source and adds to target in a single setState.

Handle isolation

  • SortableGroupHandle — only the handle activates group drag. The group wrapper has no drag listeners.
  • SortableItemHandle — only the handle activates item drag. If not used, the entire item is draggable.
  • Dragging an item MUST NEVER activate the drag of its parent group.

3-Level Nesting

A SortableItem may contain a nested SortableGroup to create a third level. Use depth={1} on the nested group. Maximum depth is 1 — exceeding it throws a dev-only error.

Patient Intake
Identity Verification
Photo ID
Driver's License
License Uploads
Medical License
DEA Registration
Insurance Info
Clinical Review
Lab Results
Certifications
Board Certification
HIPAA Certificate

Code

<SortableList id="root" items={groupIds}>
  <SortableGroup id="group-1" index={0} items={itemIds} depth={0}>
    <SortableGroupHandle><Icon name="grip-vertical" /></SortableGroupHandle>
    <SortableItem id="item-a" index={0}>
      <SortableItemHandle><Icon name="grip-vertical" /></SortableItemHandle>
      <span>Item A</span>

      {/* Level 3 — nested group inside an item */}
      <SortableGroup id="sub-group" index={0} items={subItemIds} depth={1}>
        <SortableItem id="sub-1" index={0}>
          <span>Sub Item 1</span>
        </SortableItem>
      </SortableGroup>
    </SortableItem>
  </SortableGroup>
</SortableList>

Examples

Basic Nested Usage with Handles

import {
  SortableList, SortableGroup, SortableGroupHandle,
  SortableItem, SortableItemHandle, arrayMove,
} from '@enara-health/ui-react';

<SortableList id="root" items={groupIds} onReorder={handleReorderGroups}>
  {groups.map((group, i) => (
    <SortableGroup
      key={group.id} id={group.id} index={i}
      items={group.itemIds}
      onReorderItems={handleReorderItems(group.id)}
      onReceiveItem={handleReceiveItem(group.id)}
      onReceiveExternal={handleReceiveExternal(group.id)}
    >
      <SortableGroupHandle>
        <Icon name="grip-vertical" />
      </SortableGroupHandle>
      <Text>{group.title}</Text>

      {group.itemIds.map((itemId, j) => (
        <SortableItem key={itemId} id={itemId} index={j}>
          <Card>
            <SortableItemHandle>
              <Icon name="grip-vertical" size="sm" />
            </SortableItemHandle>
            <Text>{itemId}</Text>
          </Card>
        </SortableItem>
      ))}
    </SortableGroup>
  ))}
</SortableList>

DragSource (external drop)

{/* Side panel — DragSource creates copies, not moves */}
<DragSource
  sourceId="photo-id"
  data={{ label: 'Photo ID', icon: 'id-card' }}
  used={usedIds.has('photo-id')}
>
  <Card>
    <Icon name="id-card" />
    <Text>Photo ID</Text>
  </Card>
</DragSource>

{/* Main area — group receives external drops */}
<SortableGroup
  id="section-1"
  items={itemIds}
  onReceiveExternal={(sourceId, data, insertIndex) => {
    const newId = generateId();
    addItem(newId, data, insertIndex);
  }}
>
  ...
</SortableGroup>

Anti-Patterns

  • Duplicate IDs across groups — IDs must be globally unique for @dnd-kit.
  • SortableGroupHandle outside SortableGroup — throws an error.
  • SortableItemHandle outside SortableItem — throws an error.
  • Two separate setStates for cross-container moves — causes items to disappear. Always use a single atomic setState in onReceiveItem.
  • Nesting deeper than 3 levels — a SortableGroup with depth > 1 throws a dev-only error.

Accessibility

  • Roles: List uses role="list", items use role="listitem" with aria-roledescription="sortable item". Handles have role="button" and tabIndex={0}.
  • Keyboard Support: Space to pick up, arrow keys to move, Space to drop, Escape to cancel.
  • Screen Reader: Announces drag instructions and position changes during drag operations.
  • Touch Support: Touch-friendly drag interactions with 8px activation constraint to prevent accidental drags.
  • Handle Isolation: SortableGroupHandle and SortableItemHandle isolate drag interaction from content, preventing accidental parent drags.

Best Practices

  • Ensure each item and group has a globally unique ID
  • Use handles when items contain interactive content (buttons, inputs)
  • Provide renderDragOverlay for custom drag previews
  • Use a single atomic setState for cross-container operations