Skip to content

Drag and Drop Item Handling for Nested Editable Items

  • Status: accepted
  • Deciders: Transformers Team
  • Date: 2026-02-24
  • Tags: drag-and-drop, mui, ux, forms, nested-data

Technical Story: Investigate and choose an implementation approach for drag-and-drop item handling where items are reorderable, item names are editable inline, items may optionally contain sortable child items, and both items and child items can be removed.

Context and Problem Statement

We need a UI pattern for managing a list of items that supports:

  • Reordering top-level items via drag and drop
  • Editing item names within each item row/card
  • Optionally rendering and sorting child items within an item
  • Expanding/collapsing parent item content via accordion behavior
  • Removing child items
  • Removing top-level items

The implementation should prioritize existing MUI capabilities and dependencies first, while still meeting all functional requirements with good accessibility and maintainability.

Decision Drivers

  • MUI-first approach: Prefer existing MUI tooling and patterns before introducing new libraries
  • Nested sorting support: Must support sortable child lists inside sortable parent items
  • Inline editing UX: Editing item names in-place should be straightforward and predictable
  • Accessibility: Keyboard and screen-reader behavior should be considered in the interaction model
  • Implementation risk: Avoid highly custom low-level DnD logic when a stable pattern exists
  • Long-term maintainability: Keep state shape and UI composition simple to evolve

Considered Options

  • Option 1: Use existing @mui/x-data-grid-premium features (row reordering/editing)
  • Option 2: Add @mui/x-tree-view (or @mui/x-tree-view-pro) for hierarchical interactions
  • Option 3: Compose MUI UI primitives with @dnd-kit for drag-and-drop behavior

Decision Outcome

Chosen option: Option 3: Compose MUI UI primitives with @dnd-kit, because it best satisfies all required behaviors (top-level reorder, optional child reorder, inline editing, accordion expand/collapse, and removal at both levels) while preserving a MUI-first UI layer.

This keeps MUI as the visual and interaction foundation (List, Card, TextField, IconButton, etc.) and introduces @dnd-kit only for sortable behavior where existing MUI packages are either too grid-centric or too constrained for nested editable item content.

Positive Consequences

  • Meets all required interactions without forcing data into a grid model
  • Supports nested sortable containers (parent items and child items)
  • Preserves fully custom item content (inline text fields, action buttons, metadata)
  • Keeps visual consistency with existing MUI design system components
  • Enables incremental rollout (parent reorder first, then child sorting)

Negative Consequences

  • Adds a new dependency (@dnd-kit packages)
  • Requires explicit keyboard/a11y handling decisions during implementation
  • Slightly higher initial implementation complexity than a simple list-only reorder

Pros and Cons of the Options

Option 1: @mui/x-data-grid-premium

  • Good, because it is already installed and familiar in the codebase
  • Good, because row-level editing and row reordering patterns exist for flat tabular data
  • Bad, because nested child sorting inside each item is not a natural fit
  • Bad, because card/list-style item content with rich inline controls is awkward in a grid
  • Bad, because removal/edit UX requirements are more form-like than table-like

Option 2: @mui/x-tree-view / @mui/x-tree-view-pro

  • Good, because hierarchical structures map naturally to parent/child items
  • Good, because it keeps interactions in the MUI ecosystem
  • Bad, because inline editable content per node can become constrained vs custom card/list layouts
  • Bad, because drag-and-drop ordering capabilities vary by package tier and version and would still require validation
  • Bad, because requirements are not strictly a navigation tree; they are editable form entities with actions

Option 3: MUI components + @dnd-kit

  • Good, because it supports nested sortable contexts and fine-grained behavior control
  • Good, because it allows fully custom item rendering with inline editable fields and remove actions
  • Good, because parent items can be rendered as MUI Accordion components while retaining drag-and-drop and edit/remove controls
  • Good, because MUI remains the primary UI system for structure, styling, and controls
  • Good, because it maps directly to domain data (items[] with optional children[])
  • Bad, because it introduces additional implementation surface area and dependency management

Implementation Notes (Planned)

  • Use MUI primitives for layout and controls:
    • Parent list: List/Stack + item container (Accordion, Card, or Paper)
    • Parent expansion/collapse: AccordionSummary + AccordionDetails
    • Inline edit: TextField
    • Remove actions: IconButton with delete icon
  • Use @dnd-kit/core + @dnd-kit/sortable for:
    • Parent item sorting
    • Child item sorting per parent item (optional render when children exist)
  • State model:
    • items: Array<{ id: string; name: string; children?: Array<{ id: string; name: string }> }>
  • Interaction rules:
    • Parent reorder affects only top-level ordering
    • Accordion expanded/collapsed state remains independent of reorder/edit state
    • Child reorder affects only siblings under the same parent in MVP
    • Remove child only from its parent
    • Remove parent removes the parent and its nested children

Functional Code Examples (Option 3)

1) Data shape and helpers

tsx
type ChildItem = { id: string; name: string };
type ParentItem = { id: string; name: string; children?: ChildItem[] };

const reorder = <T,>(list: T[], from: number, to: number): T[] => {
  const copy = [...list];
  const [moved] = copy.splice(from, 1);
  copy.splice(to, 0, moved);
  return copy;
};

2) Parent list with accordion + drag-and-drop

tsx
import { useMemo, useState } from 'react';
import {
  DndContext,
  PointerSensor,
  KeyboardSensor,
  closestCenter,
  useSensor,
  useSensors,
  type DragEndEvent,
} from '@dnd-kit/core';
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { List, Stack } from '@mui/material';

export function NestedItemEditor({ initialItems }: { initialItems: ParentItem[] }) {
  const [items, setItems] = useState<ParentItem[]>(initialItems);
  const [expandedById, setExpandedById] = useState<Record<string, boolean>>({});

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  const parentIds = useMemo(() => items.map(item => item.id), [items]);

  const onParentDragEnd = (event: DragEndEvent) => {
    const { active, over } = event;
    if (!over || active.id === over.id) return;

    setItems(prev => {
      const oldIndex = prev.findIndex(x => x.id === active.id);
      const newIndex = prev.findIndex(x => x.id === over.id);
      if (oldIndex < 0 || newIndex < 0) return prev;
      return arrayMove(prev, oldIndex, newIndex);
    });
  };

  return (
    <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onParentDragEnd}>
      <SortableContext items={parentIds} strategy={verticalListSortingStrategy}>
        <List disablePadding>
          <Stack gap={1}>
            {items.map(item => (
              <ParentSortableAccordion
                key={item.id}
                item={item}
                expanded={!!expandedById[item.id]}
                onToggle={() => setExpandedById(prev => ({ ...prev, [item.id]: !prev[item.id] }))}
                onRename={name =>
                  setItems(prev => prev.map(x => (x.id === item.id ? { ...x, name } : x)))
                }
                onRemove={() => setItems(prev => prev.filter(x => x.id !== item.id))}
                onChildrenChange={children =>
                  setItems(prev => prev.map(x => (x.id === item.id ? { ...x, children } : x)))
                }
              />
            ))}
          </Stack>
        </List>
      </SortableContext>
    </DndContext>
  );
}

3) Sortable parent accordion row with inline edit and remove

tsx
import {
  Accordion,
  AccordionDetails,
  AccordionSummary,
  IconButton,
  Stack,
  TextField,
  Typography,
} from '@mui/material';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import DeleteIcon from '@mui/icons-material/Delete';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

function ParentSortableAccordion(props: {
  item: ParentItem;
  expanded: boolean;
  onToggle: () => void;
  onRename: (name: string) => void;
  onRemove: () => void;
  onChildrenChange: (children: ChildItem[]) => void;
}) {
  const { item, expanded, onToggle, onRename, onRemove, onChildrenChange } = props;
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: item.id,
  });

  return (
    <Accordion
      ref={setNodeRef}
      expanded={expanded}
      onChange={onToggle}
      sx={{
        opacity: isDragging ? 0.6 : 1,
        transform: CSS.Transform.toString(transform),
        transition,
      }}
    >
      <AccordionSummary expandIcon={<ExpandMoreIcon />}>
        <Stack direction="row" gap={1} alignItems="center" width="100%">
          <IconButton {...attributes} {...listeners} size="small" aria-label="Drag parent item">
            <DragIndicatorIcon fontSize="small" />
          </IconButton>

          <TextField
            size="small"
            value={item.name}
            onChange={e => onRename(e.target.value)}
            onClick={e => e.stopPropagation()}
            onFocus={e => e.stopPropagation()}
            fullWidth
          />

          <IconButton
            size="small"
            color="error"
            aria-label="Remove parent item"
            onClick={e => {
              e.stopPropagation();
              onRemove();
            }}
          >
            <DeleteIcon fontSize="small" />
          </IconButton>
        </Stack>
      </AccordionSummary>

      <AccordionDetails>
        <Typography variant="body2" sx={{ mb: 1 }}>
          Children
        </Typography>
        <ChildSortableList
          parentId={item.id}
          childrenItems={item.children ?? []}
          onChange={onChildrenChange}
        />
      </AccordionDetails>
    </Accordion>
  );
}

4) Child list sorting (within same parent) + remove

tsx
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IconButton, Paper, Stack, TextField } from '@mui/material';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import DeleteIcon from '@mui/icons-material/Delete';

function ChildSortableList({
  parentId,
  childrenItems,
  onChange,
}: {
  parentId: string;
  childrenItems: ChildItem[];
  onChange: (children: ChildItem[]) => void;
}) {
  const onChildDragEnd = ({ active, over }: DragEndEvent) => {
    if (!over || active.id === over.id) return;
    const from = childrenItems.findIndex(x => x.id === active.id);
    const to = childrenItems.findIndex(x => x.id === over.id);
    if (from < 0 || to < 0) return;
    onChange(reorder(childrenItems, from, to));
  };

  return (
    <DndContext collisionDetection={closestCenter} onDragEnd={onChildDragEnd}>
      <SortableContext items={childrenItems.map(x => x.id)} strategy={verticalListSortingStrategy}>
        <Stack gap={1}>
          {childrenItems.map(child => (
            <ChildSortableRow
              key={child.id}
              parentId={parentId}
              child={child}
              onRename={name =>
                onChange(childrenItems.map(x => (x.id === child.id ? { ...x, name } : x)))
              }
              onRemove={() => onChange(childrenItems.filter(x => x.id !== child.id))}
            />
          ))}
        </Stack>
      </SortableContext>
    </DndContext>
  );
}

function ChildSortableRow({
  child,
  onRename,
  onRemove,
}: {
  parentId: string;
  child: ChildItem;
  onRename: (name: string) => void;
  onRemove: () => void;
}) {
  const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
    id: child.id,
  });

  return (
    <Paper
      ref={setNodeRef}
      variant="outlined"
      sx={{ p: 1, transform: CSS.Transform.toString(transform), transition }}
    >
      <Stack direction="row" alignItems="center" gap={1}>
        <IconButton {...attributes} {...listeners} size="small" aria-label="Drag child item">
          <DragIndicatorIcon fontSize="small" />
        </IconButton>
        <TextField
          size="small"
          value={child.name}
          onChange={e => onRename(e.target.value)}
          fullWidth
        />
        <IconButton size="small" color="error" aria-label="Remove child item" onClick={onRemove}>
          <DeleteIcon fontSize="small" />
        </IconButton>
      </Stack>
    </Paper>
  );
}