@nosferatu500/react-sortable-tree
v5.0.0
Published
Drag-and-drop sortable component for nested data and hierarchies
Maintainers
Readme
React Sortable Tree
Drag-and-drop sortable representation of hierarchical data for React 18/19 with virtualized rendering powered by virtua and react-dnd. Storybook demos cover both basic and advanced scenarios.
Getting started
Install the package together with its peer dependencies:
npm install @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backend
# or
yarn add @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backendThe bundle is ESM-only and includes all styles via runtime injection (no separate CSS file is required).
Quick start
import { useState } from 'react'
import { SortableTree, TreeItem } from '@nosferatu500/react-sortable-tree'
const initialData: TreeItem[] = [
{ title: 'Chicken', children: [{ title: 'Egg' }] },
{ title: 'Fish', children: [{ title: 'Fingerling' }] },
]
export function ExampleTree() {
const [treeData, setTreeData] = useState(initialData)
return (
<div style={{ height: 400 }}>
<SortableTree
treeData={treeData}
onChange={setTreeData}
/>
</div>
)
}Already have a surrounding react-dnd context? Use the context-less export instead:
import { SortableTreeWithoutDndContext } from '@nosferatu500/react-sortable-tree'Component props
All props are typed in ReactSortableTreeProps (see src/react-sortable-tree.tsx).
Required props
| Prop | Type | Description |
|------|------|-------------|
| treeData | TreeItem[] | Array of tree nodes with { title?, subtitle?, expanded?, children?, ...custom } |
| onChange | (treeData: TreeItem[]) => void | Called on every tree data change |
Appearance & layout
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| rowHeight | number \| ((treeIndex, node, path) => number) | 62 | Height of each row in pixels |
| rowDirection | 'ltr' \| 'rtl' | 'ltr' | Layout direction |
| scaffoldBlockPxWidth | number | 44 | Width of indent per level |
| slideRegionSize | number | 100 | Size of the drag slide region |
| style | CSSProperties | - | Styles for the outer container |
| innerStyle | CSSProperties | - | Styles for the virtual list |
| className | string | - | Class name for the outer container |
Theming & custom renderers
| Prop | Type | Description |
|------|------|-------------|
| theme | ThemeProps | Theme object (see Theming section) |
| nodeContentRenderer | ComponentType | Custom component for node content |
| treeNodeRenderer | ComponentType | Custom component for the entire tree row |
| placeholderRenderer | ComponentType | Custom component for empty tree state |
Drag & drop
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| canDrag | boolean \| ((params) => boolean) | true | Whether nodes can be dragged |
| canDrop | (params) => boolean | - | Validate if a drop is allowed |
| canNodeHaveChildren | (node) => boolean | () => true | Whether a node can have children |
| maxDepth | number | - | Maximum nesting depth |
| shouldCopyOnOutsideDrop | boolean \| ((params) => boolean) | false | Copy node when dropped outside |
| dndType | string | - | Custom drag type for multi-tree setups |
| onMoveNode | (params) => void | - | Called after a node is moved |
| onDragStateChanged | (params) => void | - | Called when drag state changes |
Search
| Prop | Type | Description |
|------|------|-------------|
| searchQuery | string | Search query string |
| searchMethod | (params) => boolean | Custom search matching function |
| searchFocusOffset | number | Index of the focused match |
| searchFinishCallback | (matches) => void | Called when search completes |
| onlyExpandSearchedNodes | boolean | Collapse non-matching paths |
Other
| Prop | Type | Description |
|------|------|-------------|
| generateNodeProps | (params) => object | Add custom props to each node |
| getNodeKey | (node) => string \| number | Generate stable node keys |
| onVisibilityToggle | (params) => void | Called when node expands/collapses |
| loadCollapsedLazyChildren | boolean | Load lazy children before expanding |
| virtuaRef | RefObject<VListHandle> | Direct access to the virtual list |
| dragDropManager | object | External react-dnd manager |
Theming
The component supports theming through CSS variables, the theme prop, and custom renderers.
CSS Variables
Override these CSS variables on the .rst__tree class or a parent element:
.my-custom-theme .rst__tree {
--rst-row-height: 62px;
--rst-block-width: 44px;
--rst-handle-width: 44px;
--rst-line-color: #000;
--rst-line-highlight: #36c2f6;
--rst-line-highlight-arrow: white;
--rst-primary-color: #36c2f6;
--rst-focus-color: #fc6421;
--rst-match-color: #0080ff;
--rst-bg-landing: lightblue;
--rst-bg-cancel: #e6a8ad;
--rst-text-color: #333;
--rst-icon-color: #6DB3F2;
--rst-button-bg: #fff;
--rst-button-border: #989898;
}Theme prop
The theme prop accepts an object with these properties:
type ThemeProps = {
style?: React.CSSProperties
innerStyle?: React.CSSProperties
scaffoldBlockPxWidth?: number
slideRegionSize?: number
treeNodeRenderer?: React.ComponentType
nodeContentRenderer?: React.ComponentType
placeholderRenderer?: React.ComponentType
dndType?: string
}Theme values are merged with component props, with direct props taking precedence.
Example: File Explorer Theme
The library includes a File Explorer theme example in the Storybook demos:
import { SortableTree } from '@nosferatu500/react-sortable-tree'
import { fileExplorerTheme, FILE_EXPLORER_THEME_CLASS } from './themes/file-explorer'
function FileTree() {
const [treeData, setTreeData] = useState([
{ title: 'src', isDirectory: true, expanded: true, children: [
{ title: 'index.ts' },
{ title: 'App.tsx' },
]},
{ title: 'package.json' },
])
return (
<div className={FILE_EXPLORER_THEME_CLASS}>
<SortableTree
treeData={treeData}
onChange={setTreeData}
theme={fileExplorerTheme}
rowHeight={28}
// Only folders can have children
canNodeHaveChildren={(node) => node.isDirectory === true}
// Only allow dropping into folders
canDrop={({ nextParent }) =>
!nextParent || nextParent.isDirectory === true
}
/>
</div>
)
}For dark mode, add the rst__file-explorer-dark class to the wrapper.
Creating custom themes
To create a custom theme:
- Create a custom
nodeContentRenderercomponent (seesrc/node-renderer-default.tsxfor reference) - Add CSS styles with your theme class
- Export a theme object:
export const myTheme = {
nodeContentRenderer: MyCustomNodeRenderer,
scaffoldBlockPxWidth: 24,
slideRegionSize: 50,
}Data helper functions
Utilities exported from the package:
Node manipulation
addNodeUnderParent({ treeData, newNode, parentKey, getNodeKey, expandParent?, addAsFirstChild? })- Add a node under a parentinsertNode({ treeData, newNode, depth, minimumTreeIndex, getNodeKey, expandParent? })- Insert a node at a specific positionremoveNode({ treeData, path, getNodeKey })- Remove a node by pathremoveNodeAtPath({ treeData, path, getNodeKey })- Remove a node at exact pathchangeNodeAtPath({ treeData, path, newNode, getNodeKey })- Update a node at path
Tree inspection
getNodeAtPath({ treeData, path, getNodeKey })- Get node at pathgetDescendantCount({ node })- Count all descendantsgetDepth(node)- Get nesting depth of a nodeisDescendant(older, younger)- Check parent-child relationshipgetVisibleNodeCount({ treeData })- Count visible (expanded) nodes
Tree traversal
walk({ treeData, getNodeKey, callback, ignoreCollapsed? })- Walk tree depth-firstmap({ treeData, getNodeKey, callback, ignoreCollapsed? })- Transform all nodestoggleExpandedForAll({ treeData, expanded })- Expand or collapse all nodesfind({ treeData, getNodeKey, searchQuery, searchMethod, expandAllMatchPaths? })- Search with path expansion
Data conversion
getFlatDataFromTree({ treeData, getNodeKey, ignoreCollapsed? })- Convert to flat arraygetTreeFromFlatData({ flatData, getKey, getParentKey, rootKey? })- Convert from flat array
Default handlers
defaultGetNodeKey({ treeIndex })- Default key generator (uses index)defaultSearchMethod({ node, searchQuery })- Default search (matches title)
License
MIT
