Building a Folder Navigation Sidebar with React MUI TreeView
As a front-end developer, creating intuitive navigation systems is a critical part of many applications. File explorers, document management systems, and category browsers all rely on hierarchical navigation that's both functional and user-friendly. MUI's TreeView component offers a powerful solution for implementing these interfaces in React applications.
In this guide, I'll walk you through building a complete folder navigation sidebar using MUI's TreeView component. We'll start with the basics and progressively enhance our implementation with advanced features like drag-and-drop, context menus, and optimized rendering for large datasets.
What You'll Learn
By the end of this article, you'll be able to:
- Implement a fully functional folder navigation sidebar using MUI TreeView
- Customize the appearance and behavior of TreeView nodes
- Handle complex user interactions like expanding/collapsing folders
- Implement drag-and-drop functionality for reorganizing items
- Optimize performance for large folder structures
- Integrate the TreeView with other parts of your application
Understanding MUI TreeView
The TreeView component is part of MUI's lab components, which means it's still under active development but ready for production use. It provides a way to display hierarchical data in a tree structure, making it perfect for folder navigation.
Installation and Basic Setup
To get started, you'll need to install the TreeView component and its dependencies:
npm install @mui/lab @mui/material @mui/icons-material
The TreeView component is the container for your tree structure, while TreeItem components represent individual nodes in the tree. Let's look at the basic structure:
import React from 'react';
import TreeView from '@mui/lab/TreeView';
import TreeItem from '@mui/lab/TreeItem';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
function FolderNavigation() {
return (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
>
<TreeItem nodeId="1" label="Documents">
<TreeItem nodeId="2" label="Work Files" />
<TreeItem nodeId="3" label="Personal">
<TreeItem nodeId="4" label="Vacation Photos" />
<TreeItem nodeId="5" label="Budget.xlsx" />
</TreeItem>
</TreeItem>
<TreeItem nodeId="6" label="Downloads" />
</TreeView>
);
}
export default FolderNavigation;
This basic example demonstrates a simple folder structure with nested items. The nodeId
prop is a unique identifier for each node, and the label
prop defines what text is displayed for the node.
Core TreeView Props
The TreeView component comes with several important props that control its behavior:
Prop | Type | Default | Description |
---|---|---|---|
defaultCollapseIcon | node | undefined | The icon used to collapse nodes |
defaultEndIcon | node | undefined | The icon displayed next to leaf nodes (nodes without children) |
defaultExpandIcon | node | undefined | The icon used to expand nodes |
defaultExpanded | array | [] | Array of node IDs that should be expanded by default |
defaultSelected | string | null | The ID of the node that should be selected by default |
expanded | array | undefined | Array of node IDs that are currently expanded (controlled mode) |
multiSelect | bool | false | Whether multiple nodes can be selected |
onNodeSelect | func | undefined | Callback fired when a node is selected |
onNodeToggle | func | undefined | Callback fired when a node is expanded or collapsed |
selected | string | array | undefined | The ID(s) of the currently selected node(s) (controlled mode) |
TreeItem Props
The TreeItem component also has several important props:
Prop | Type | Default | Description |
---|---|---|---|
nodeId | string | required | Unique identifier for the node |
label | node | required | Content of the tree item |
icon | node | undefined | Icon element displayed before the label |
expandIcon | node | undefined | Icon displayed when the node can be expanded |
collapseIcon | node | undefined | Icon displayed when the node can be collapsed |
endIcon | node | undefined | Icon displayed next to a leaf node |
disabled | bool | false | If true, the node will be disabled |
Building a Folder Navigation Sidebar Step by Step
Now that we understand the basics, let's build a complete folder navigation sidebar with MUI TreeView. We'll take a progressive approach, starting with a simple implementation and adding features as we go.
Step 1: Set Up the Project Structure
First, let's create the basic structure for our folder navigation component:
// src/components/FolderNavigation.js
import React, { useState } from 'react';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TreeView from '@mui/lab/TreeView';
import TreeItem from '@mui/lab/TreeItem';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
// We'll define our component here
export default FolderNavigation;
This imports all the necessary components and icons we'll need. We're using:
- TreeView and TreeItem from MUI Lab for the tree structure
- Box and Typography from MUI for layout and text
- Various icons for folders and files
- The styled API for custom styling
Step 2: Define Sample Data Structure
Before implementing the component, let's define a sample data structure for our folders and files:
// Sample folder structure data
const folderData = [
{
id: 'root',
name: 'Project Files',
type: 'folder',
children: [
{
id: 'docs',
name: 'Documents',
type: 'folder',
children: [
{
id: 'work',
name: 'Work Files',
type: 'folder',
children: [
{ id: 'report', name: 'Annual Report.pdf', type: 'file' },
{ id: 'presentation', name: 'Quarterly Update.pptx', type: 'file' }
]
},
{
id: 'personal',
name: 'Personal',
type: 'folder',
children: [
{ id: 'budget', name: 'Budget.xlsx', type: 'file' },
{ id: 'vacation', name: 'Vacation Photos', type: 'folder', children: [] }
]
}
]
},
{
id: 'downloads',
name: 'Downloads',
type: 'folder',
children: [
{ id: 'software', name: 'Software.zip', type: 'file' },
{ id: 'image', name: 'Image.jpg', type: 'file' }
]
},
{ id: 'notes', name: 'Notes.txt', type: 'file' }
]
}
];
This data structure represents a typical file system with folders and files. Each item has:
- A unique
id
- A display
name
- A
type
(either 'folder' or 'file') - Optional
children
for folders
Step 3: Create Custom Styled TreeItems
Let's enhance the visual appearance of our TreeView by creating styled versions of the TreeItem component:
// Custom styled TreeItem
const StyledTreeItem = styled(TreeItem)(({ theme }) => ({
'& .MuiTreeItem-content': {
padding: theme.spacing(0.5, 1),
borderRadius: theme.shape.borderRadius,
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
'&.Mui-selected': {
backgroundColor: theme.palette.action.selected,
'&:hover': {
backgroundColor: theme.palette.action.selected,
},
},
},
'& .MuiTreeItem-group': {
marginLeft: theme.spacing(2),
paddingLeft: theme.spacing(1),
borderLeft: `1px dashed ${theme.palette.divider}`,
},
}));
// Custom TreeItem with icons based on item type
const FileTreeItem = (props) => {
const { nodeId, type, label, ...other } = props;
const getIcon = () => {
if (type === 'file') {
return <InsertDriveFileIcon color="action" fontSize="small" />;
}
return null; // Folder icons will be handled by TreeView's default icons
};
return (
<StyledTreeItem
nodeId={nodeId}
label={
<Box sx={{ display: 'flex', alignItems: 'center', py: 0.5 }}>
{getIcon()}
<Typography variant="body2" sx={{ ml: 1 }}>
{label}
</Typography>
</Box>
}
{...other}
/>
);
};
Here, we've created:
- A
StyledTreeItem
that enhances the default TreeItem with hover effects, better spacing, and a dashed line connecting parent and child nodes - A
FileTreeItem
component that displays different icons based on whether the item is a file or folder
Step 4: Implement the Recursive Rendering Function
Now, let's create a function to recursively render our folder structure:
// Recursive function to render the tree
const renderTree = (nodes) => {
return nodes.map((node) => (
<FileTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={node.name}
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</FileTreeItem>
));
};
This function takes an array of nodes and maps through them, creating a FileTreeItem
for each node. If a node has children, it recursively calls itself to render those children.
Step 5: Create the Main Component
Now, let's put everything together to create our FolderNavigation component:
// Main component
const FolderNavigation = () => {
const [expanded, setExpanded] = useState(['root']);
const [selected, setSelected] = useState([]);
const handleToggle = (event, nodeIds) => {
setExpanded(nodeIds);
};
const handleSelect = (event, nodeIds) => {
setSelected(nodeIds);
};
return (
<Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
<TreeView
aria-label="folder navigation"
defaultCollapseIcon={<FolderOpenIcon color="primary" />}
defaultExpandIcon={<FolderIcon color="primary" />}
defaultEndIcon={<InsertDriveFileIcon color="action" fontSize="small" />}
expanded={expanded}
selected={selected}
onNodeToggle={handleToggle}
onNodeSelect={handleSelect}
multiSelect={false}
sx={{
height: '100%',
flexGrow: 1,
overflowY: 'auto',
'& .MuiTreeItem-root': {
'&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label': {
backgroundColor: 'transparent',
},
},
}}
>
{renderTree(folderData)}
</TreeView>
</Box>
);
};
In this component:
- We use React's
useState
hook to manage the expanded and selected state of the tree nodes - We define handlers for toggle and select events
- We render the TreeView with appropriate icons for different node states
- We call our
renderTree
function to generate the tree structure from our data
Step 6: Implement Controlled Expansion and Selection
Let's enhance our component to handle controlled expansion and selection, which gives us more control over the behavior:
const FolderNavigation = () => {
const [expanded, setExpanded] = useState(['root']);
const [selected, setSelected] = useState([]);
const handleToggle = (event, nodeIds) => {
setExpanded(nodeIds);
};
const handleSelect = (event, nodeIds) => {
setSelected(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
// You can add additional logic here, like fetching file contents
// when a file is selected
const selectedNode = findNodeById(folderData, nodeIds);
if (selectedNode && selectedNode.type === 'file') {
console.log(`File selected: ${selectedNode.name}`);
// You could dispatch an action or call a function here
}
};
// Helper function to find a node by ID
const findNodeById = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children && node.children.length > 0) {
const foundInChildren = findNodeById(node.children, id);
if (foundInChildren) {
return foundInChildren;
}
}
}
return null;
};
// ... rest of the component
}
This enhancement adds a helper function to find a node by its ID, which is useful for handling selection events. We also added logic to detect when a file is selected, which could be extended to load file contents or perform other actions.
Step 7: Add Context Menu Functionality
Let's add a context menu to our folder navigation to enable actions like creating, renaming, and deleting items:
import React, { useState, useRef } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
// Inside the FolderNavigation component
const FolderNavigation = () => {
// Previous state variables...
const [contextMenu, setContextMenu] = useState(null);
const [rightClickedNode, setRightClickedNode] = useState(null);
const handleContextMenu = (event, nodeId) => {
event.preventDefault();
event.stopPropagation();
setRightClickedNode(nodeId);
setContextMenu({
mouseX: event.clientX,
mouseY: event.clientY,
});
};
const handleContextMenuClose = () => {
setContextMenu(null);
};
const handleContextMenuAction = (action) => {
const node = findNodeById(folderData, rightClickedNode);
switch (action) {
case 'new':
console.log(`Create new item in ${node.name}`);
// Logic to create a new item
break;
case 'rename':
console.log(`Rename ${node.name}`);
// Logic to rename an item
break;
case 'delete':
console.log(`Delete ${node.name}`);
// Logic to delete an item
break;
case 'copy':
console.log(`Copy ${node.name}`);
// Logic to copy an item
break;
case 'cut':
console.log(`Cut ${node.name}`);
// Logic to cut an item
break;
case 'paste':
console.log(`Paste into ${node.name}`);
// Logic to paste an item
break;
default:
break;
}
handleContextMenuClose();
};
// Modify the renderTree function to add context menu event
const renderTree = (nodes) => {
return nodes.map((node) => (
<FileTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={node.name}
onContextMenu={(event) => handleContextMenu(event, node.id)}
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</FileTreeItem>
));
};
return (
<Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
<TreeView
// Previous props...
>
{renderTree(folderData)}
</TreeView>
<Menu
open={contextMenu !== null}
onClose={handleContextMenuClose}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? { top: contextMenu.mouseY, left: contextMenu.mouseX }
: undefined
}
>
<MenuItem onClick={() => handleContextMenuAction('new')}>
<ListItemIcon>
<AddIcon fontSize="small" />
</ListItemIcon>
<ListItemText>New</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleContextMenuAction('rename')}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Rename</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleContextMenuAction('delete')}>
<ListItemIcon>
<DeleteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleContextMenuAction('copy')}>
<ListItemIcon>
<ContentCopyIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Copy</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleContextMenuAction('cut')}>
<ListItemIcon>
<ContentCutIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Cut</ListItemText>
</MenuItem>
<MenuItem onClick={() => handleContextMenuAction('paste')}>
<ListItemIcon>
<ContentPasteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Paste</ListItemText>
</MenuItem>
</Menu>
</Box>
);
};
This enhancement adds a context menu that appears when right-clicking on a tree node. The menu includes common file operations like new, rename, delete, copy, cut, and paste. The handleContextMenuAction
function is a placeholder where you would implement the actual logic for these operations.
Step 8: Implement Drag and Drop Functionality
Let's add drag-and-drop functionality to allow users to reorganize the folder structure:
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
// DraggableTreeItem component
const DraggableTreeItem = (props) => {
const { nodeId, type, label, onMoveNode, ...other } = props;
const [{ isDragging }, drag] = useDrag({
type: 'TREE_ITEM',
item: { id: nodeId, type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
const [{ isOver, canDrop }, drop] = useDrop({
accept: 'TREE_ITEM',
drop: (item) => {
if (item.id !== nodeId) {
onMoveNode(item.id, nodeId);
}
},
canDrop: (item) => {
// Only allow dropping into folders
return type === 'folder' && item.id !== nodeId;
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
// Combine drag and drop refs
const ref = useRef(null);
drag(drop(ref));
return (
<div
ref={ref}
style={{
opacity: isDragging ? 0.5 : 1,
backgroundColor: isOver && canDrop ? 'rgba(0, 255, 0, 0.1)' : 'transparent',
}}
>
<FileTreeItem
nodeId={nodeId}
type={type}
label={label}
{...other}
/>
</div>
);
};
// Update the FolderNavigation component
const FolderNavigation = () => {
// Previous state variables...
const handleMoveNode = (sourceId, targetId) => {
// This is where you would implement the logic to move a node
// For a real application, you would likely update your state or make an API call
console.log(`Moving node ${sourceId} to ${targetId}`);
// Example implementation (not complete):
// 1. Find the source node and its parent
// 2. Find the target node (which should be a folder)
// 3. Remove the source node from its parent's children
// 4. Add the source node to the target node's children
// 5. Update the state with the new structure
};
// Update the renderTree function to use DraggableTreeItem
const renderTree = (nodes) => {
return nodes.map((node) => (
<DraggableTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={node.name}
onContextMenu={(event) => handleContextMenu(event, node.id)}
onMoveNode={handleMoveNode}
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</DraggableTreeItem>
));
};
return (
<DndProvider backend={HTML5Backend}>
<Box sx={{ minHeight: 300, flexGrow: 1, maxWidth: 300, overflowY: 'auto' }}>
{/* Rest of the component... */}
</Box>
</DndProvider>
);
};
This enhancement adds drag-and-drop functionality using the react-dnd
library. We create a DraggableTreeItem
component that wraps our FileTreeItem
and handles the drag-and-drop logic. The handleMoveNode
function is a placeholder where you would implement the actual logic to update your data structure when a node is moved.
Step 9: Add Search Functionality
Let's add a search feature to help users find files and folders quickly:
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import SearchIcon from '@mui/icons-material/Search';
// Inside the FolderNavigation component
const FolderNavigation = () => {
// Previous state variables...
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const handleSearch = (event) => {
const term = event.target.value;
setSearchTerm(term);
if (term.trim() === '') {
setSearchResults([]);
return;
}
// Search through the folder structure
const results = searchNodes(folderData, term.toLowerCase());
setSearchResults(results);
// Expand paths to search results
const pathsToExpand = results.reduce((paths, node) => {
return [...paths, ...getPathToNode(folderData, node.id)];
}, []);
setExpanded([...new Set(pathsToExpand)]);
};
// Helper function to search nodes
const searchNodes = (nodes, term, results = []) => {
for (const node of nodes) {
if (node.name.toLowerCase().includes(term)) {
results.push(node);
}
if (node.children && node.children.length > 0) {
searchNodes(node.children, term, results);
}
}
return results;
};
// Helper function to get the path to a node
const getPathToNode = (nodes, targetId, path = [], currentPath = []) => {
for (const node of nodes) {
const newPath = [...currentPath, node.id];
if (node.id === targetId) {
return [...path, ...newPath];
}
if (node.children && node.children.length > 0) {
const foundPath = getPathToNode(node.children, targetId, path, newPath);
if (foundPath.length > 0) {
return foundPath;
}
}
}
return [];
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<TextField
fullWidth
variant="outlined"
size="small"
placeholder="Search files and folders..."
value={searchTerm}
onChange={handleSearch}
sx={{ mb: 2 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<TreeView
// Previous props...
>
{renderTree(folderData)}
</TreeView>
</Box>
{/* Context menu... */}
</Box>
);
};
This enhancement adds a search field that filters the folder structure as the user types. When a match is found, the tree automatically expands to show the matching nodes. The searchNodes
function recursively searches through the folder structure, and the getPathToNode
function helps identify which nodes need to be expanded to reveal the search results.
Step 10: Handle Large Data Sets with Virtualization
For large folder structures, rendering the entire tree can cause performance issues. Let's implement virtualization to improve performance:
import { FixedSizeTree } from 'react-vtree';
import AutoSizer from 'react-virtualized-auto-sizer';
// Inside the FolderNavigation component
const FolderNavigation = () => {
// Previous state variables...
// Flatten the tree data for virtualization
const flattenTree = (nodes, depth = 0, parentExpanded = true, result = []) => {
for (const node of nodes) {
const isExpanded = expanded.includes(node.id);
const isVisible = depth === 0 || parentExpanded;
if (isVisible) {
result.push({
id: node.id,
name: node.name,
type: node.type,
depth,
isExpanded,
isLeaf: !node.children || node.children.length === 0,
});
if (node.children && node.children.length > 0 && isExpanded) {
flattenTree(node.children, depth + 1, isExpanded, result);
}
}
}
return result;
};
const flatData = flattenTree(folderData);
// Node renderer for virtualized tree
const Node = ({ data, style }) => {
const { id, name, type, depth, isExpanded, isLeaf } = data;
const indent = depth * 24;
const getIcon = () => {
if (type === 'file') {
return <InsertDriveFileIcon color="action" fontSize="small" />;
}
return isExpanded ?
<FolderOpenIcon color="primary" /> :
<FolderIcon color="primary" />;
};
const handleNodeClick = () => {
if (!isLeaf) {
const newExpanded = isExpanded
? expanded.filter(id => id !== data.id)
: [...expanded, data.id];
setExpanded(newExpanded);
}
setSelected([id]);
};
const isSelected = selected.includes(id);
return (
<div
style={{
...style,
paddingLeft: `${indent}px`,
backgroundColor: isSelected ? 'rgba(0, 0, 0, 0.08)' : 'transparent',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={handleNodeClick}
onContextMenu={(event) => handleContextMenu(event, id)}
>
{!isLeaf && (
<div style={{ marginRight: 4 }}>
{isExpanded ? <ExpandMoreIcon /> : <ChevronRightIcon />}
</div>
)}
<div style={{ marginRight: 4 }}>
{getIcon()}
</div>
<Typography variant="body2">{name}</Typography>
</div>
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<TextField
// Search field...
/>
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
<AutoSizer>
{({ height, width }) => (
<FixedSizeTree
height={height}
width={width}
itemSize={32}
itemCount={flatData.length}
itemData={flatData}
children={Node}
/>
)}
</AutoSizer>
</Box>
{/* Context menu... */}
</Box>
);
};
This enhancement replaces the standard TreeView with a virtualized tree implementation using react-vtree
and react-virtualized-auto-sizer
. The virtualized approach only renders the nodes that are currently visible in the viewport, which significantly improves performance for large data sets.
Step 11: Integrate with Backend API
Let's integrate our folder navigation with a backend API to fetch and update the folder structure:
import { useEffect } from 'react';
import axios from 'axios';
import CircularProgress from '@mui/material/CircularProgress';
// Inside the FolderNavigation component
const FolderNavigation = () => {
// Previous state variables...
const [folderData, setFolderData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchFolderData = async () => {
try {
setLoading(true);
const response = await axios.get('/api/folders');
setFolderData(response.data);
// Expand the root folder by default
if (response.data.length > 0) {
setExpanded([response.data[0].id]);
}
setLoading(false);
} catch (err) {
setError('Failed to fetch folder data');
setLoading(false);
console.error(err);
}
};
fetchFolderData();
}, []);
const handleCreateFolder = async (parentId, name) => {
try {
const response = await axios.post('/api/folders', {
parentId,
name,
type: 'folder'
});
// Update the folder structure with the new folder
const updatedData = addNodeToTree(folderData, parentId, response.data);
setFolderData(updatedData);
} catch (err) {
console.error('Failed to create folder', err);
}
};
const handleDeleteItem = async (id) => {
try {
await axios.delete(`/api/items/${id}`);
// Update the folder structure by removing the deleted item
const updatedData = removeNodeFromTree(folderData, id);
setFolderData(updatedData);
} catch (err) {
console.error('Failed to delete item', err);
}
};
// Helper function to add a node to the tree
const addNodeToTree = (nodes, parentId, newNode) => {
return nodes.map(node => {
if (node.id === parentId) {
return {
...node,
children: [...(node.children || []), newNode]
};
}
if (node.children && node.children.length > 0) {
return {
...node,
children: addNodeToTree(node.children, parentId, newNode)
};
}
return node;
});
};
// Helper function to remove a node from the tree
const removeNodeFromTree = (nodes, id) => {
return nodes.filter(node => node.id !== id).map(node => {
if (node.children && node.children.length > 0) {
return {
...node,
children: removeNodeFromTree(node.children, id)
};
}
return node;
});
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box sx={{ p: 2 }}>
<Typography color="error">{error}</Typography>
</Box>
);
}
// Rest of the component...
};
This enhancement integrates the folder navigation with a backend API. It fetches the folder structure when the component mounts and provides functions to create and delete items. The addNodeToTree
and removeNodeFromTree
helper functions update the local state when changes are made.
Customizing TreeView Appearance and Behavior
MUI's TreeView component is highly customizable. Let's explore some advanced customization options:
Theme Customization
You can customize the TreeView appearance using MUI's theming system:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiTreeItem: {
styleOverrides: {
root: {
'&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label': {
backgroundColor: 'transparent',
},
'&.Mui-selected > .MuiTreeItem-content .MuiTreeItem-label:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
},
content: {
padding: '4px 8px',
borderRadius: '4px',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)',
},
},
label: {
fontWeight: 400,
padding: '0 4px',
},
iconContainer: {
marginRight: 4,
width: 20,
display: 'flex',
justifyContent: 'center',
'& svg': {
fontSize: 20,
},
},
group: {
marginLeft: 12,
paddingLeft: 12,
borderLeft: `1px dashed rgba(0, 0, 0, 0.2)`,
},
},
},
},
});
// Wrap your component with ThemeProvider
const App = () => {
return (
<ThemeProvider theme={theme}>
<FolderNavigation />
</ThemeProvider>
);
};
Custom Node Content
You can customize the content of each node to include additional information or controls:
const CustomTreeItem = (props) => {
const { nodeId, type, label, size, modified, ...other } = props;
return (
<StyledTreeItem
nodeId={nodeId}
label={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{type === 'file' ? (
<InsertDriveFileIcon color="action" fontSize="small" />
) : (
<FolderIcon color="primary" fontSize="small" />
)}
<Typography variant="body2" sx={{ ml: 1 }}>
{label}
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{size && (
<Typography variant="caption" color="text.secondary">
{size}
</Typography>
)}
{modified && (
<Typography variant="caption" color="text.secondary">
{modified}
</Typography>
)}
</Box>
</Box>
}
{...other}
/>
);
};
This custom TreeItem displays additional information like file size and modification date. You would use it like this:
const renderTree = (nodes) => {
return nodes.map((node) => (
<CustomTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={node.name}
size={node.size}
modified={node.modified}
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</CustomTreeItem>
));
};
Custom Expand/Collapse Icons
You can customize the icons used for expanding and collapsing nodes:
import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined';
import IndeterminateCheckBoxOutlinedIcon from '@mui/icons-material/IndeterminateCheckBoxOutlined';
import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
// Inside your component
return (
<TreeView
defaultCollapseIcon={<IndeterminateCheckBoxOutlinedIcon />}
defaultExpandIcon={<AddBoxOutlinedIcon />}
defaultEndIcon={<CheckBoxOutlineBlankIcon />}
// Other props...
>
{/* Tree content */}
</TreeView>
);
Advanced TreeView Capabilities
Let's explore some advanced capabilities of the MUI TreeView component:
Keyboard Navigation
TreeView supports keyboard navigation out of the box, but you can enhance it with custom handlers:
const handleKeyDown = (event) => {
const selectedNode = findNodeById(folderData, selected[0]);
switch (event.key) {
case 'Enter':
if (selectedNode && selectedNode.type === 'file') {
console.log(`Opening file: ${selectedNode.name}`);
// Logic to open the file
}
break;
case 'Delete':
if (selectedNode) {
console.log(`Deleting: ${selectedNode.name}`);
// Logic to delete the selected item
}
break;
case 'F2':
if (selectedNode) {
console.log(`Renaming: ${selectedNode.name}`);
// Logic to rename the selected item
}
break;
default:
break;
}
};
// Add the event listener to the TreeView
<TreeView
// Other props...
onKeyDown={handleKeyDown}
>
{/* Tree content */}
</TreeView>
Lazy Loading
For large folder structures, you might want to implement lazy loading to fetch child nodes only when a parent is expanded:
const FolderNavigation = () => {
// Previous state variables...
const [loadingNodes, setLoadingNodes] = useState({});
const handleToggle = async (event, nodeIds) => {
const expandedNodes = nodeIds.filter(id => !expanded.includes(id));
// For each newly expanded node, fetch its children if needed
for (const nodeId of expandedNodes) {
const node = findNodeById(folderData, nodeId);
// If the node is a folder and has no children yet, fetch them
if (node && node.type === 'folder' && (!node.children || node.children.length === 0)) {
try {
setLoadingNodes(prev => ({ ...prev, [nodeId]: true }));
// Fetch children from the API
const response = await axios.get(`/api/folders/${nodeId}/children`);
// Update the folder structure with the fetched children
const updatedData = updateNodeChildren(folderData, nodeId, response.data);
setFolderData(updatedData);
setLoadingNodes(prev => ({ ...prev, [nodeId]: false }));
} catch (err) {
console.error(`Failed to fetch children for folder ${nodeId}`, err);
setLoadingNodes(prev => ({ ...prev, [nodeId]: false }));
}
}
}
setExpanded(nodeIds);
};
// Helper function to update a node's children
const updateNodeChildren = (nodes, nodeId, children) => {
return nodes.map(node => {
if (node.id === nodeId) {
return {
...node,
children
};
}
if (node.children && node.children.length > 0) {
return {
...node,
children: updateNodeChildren(node.children, nodeId, children)
};
}
return node;
});
};
// Modify the renderTree function to show loading indicators
const renderTree = (nodes) => {
return nodes.map((node) => (
<FileTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2">{node.name}</Typography>
{loadingNodes[node.id] && (
<CircularProgress size={16} sx={{ ml: 1 }} />
)}
</Box>
}
// Other props...
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</FileTreeItem>
));
};
// Rest of the component...
};
This implementation fetches children only when a node is expanded, which can significantly improve performance for large folder structures.
Handling Multiple Selection
The TreeView component supports multiple selection, which can be useful for operations that involve multiple files or folders:
const FolderNavigation = () => {
// Previous state variables...
const handleSelect = (event, nodeIds) => {
setSelected(nodeIds);
// Count the number of files and folders selected
let fileCount = 0;
let folderCount = 0;
for (const nodeId of nodeIds) {
const node = findNodeById(folderData, nodeId);
if (node) {
if (node.type === 'file') {
fileCount++;
} else {
folderCount++;
}
}
}
console.log(`Selected ${fileCount} files and ${folderCount} folders`);
// You could update a status bar or enable/disable actions based on the selection
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Previous components... */}
<TreeView
// Other props...
multiSelect={true}
selected={selected}
onNodeSelect={handleSelect}
>
{renderTree(folderData)}
</TreeView>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="caption">
{selected.length > 0
? `${selected.length} item(s) selected`
: 'No items selected'}
</Typography>
</Box>
</Box>
);
};
Best Practices and Common Issues
When working with MUI TreeView for folder navigation, here are some best practices and common issues to be aware of:
Performance Optimization
- Virtualization: For large trees, always use virtualization to render only visible nodes.
- Memoization: Use React's
useMemo
anduseCallback
to prevent unnecessary re-renders. - Lazy Loading: Load child nodes only when needed to reduce initial load time.
// Example of memoization for the renderTree function
const renderTree = useCallback((nodes) => {
return nodes.map((node) => (
<FileTreeItem
key={node.id}
nodeId={node.id}
type={node.type}
label={node.name}
>
{node.children && node.children.length > 0 ? renderTree(node.children) : null}
</FileTreeItem>
));
}, [expanded, selected]); // Only re-create when expanded or selected changes
Accessibility
- Keyboard Navigation: Ensure that all functionality is accessible via keyboard.
- ARIA Attributes: MUI TreeView includes ARIA attributes, but you may need to add more for custom functionality.
- Focus Management: Properly manage focus, especially after operations like deleting nodes.
// Example of enhancing accessibility
<TreeView
aria-label="Folder navigation"
defaultCollapseIcon={<FolderOpenIcon />}
defaultExpandIcon={<FolderIcon />}
// Other props...
>
{renderTree(folderData)}
</TreeView>
Common Issues and Solutions
-
Issue: TreeView doesn't update when data changes. Solution: Ensure you're properly updating the state and using the correct keys for each node.
-
Issue: Drag and drop doesn't work correctly. Solution: Make sure you're handling the drop event properly and updating the data structure accordingly.
// Proper data structure update after drag and drop
const handleMoveNode = (sourceId, targetId) => {
// Find the source node and its parent
let sourceNode = null;
let sourceParentId = null;
const findSourceNodeAndParent = (nodes, parentId = null) => {
for (const node of nodes) {
if (node.id === sourceId) {
sourceNode = { ...node };
sourceParentId = parentId;
return true;
}
if (node.children && node.children.length > 0) {
if (findSourceNodeAndParent(node.children, node.id)) {
return true;
}
}
}
return false;
};
findSourceNodeAndParent(folderData);
if (!sourceNode || !sourceParentId) {
console.error('Source node or parent not found');
return;
}
// Remove the source node from its parent
const removeFromParent = (nodes) => {
return nodes.map(node => {
if (node.id === sourceParentId) {
return {
...node,
children: node.children.filter(child => child.id !== sourceId)
};
}
if (node.children && node.children.length > 0) {
return {
...node,
children: removeFromParent(node.children)
};
}
return node;
});
};
let updatedData = removeFromParent(folderData);
// Add the source node to the target node
const addToTarget = (nodes) => {
return nodes.map(node => {
if (node.id === targetId) {
return {
...node,
children: [...(node.children || []), sourceNode]
};
}
if (node.children && node.children.length > 0) {
return {
...node,
children: addToTarget(node.children)
};
}
return node;
});
};
updatedData = addToTarget(updatedData);
setFolderData(updatedData);
};
- Issue: Context menu appears in the wrong position. Solution: Make sure you're using the correct coordinates from the event and handling scroll position correctly.
// Proper context menu positioning
const handleContextMenu = (event, nodeId) => {
event.preventDefault();
event.stopPropagation();
// Get the target element's position relative to the viewport
const rect = event.currentTarget.getBoundingClientRect();
setRightClickedNode(nodeId);
setContextMenu({
mouseX: event.clientX,
mouseY: event.clientY,
// Store the scroll position to adjust when the menu is closed
scrollTop: document.documentElement.scrollTop,
scrollLeft: document.documentElement.scrollLeft
});
};
// When rendering the menu, adjust for any scrolling that happened since the menu was opened
<Menu
open={contextMenu !== null}
onClose={handleContextMenuClose}
anchorReference="anchorPosition"
anchorPosition={
contextMenu !== null
? {
top: contextMenu.mouseY + (document.documentElement.scrollTop - contextMenu.scrollTop),
left: contextMenu.mouseX + (document.documentElement.scrollLeft - contextMenu.scrollLeft)
}
: undefined
}
>
{/* Menu items */}
</Menu>
Wrapping Up
In this comprehensive guide, we've explored how to build a fully-featured folder navigation sidebar using MUI's TreeView component. We've covered everything from basic setup to advanced features like drag-and-drop, context menus, search, and virtualization for large data sets.
The MUI TreeView component provides a solid foundation for building hierarchical navigation interfaces, but as we've seen, it can be extended and customized to meet specific requirements. By combining it with other MUI components and React patterns, you can create a powerful and user-friendly folder navigation system for your applications.
Remember to consider performance, accessibility, and user experience when implementing your solution, especially when dealing with large data sets. With the techniques and best practices covered in this guide, you should be well-equipped to build a robust folder navigation sidebar for your React applications.