Sortable Tree Component
Sortable Tree (drag vertically to reorder, horizontally to indent / outdent)
Drag a row up or down to reorder among siblings, and left or right to change its depth : the pointer's horizontal position projects the target parent. Dragging a folder moves its whole subtree (collapsed during the drag), so a node can never be dropped into its own children.
<SortableTree defaultItems={ [ { id , label , children : [...] } ] } renderNode={ node => <SortableTreeItem>{ node.label }</SortableTreeItem> }/>Controlled Tree (live structure)
[
{
"src": [
{
"components": [
"Button.jsx",
"Input.jsx"
]
},
{
"hooks": [
"useValue.js"
]
},
"index.js"
]
},
{
"public": [
"logo.svg"
]
},
"README.md"
]<SortableTree items={ tree } onChange={ ( next , change ) => setTree( next ) }/>// change = { item , fromParent , toParent , fromIndex , toIndex }Async Change with API Call (optimistic revert on failure)
<SortableTree defaultItems={ tree } onChange={ ( next , change ) => api.save( next ) }/>// onChange may return a promise : a rejection restores the previous treeMax Depth (maxDepth = 2) + drop indicator
Nesting is capped at 2 levels : dragging a row deeper snaps back to the limit. The blue line is the drop indicator — it shows the exact insertion point and, by its indentation, the depth the node will land at. A folder counts its own subtree against the limit.
<SortableTree maxDepth={ 2 } defaultItems={ tree } renderNode={ ... } />Flat List (maxDepth = 0 — a single root level, no nesting)
With maxDepth={ 0 } every node stays at depth 0 : dragging a row sideways never indents it, so the tree behaves as a plain sortable list — only the vertical order changes. collapsible={ false } is paired with it since there is no subtree left to fold.
<SortableTree maxDepth={ 0 } collapsible={ false } defaultItems={ [ { id , label } , ... ] } renderNode={ node => <SortableTreeItem>{ node.label }</SortableTreeItem> }/>Nesting Rule (canNest — only folders accept children)
Files can be reordered but never receive children : dragging a node under a file walks the drop up to the file's own level (its parent folder). The drop indicator turns red when no valid parent exists at a position.
<SortableTree canNest={ ( item , parent ) => !parent || parent.type === 'folder' } defaultItems={ tree } renderNode={ ... }/>Expand All / Collapse All (controlled collapse)
The collapsed state is controlled via collapsed + onCollapsedChange, so the parent can drive it — here two buttons set it to every folder id (collapse all) or to an empty list (expand all).
<SortableTree collapsed={ collapsedIds } onCollapsedChange={ setCollapsedIds } defaultItems={ tree } renderNode={ ... }/>Frozen Tree (disabled + collapsible = false)
disabled stops all dragging and collapsible={ false } removes the chevrons and keeps every node expanded — a read-only, fully static tree.
<SortableTree disabled collapsible={ false } defaultItems={ tree } renderNode={ ... } />Add / Remove Nodes Dynamically (insert into a subfolder)
The tree is a controlled nested value, so add / remove are plain state updates. Each folder has a « + » button that inserts a child into that folder's children (targeted by its id) — the folder auto-expands to reveal it ; every node has a « × » to delete it (with its subtree). Reordering by drag still works alongside.
import insertNode from 'oihana-next-ui/helpers/trees/insertNode' ;import removeNode from 'oihana-next-ui/helpers/trees/removeNode' ;// add into a subfolder (by id) — or null for the top levelsetTree( t => insertNode( t , folderId , newNode ) ) ;// remove a node and its subtreesetTree( t => removeNode( t , nodeId ) ) ;Props Reference
| Prop | Type | Description |
|---|---|---|
canNest | function | (draggedItem, parentItem | null) => boolean — null = top level ; a rejected parent makes the drop walk up to the nearest valid ancestor (red indicator if none) |
collapsed | Array | Controlled list of collapsed node ids ; pair with onCollapsedChange |
collapsible | boolean | Allow expanding/collapsing (default true) ; false hides the chevrons and keeps every node expanded |
defaultCollapsed | Array | Ids of nodes collapsed initially (uncontrolled collapse) |
defaultItems | Array | Uncontrolled initial tree (the component owns and updates it) |
disabled | boolean | Disable dragging for every node (default false) ; the tree can still be expanded/collapsed |
getItemId | function | (item) => string | number — unique id accessor (defaults to item.id) |
handle | boolean | Show a drag handle on each row (default true) ; false makes the whole row draggable |
indent | number | Indentation width per depth level, in pixels (default 24) |
items | Array | Controlled nested tree [ { id , children : [...] } ] ; pair with onChange |
maxDepth | number | Maximum nesting depth ; a dragged folder counts its own subtree height against it |
onChange | function | (tree, { item, fromParent, toParent, fromIndex, toIndex }) => void | Promise — a rejected promise reverts the tree (uncontrolled) |
onCollapsedChange | function | (collapsedIds) => void — called with the new collapsed ids on each toggle |
renderNode | function | (item, { depth, collapsed, childCount }) => a SortableTreeItem element (required) |