Appearance
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-premiumfeatures (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-kitfor 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-kitpackages) - 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
Accordioncomponents 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 optionalchildren[]) - 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, orPaper) - Parent expansion/collapse:
AccordionSummary+AccordionDetails - Inline edit:
TextField - Remove actions:
IconButtonwith delete icon
- Parent list:
- Use
@dnd-kit/core+@dnd-kit/sortablefor:- 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>
);
}