@nosferatu500/react-sortable-tree
Version:
Drag-and-drop sortable component for nested data and hierarchies
262 lines (200 loc) • 9.51 kB
Markdown
# React Sortable Tree


[](https://npmcharts.com/compare/@nosferatu500/react-sortable-tree?minimal=true)
[](https://npmcharts.com/compare/@nosferatu500/react-sortable-tree?minimal=true)
[](http://makeapullrequest.com)
Drag-and-drop sortable representation of hierarchical data for React 18/19 with virtualized rendering powered by [`virtua`](https://github.com/inokawa/virtua) and [`react-dnd`](https://react-dnd.github.io/react-dnd/). [Storybook demos](https://nosferatu500.github.io/react-sortable-tree/) cover both basic and advanced scenarios.
## Getting started
Install the package together with its peer dependencies:
```sh
npm install @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backend
# or
yarn add @nosferatu500/react-sortable-tree react-dnd react-dnd-html5-backend
```
The bundle is ESM-only and includes all styles via runtime injection (no separate CSS file is required).
## Quick start
```tsx
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:
```tsx
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](#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:
```css
.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:
```ts
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:
```tsx
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:
1. Create a custom `nodeContentRenderer` component (see `src/node-renderer-default.tsx` for reference)
2. Add CSS styles with your theme class
3. Export a theme object:
```ts
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 parent
- `insertNode({ treeData, newNode, depth, minimumTreeIndex, getNodeKey, expandParent? })` - Insert a node at a specific position
- `removeNode({ treeData, path, getNodeKey })` - Remove a node by path
- `removeNodeAtPath({ treeData, path, getNodeKey })` - Remove a node at exact path
- `changeNodeAtPath({ treeData, path, newNode, getNodeKey })` - Update a node at path
### Tree inspection
- `getNodeAtPath({ treeData, path, getNodeKey })` - Get node at path
- `getDescendantCount({ node })` - Count all descendants
- `getDepth(node)` - Get nesting depth of a node
- `isDescendant(older, younger)` - Check parent-child relationship
- `getVisibleNodeCount({ treeData })` - Count visible (expanded) nodes
### Tree traversal
- `walk({ treeData, getNodeKey, callback, ignoreCollapsed? })` - Walk tree depth-first
- `map({ treeData, getNodeKey, callback, ignoreCollapsed? })` - Transform all nodes
- `toggleExpandedForAll({ treeData, expanded })` - Expand or collapse all nodes
- `find({ treeData, getNodeKey, searchQuery, searchMethod, expandAllMatchPaths? })` - Search with path expansion
### Data conversion
- `getFlatDataFromTree({ treeData, getNodeKey, ignoreCollapsed? })` - Convert to flat array
- `getTreeFromFlatData({ 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