Building Team and Permission Managers with React MUI Transfer List
Working with user permissions or team assignments is a common requirement in many applications. Whether you're building an admin panel, a team management tool, or a role-based access control system, you need an intuitive interface for assigning items between lists. MUI's TransferList component is perfect for this scenario, offering a clean, accessible way to move items between two lists.
In this article, I'll show you how to implement a robust item assignment interface using MUI's TransferList component, focusing on practical applications like team member assignment and permission management.
What You'll Learn
By the end of this article, you'll be able to:
- Understand MUI's TransferList component and its core capabilities
- Implement a basic TransferList with proper data structure
- Build a team member assignment interface
- Create a permission management system
- Customize the TransferList appearance with MUI theming
- Handle edge cases and optimize performance
- Enhance accessibility for all users
Understanding MUI's TransferList Component
The TransferList component isn't a direct part of MUI's core components but is available as a composition pattern in their documentation. It combines several MUI components like List, Card, Checkbox, and Button to create a dual-list selector where users can transfer items between two lists.
Core Concepts
At its heart, the TransferList pattern consists of:
- Two lists (left and right) - typically representing "available" and "selected" items
- Transfer buttons between the lists to move items
- Checkboxes for selecting multiple items
- A consistent data model for tracking items in both lists
The component is particularly useful when users need to select multiple items from a larger set, with the ability to review their selections side-by-side with available options.
Data Structure
The TransferList operates on two arrays that contain the items for each list. A typical data structure looks like:
// Example data structure for TransferList
const [left, setLeft] = useState([
{ id: 1, name: 'John Doe' },
{ id: 2, name: 'Jane Smith' },
// more items...
]);
const [right, setRight] = useState([
{ id: 3, name: 'Alex Johnson' },
// more items...
]);
Basic Usage Pattern
Let's look at how the TransferList is typically implemented:
import React, { useState } from 'react';
import Grid from '@mui/material/Grid';
import List from '@mui/material/List';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import Checkbox from '@mui/material/Checkbox';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
function TransferList() {
// Lists state
const [left, setLeft] = useState(['Item 1', 'Item 2', 'Item 3']);
const [right, setRight] = useState(['Item 4', 'Item 5']);
// Selected items state
const [checked, setChecked] = useState([]);
// Handle toggling a single item
const handleToggle = (value) => () => {
const currentIndex = checked.indexOf(value);
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
};
// Check if all items in a list are selected
const numberOfChecked = (items) => intersection(checked, items).length;
// Handle selecting all items in a list
const handleToggleAll = (items) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items));
} else {
setChecked(union(checked, items));
}
};
// Helper functions for array operations
const not = (a, b) => a.filter((value) => b.indexOf(value) === -1);
const intersection = (a, b) => a.filter((value) => b.indexOf(value) !== -1);
const union = (a, b) => [...a, ...not(b, a)];
// Handle moving items from one list to another
const handleCheckedRight = () => {
setRight([...right, ...intersection(checked, left)]);
setLeft(not(left, checked));
setChecked(not(checked, left));
};
const handleCheckedLeft = () => {
setLeft([...left, ...intersection(checked, right)]);
setRight(not(right, checked));
setChecked(not(checked, right));
};
// Custom list component
const customList = (title, items) => (
<Card>
<CardHeader
sx={{ px: 2, py: 1 }}
avatar={
<Checkbox
onClick={handleToggleAll(items)}
checked={numberOfChecked(items) === items.length && items.length !== 0}
indeterminate={
numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0
}
disabled={items.length === 0}
inputProps={{ 'aria-label': 'all items selected' }}
/>
}
title={title}
subheader={`${numberOfChecked(items)}/${items.length} selected`}
/>
<Divider />
<List
sx={{
width: 200,
height: 230,
bgcolor: 'background.paper',
overflow: 'auto',
}}
dense
component="div"
role="list"
>
{items.map((value) => {
const labelId = `transfer-list-all-item-${value}-label`;
return (
<ListItem
key={value}
role="listitem"
button
onClick={handleToggle(value)}
>
<ListItemIcon>
<Checkbox
checked={checked.indexOf(value) !== -1}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={value} />
</ListItem>
);
})}
</List>
</Card>
);
return (
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item>{customList('Choices', left)}</Grid>
<Grid item>
<Grid container direction="column" alignItems="center">
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={leftChecked.length === 0}
aria-label="move selected right"
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={rightChecked.length === 0}
aria-label="move selected left"
>
<
</Button>
</Grid>
</Grid>
<Grid item>{customList('Chosen', right)}</Grid>
</Grid>
);
}
export default TransferList;
This implementation provides the foundation for our more advanced use cases.
Key Props and Customization Options
While the TransferList isn't a standalone component, we can identify the key props and customization options for its constituent parts:
Component | Key Props | Description |
---|---|---|
Card | sx, elevation, variant | Controls the container appearance |
List | sx, dense, disablePadding | Controls list appearance and behavior |
Checkbox | checked, indeterminate, disabled | Controls checkbox state |
Button | variant, disabled, onClick | Controls transfer button appearance and behavior |
CardHeader | title, subheader, avatar | Controls list header appearance |
The beauty of this pattern is its flexibility - you can customize each component according to your needs using MUI's theming and styling options.
Building a Team Member Assignment Interface
Let's build a practical example: a team member assignment interface where an admin can assign users to a project team.
Step 1: Setting up the Component Structure
First, let's create our component structure:
import React, { useState, useEffect } from 'react';
import {
Grid, Card, CardHeader, List, ListItem, ListItemIcon,
ListItemText, Checkbox, Button, Divider, Typography, Box,
TextField, Avatar
} from '@mui/material';
function TeamAssignment({ allUsers = [], initialTeamMembers = [], onSave }) {
// State will go here
return (
<Box sx={{ width: '100%', p: 2 }}>
<Typography variant="h5" gutterBottom>
Team Member Assignment
</Typography>
{/* TransferList implementation will go here */}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
onClick={() => onSave(right)}
>
Save Team Assignments
</Button>
</Box>
</Box>
);
}
export default TeamAssignment;
Step 2: Implementing the State Management
Next, let's add state management for our lists:
function TeamAssignment({ allUsers = [], initialTeamMembers = [], onSave }) {
// Initialize state with users not in the team (left) and team members (right)
const [left, setLeft] = useState([]);
const [right, setRight] = useState([]);
const [checked, setChecked] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
// Initialize lists when props change
useEffect(() => {
// Get IDs of initial team members
const teamMemberIds = initialTeamMembers.map(user => user.id);
// Filter users not in the team
const availableUsers = allUsers.filter(
user => !teamMemberIds.includes(user.id)
);
setLeft(availableUsers);
setRight(initialTeamMembers);
}, [allUsers, initialTeamMembers]);
// Helper functions for array operations
const not = (a, b) => {
return a.filter(value => !b.some(item => item.id === value.id));
};
const intersection = (a, b) => {
return a.filter(value => b.some(item => item.id === value.id));
};
// ... rest of the component
Step 3: Implementing Item Selection Logic
Now, let's add the logic for selecting and transferring items:
// Handle toggling a single item
const handleToggle = (value) => () => {
const currentIndex = checked.findIndex(item => item.id === value.id);
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
};
// Check if all items in a list are selected
const numberOfChecked = (items) => {
return intersection(checked, items).length;
};
// Handle selecting all items in a list
const handleToggleAll = (items) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items));
} else {
setChecked([...checked, ...not(items, checked)]);
}
};
// Handle moving items from one list to another
const handleCheckedRight = () => {
const itemsToMove = intersection(checked, left);
setRight([...right, ...itemsToMove]);
setLeft(not(left, itemsToMove));
setChecked(not(checked, itemsToMove));
};
const handleCheckedLeft = () => {
const itemsToMove = intersection(checked, right);
setLeft([...left, ...itemsToMove]);
setRight(not(right, itemsToMove));
setChecked(not(checked, itemsToMove));
};
Step 4: Creating the Custom List Component
Let's create a custom list component that displays user information:
// Filter function for search
const filterItems = (items) => {
if (!searchTerm) return items;
return items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.email.toLowerCase().includes(searchTerm.toLowerCase())
);
};
// Custom list component for users
const customList = (title, items, showSearch = false) => {
const filteredItems = filterItems(items);
return (
<Card sx={{ width: 350 }}>
<CardHeader
sx={{ px: 2, py: 1 }}
avatar={
<Checkbox
onClick={handleToggleAll(items)}
checked={numberOfChecked(items) === items.length && items.length !== 0}
indeterminate={
numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0
}
disabled={items.length === 0}
inputProps={{ 'aria-label': 'all items selected' }}
/>
}
title={title}
subheader={`${numberOfChecked(items)}/${items.length} selected`}
/>
<Divider />
{showSearch && (
<Box sx={{ p: 1 }}>
<TextField
fullWidth
size="small"
placeholder="Search users..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
variant="outlined"
/>
</Box>
)}
<List
sx={{
height: 350,
bgcolor: 'background.paper',
overflow: 'auto',
}}
dense
component="div"
role="list"
>
{filteredItems.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{showSearch && searchTerm ? 'No matching users found' : 'No users available'}
</Typography>
</Box>
) : (
filteredItems.map((user) => {
const labelId = `transfer-list-item-${user.id}-label`;
return (
<ListItem
key={user.id}
role="listitem"
button
onClick={handleToggle(user)}
>
<ListItemIcon>
<Checkbox
checked={checked.some(item => item.id === user.id)}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemIcon>
<Avatar
alt={user.name}
src={user.avatar}
sx={{ width: 32, height: 32 }}
>
{user.name.charAt(0)}
</Avatar>
</ListItemIcon>
<ListItemText
id={labelId}
primary={user.name}
secondary={user.email}
/>
</ListItem>
);
})
)}
</List>
</Card>
);
};
Step 5: Putting It All Together
Finally, let's complete our component:
return (
<Box sx={{ width: '100%', p: 2 }}>
<Typography variant="h5" gutterBottom>
Team Member Assignment
</Typography>
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item>{customList('Available Users', left, true)}</Grid>
<Grid item>
<Grid container direction="column" alignItems="center">
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={intersection(checked, left).length === 0}
aria-label="move selected to team"
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={intersection(checked, right).length === 0}
aria-label="remove selected from team"
>
<
</Button>
</Grid>
</Grid>
<Grid item>{customList('Team Members', right)}</Grid>
</Grid>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
onClick={() => onSave(right)}
>
Save Team Assignments
</Button>
</Box>
</Box>
);
Step 6: Using the Team Assignment Component
Here's how you would use this component in a parent component:
import React, { useState, useEffect } from 'react';
import TeamAssignment from './TeamAssignment';
import { Typography, Paper } from '@mui/material';
function ProjectTeamManager({ projectId }) {
const [allUsers, setAllUsers] = useState([]);
const [teamMembers, setTeamMembers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch all users and current team members
const fetchData = async () => {
try {
// In a real app, these would be API calls
const usersResponse = await fetchAllUsers();
const teamResponse = await fetchTeamMembers(projectId);
setAllUsers(usersResponse);
setTeamMembers(teamResponse);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [projectId]);
const handleSaveTeam = async (newTeamMembers) => {
try {
// In a real app, this would be an API call
await saveTeamMembers(projectId, newTeamMembers);
setTeamMembers(newTeamMembers);
// Show success notification
} catch (error) {
console.error('Error saving team members:', error);
// Show error notification
}
};
if (loading) {
return <Typography>Loading...</Typography>;
}
return (
<Paper elevation={3} sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom>
Project Team Management
</Typography>
<TeamAssignment
allUsers={allUsers}
initialTeamMembers={teamMembers}
onSave={handleSaveTeam}
/>
</Paper>
);
}
export default ProjectTeamManager;
// Mock API functions
const fetchAllUsers = () => {
return Promise.resolve([
{ id: 1, name: 'John Doe', email: 'john@example.com', avatar: '' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', avatar: '' },
{ id: 3, name: 'Alex Johnson', email: 'alex@example.com', avatar: '' },
{ id: 4, name: 'Sarah Williams', email: 'sarah@example.com', avatar: '' },
{ id: 5, name: 'Michael Brown', email: 'michael@example.com', avatar: '' },
]);
};
const fetchTeamMembers = (projectId) => {
return Promise.resolve([
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', avatar: '' },
{ id: 4, name: 'Sarah Williams', email: 'sarah@example.com', avatar: '' },
]);
};
const saveTeamMembers = (projectId, members) => {
console.log('Saving team members for project', projectId, members);
return Promise.resolve(true);
};
Building a Permission Management System
Now, let's build another practical example: a permission management system where an admin can assign permissions to a role.
Step 1: Setting up the Permission Management Component
import React, { useState, useEffect } from 'react';
import {
Grid, Card, CardHeader, List, ListItem, ListItemIcon,
ListItemText, Checkbox, Button, Divider, Typography, Box,
TextField, Chip, Tooltip
} from '@mui/material';
import LockIcon from '@mui/icons-material/Lock';
import InfoIcon from '@mui/icons-material/Info';
function PermissionManager({ allPermissions = [], initialAssignedPermissions = [], roleName, onSave }) {
// State for available permissions and assigned permissions
const [left, setLeft] = useState([]);
const [right, setRight] = useState([]);
const [checked, setChecked] = useState([]);
const [searchTerm, setSearchTerm] = useState('');
// Initialize lists when props change
useEffect(() => {
// Get IDs of initially assigned permissions
const assignedPermissionIds = initialAssignedPermissions.map(perm => perm.id);
// Filter permissions not yet assigned
const availablePermissions = allPermissions.filter(
perm => !assignedPermissionIds.includes(perm.id)
);
setLeft(availablePermissions);
setRight(initialAssignedPermissions);
}, [allPermissions, initialAssignedPermissions]);
// Helper functions for array operations (similar to team assignment)
const not = (a, b) => {
return a.filter(value => !b.some(item => item.id === value.id));
};
const intersection = (a, b) => {
return a.filter(value => b.some(item => item.id === value.id));
};
// ... rest of the component will go here
Step 2: Implementing Selection and Transfer Logic
// Handle toggling a single item
const handleToggle = (value) => () => {
const currentIndex = checked.findIndex(item => item.id === value.id);
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
};
// Check if all items in a list are selected
const numberOfChecked = (items) => {
return intersection(checked, items).length;
};
// Handle selecting all items in a list
const handleToggleAll = (items) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items));
} else {
setChecked([...checked, ...not(items, checked)]);
}
};
// Handle moving items from one list to another
const handleCheckedRight = () => {
const itemsToMove = intersection(checked, left);
setRight([...right, ...itemsToMove]);
setLeft(not(left, itemsToMove));
setChecked(not(checked, itemsToMove));
};
const handleCheckedLeft = () => {
const itemsToMove = intersection(checked, right);
setLeft([...left, ...itemsToMove]);
setRight(not(right, itemsToMove));
setChecked(not(checked, itemsToMove));
};
Step 3: Creating the Custom List Component for Permissions
// Filter function for search
const filterItems = (items) => {
if (!searchTerm) return items;
return items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.category.toLowerCase().includes(searchTerm.toLowerCase())
);
};
// Group permissions by category
const groupByCategory = (items) => {
const grouped = {};
items.forEach(item => {
if (!grouped[item.category]) {
grouped[item.category] = [];
}
grouped[item.category].push(item);
});
return grouped;
};
// Custom list component for permissions
const customList = (title, items, showSearch = false) => {
const filteredItems = filterItems(items);
const groupedItems = groupByCategory(filteredItems);
const categories = Object.keys(groupedItems).sort();
return (
<Card sx={{ width: 350 }}>
<CardHeader
sx={{ px: 2, py: 1 }}
avatar={
<Checkbox
onClick={handleToggleAll(items)}
checked={numberOfChecked(items) === items.length && items.length !== 0}
indeterminate={
numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0
}
disabled={items.length === 0}
inputProps={{ 'aria-label': 'all items selected' }}
/>
}
title={title}
subheader={`${numberOfChecked(items)}/${items.length} selected`}
/>
<Divider />
{showSearch && (
<Box sx={{ p: 1 }}>
<TextField
fullWidth
size="small"
placeholder="Search permissions..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
variant="outlined"
/>
</Box>
)}
<List
sx={{
height: 350,
bgcolor: 'background.paper',
overflow: 'auto',
}}
dense
component="div"
role="list"
>
{filteredItems.length === 0 ? (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
{showSearch && searchTerm ? 'No matching permissions found' : 'No permissions available'}
</Typography>
</Box>
) : (
categories.map(category => (
<React.Fragment key={category}>
<ListItem sx={{ bgcolor: 'action.hover', py: 0.5 }}>
<ListItemText
primary={
<Typography variant="subtitle2" color="text.secondary">
{category.toUpperCase()}
</Typography>
}
/>
</ListItem>
{groupedItems[category].map((permission) => {
const labelId = `transfer-list-item-${permission.id}-label`;
return (
<ListItem
key={permission.id}
role="listitem"
button
onClick={handleToggle(permission)}
>
<ListItemIcon>
<Checkbox
checked={checked.some(item => item.id === permission.id)}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemIcon>
<LockIcon color="action" fontSize="small" />
</ListItemIcon>
<ListItemText
id={labelId}
primary={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{permission.name}
{permission.description && (
<Tooltip title={permission.description}>
<InfoIcon
color="action"
fontSize="small"
sx={{ ml: 0.5, fontSize: 16 }}
/>
</Tooltip>
)}
</Box>
}
secondary={
permission.impact === 'high' ? (
<Chip
label="High Impact"
size="small"
color="error"
variant="outlined"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
) : null
}
/>
</ListItem>
);
})}
</React.Fragment>
))
)}
</List>
</Card>
);
};
Step 4: Completing the Permission Manager Component
return (
<Box sx={{ width: '100%', p: 2 }}>
<Typography variant="h5" gutterBottom>
Permission Management for Role: {roleName}
</Typography>
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item>{customList('Available Permissions', left, true)}</Grid>
<Grid item>
<Grid container direction="column" alignItems="center">
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={intersection(checked, left).length === 0}
aria-label="assign selected permissions"
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={intersection(checked, right).length === 0}
aria-label="remove selected permissions"
>
<
</Button>
</Grid>
</Grid>
<Grid item>{customList('Assigned Permissions', right)}</Grid>
</Grid>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" color="text.secondary">
{right.filter(p => p.impact === 'high').length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Chip
label="High Impact"
size="small"
color="error"
variant="outlined"
sx={{ mr: 1 }}
/>
permissions require additional review
</Box>
)}
</Typography>
<Button
variant="contained"
color="primary"
onClick={() => onSave(right)}
>
Save Permissions
</Button>
</Box>
</Box>
);
}
export default PermissionManager;
Step 5: Using the Permission Manager Component
import React, { useState, useEffect } from 'react';
import PermissionManager from './PermissionManager';
import { Typography, Paper, CircularProgress, Box } from '@mui/material';
function RolePermissionManager({ roleId }) {
const [allPermissions, setAllPermissions] = useState([]);
const [assignedPermissions, setAssignedPermissions] = useState([]);
const [roleName, setRoleName] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch all permissions and currently assigned permissions
const fetchData = async () => {
try {
// In a real app, these would be API calls
const permissionsResponse = await fetchAllPermissions();
const roleResponse = await fetchRoleDetails(roleId);
setAllPermissions(permissionsResponse);
setAssignedPermissions(roleResponse.permissions);
setRoleName(roleResponse.name);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, [roleId]);
const handleSavePermissions = async (newPermissions) => {
try {
// In a real app, this would be an API call
await saveRolePermissions(roleId, newPermissions);
setAssignedPermissions(newPermissions);
// Show success notification
} catch (error) {
console.error('Error saving permissions:', error);
// Show error notification
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
<CircularProgress />
</Box>
);
}
return (
<Paper elevation={3} sx={{ p: 3 }}>
<PermissionManager
allPermissions={allPermissions}
initialAssignedPermissions={assignedPermissions}
roleName={roleName}
onSave={handleSavePermissions}
/>
</Paper>
);
}
export default RolePermissionManager;
// Mock API functions
const fetchAllPermissions = () => {
return Promise.resolve([
{
id: 1,
name: 'View Users',
description: 'Can view user profiles and basic information',
category: 'User Management',
impact: 'low'
},
{
id: 2,
name: 'Create Users',
description: 'Can create new user accounts',
category: 'User Management',
impact: 'medium'
},
{
id: 3,
name: 'Delete Users',
description: 'Can permanently delete user accounts',
category: 'User Management',
impact: 'high'
},
{
id: 4,
name: 'View Reports',
description: 'Can view system reports and analytics',
category: 'Reporting',
impact: 'low'
},
{
id: 5,
name: 'Export Data',
description: 'Can export system data to CSV/Excel',
category: 'Reporting',
impact: 'medium'
},
{
id: 6,
name: 'System Configuration',
description: 'Can modify system-wide settings',
category: 'Administration',
impact: 'high'
},
{
id: 7,
name: 'View Audit Logs',
description: 'Can view system audit logs',
category: 'Administration',
impact: 'medium'
},
]);
};
const fetchRoleDetails = (roleId) => {
return Promise.resolve({
id: roleId,
name: 'Content Editor',
permissions: [
{
id: 1,
name: 'View Users',
description: 'Can view user profiles and basic information',
category: 'User Management',
impact: 'low'
},
{
id: 4,
name: 'View Reports',
description: 'Can view system reports and analytics',
category: 'Reporting',
impact: 'low'
},
]
});
};
const saveRolePermissions = (roleId, permissions) => {
console.log('Saving permissions for role', roleId, permissions);
return Promise.resolve(true);
};
Advanced Customization and Optimization
Let's explore some advanced customization options and performance optimizations for our TransferList implementations.
Custom Styling with MUI Theme
You can customize the appearance of the TransferList components using MUI's theming system:
import { createTheme, ThemeProvider } from '@mui/material/styles';
// Create a custom theme for the transfer list
const transferListTheme = createTheme({
components: {
MuiCard: {
styleOverrides: {
root: {
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.05)'
}
}
},
MuiCardHeader: {
styleOverrides: {
root: {
backgroundColor: '#f5f5f5'
}
}
},
MuiListItem: {
styleOverrides: {
root: {
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
}
}
},
MuiButton: {
styleOverrides: {
outlinedPrimary: {
borderColor: '#1976d2',
color: '#1976d2',
'&:hover': {
backgroundColor: 'rgba(25, 118, 210, 0.04)'
}
}
}
}
}
});
// Use the theme in your component
function StyledTransferList() {
// ...component code
return (
<ThemeProvider theme={transferListTheme}>
{/* Your transfer list implementation */}
</ThemeProvider>
);
}
Performance Optimization with React.memo and useCallback
For large lists, you can optimize performance using React.memo and useCallback:
import React, { useState, useEffect, useCallback, memo } from 'react';
// Memoized list item component
const MemoizedListItem = memo(({ item, checked, onToggle }) => {
const labelId = `transfer-list-item-${item.id}-label`;
return (
<ListItem
key={item.id}
role="listitem"
button
onClick={() => onToggle(item)}
>
<ListItemIcon>
<Checkbox
checked={checked}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText
id={labelId}
primary={item.name}
/>
</ListItem>
);
});
function OptimizedTransferList({ leftItems, rightItems, onSave }) {
// State management
const [left, setLeft] = useState(leftItems);
const [right, setRight] = useState(rightItems);
const [checked, setChecked] = useState([]);
// Update when props change
useEffect(() => {
setLeft(leftItems);
setRight(rightItems);
}, [leftItems, rightItems]);
// Memoized handlers
const handleToggle = useCallback((value) => {
setChecked(prev => {
const currentIndex = prev.findIndex(item => item.id === value.id);
const newChecked = [...prev];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
return newChecked;
});
}, []);
const handleCheckedRight = useCallback(() => {
const itemsToMove = intersection(checked, left);
setRight(prev => [...prev, ...itemsToMove]);
setLeft(prev => not(prev, itemsToMove));
setChecked(prev => not(prev, itemsToMove));
}, [checked, left]);
const handleCheckedLeft = useCallback(() => {
const itemsToMove = intersection(checked, right);
setLeft(prev => [...prev, ...itemsToMove]);
setRight(prev => not(prev, itemsToMove));
setChecked(prev => not(prev, itemsToMove));
}, [checked, right]);
// Helper functions (similar to previous examples)
// Render optimized list
const renderList = useCallback((items) => {
return items.map(item => (
<MemoizedListItem
key={item.id}
item={item}
checked={checked.some(checkedItem => checkedItem.id === item.id)}
onToggle={handleToggle}
/>
));
}, [checked, handleToggle]);
// ... rest of component
Virtualization for Large Lists
For very large lists, you can use virtualization to improve performance:
import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
// Custom virtualized list component
const VirtualizedList = ({ items, checked, onToggle }) => {
const Row = ({ index, style }) => {
const item = items[index];
const labelId = `transfer-list-item-${item.id}-label`;
const isChecked = checked.some(checkedItem => checkedItem.id === item.id);
return (
<ListItem
style={style}
key={item.id}
role="listitem"
button
onClick={() => onToggle(item)}
>
<ListItemIcon>
<Checkbox
checked={isChecked}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText
id={labelId}
primary={item.name}
secondary={item.description}
/>
</ListItem>
);
};
return (
<div style={{ height: 350, width: '100%' }}>
<AutoSizer>
{({ height, width }) => (
<FixedSizeList
height={height}
width={width}
itemSize={50}
itemCount={items.length}
overscanCount={5}
>
{Row}
</FixedSizeList>
)}
</AutoSizer>
</div>
);
};
// Use the virtualized list in your TransferList component
const customList = (title, items) => (
<Card>
<CardHeader
// ... header implementation
/>
<Divider />
<VirtualizedList
items={items}
checked={checked}
onToggle={handleToggle}
/>
</Card>
);
Accessibility Enhancements
Let's improve the accessibility of our TransferList component:
function AccessibleTransferList({ leftItems, rightItems, leftLabel, rightLabel, onSave }) {
// ... state and handlers
// Enhanced custom list with accessibility features
const accessibleList = (title, items, listId) => (
<Card>
<CardHeader
sx={{ px: 2, py: 1 }}
avatar={
<Checkbox
onClick={handleToggleAll(items)}
checked={numberOfChecked(items) === items.length && items.length !== 0}
indeterminate={
numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0
}
disabled={items.length === 0}
inputProps={{
'aria-label': `select all ${title.toLowerCase()}`,
'aria-describedby': `${listId}-description`
}}
/>
}
title={
<Typography id={`${listId}-title`} variant="h6">
{title}
</Typography>
}
subheader={
<Typography id={`${listId}-description`} variant="body2">
`${numberOfChecked(items)}/${items.length} selected`
</Typography>
}
/>
<Divider />
<List
sx={{
height: 350,
bgcolor: 'background.paper',
overflow: 'auto',
}}
dense
component="div"
role="listbox"
aria-labelledby={`${listId}-title`}
id={listId}
>
{items.map((item) => {
const labelId = `${listId}-item-${item.id}-label`;
const isChecked = checked.some(checkedItem => checkedItem.id === item.id);
return (
<ListItem
key={item.id}
role="option"
button
onClick={handleToggle(item)}
aria-selected={isChecked}
>
<ListItemIcon>
<Checkbox
checked={isChecked}
tabIndex={-1}
disableRipple
inputProps={{
'aria-labelledby': labelId,
'aria-checked': isChecked
}}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={item.name} />
</ListItem>
);
})}
</List>
</Card>
);
return (
<Box
sx={{ width: '100%', p: 2 }}
role="region"
aria-label="Transfer list"
>
<Grid container spacing={2} justifyContent="center" alignItems="center">
<Grid item>{accessibleList(leftLabel, left, 'left-list')}</Grid>
<Grid item>
<Grid container direction="column" alignItems="center">
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={intersection(checked, left).length === 0}
aria-label={`Move selected items to ${rightLabel}`}
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={intersection(checked, right).length === 0}
aria-label={`Move selected items to ${leftLabel}`}
>
<
</Button>
</Grid>
</Grid>
<Grid item>{accessibleList(rightLabel, right, 'right-list')}</Grid>
</Grid>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
onClick={() => onSave(right)}
aria-label="Save changes"
>
Save Changes
</Button>
</Box>
</Box>
);
}
Integration with Form Libraries
Let's see how to integrate our TransferList with popular form libraries like Formik:
import React from 'react';
import { Formik, Form, Field } from 'formik';
import * as Yup from 'yup';
import { Button, Box, Typography } from '@mui/material';
// Create a formik-compatible TransferList field
const FormikTransferList = ({
field, // { name, value, onChange, onBlur }
form, // { touched, errors, setFieldValue }
leftItems,
rightItems,
leftLabel,
rightLabel,
...props
}) => {
const [left, setLeft] = useState(leftItems.filter(item =>
!field.value.some(val => val.id === item.id)
));
const [right, setRight] = useState(field.value);
const [checked, setChecked] = useState([]);
// Update when field value changes externally
useEffect(() => {
setRight(field.value);
setLeft(leftItems.filter(item =>
!field.value.some(val => val.id === item.id)
));
}, [field.value, leftItems]);
// Handle moving items between lists
const handleCheckedRight = () => {
const itemsToMove = intersection(checked, left);
const newRight = [...right, ...itemsToMove];
setRight(newRight);
setLeft(not(left, itemsToMove));
setChecked(not(checked, itemsToMove));
// Update formik field value
form.setFieldValue(field.name, newRight);
};
const handleCheckedLeft = () => {
const itemsToMove = intersection(checked, right);
const newRight = not(right, itemsToMove);
setLeft([...left, ...itemsToMove]);
setRight(newRight);
setChecked(not(checked, itemsToMove));
// Update formik field value
form.setFieldValue(field.name, newRight);
};
// ... rest of TransferList implementation
return (
<Box>
{/* Transfer list UI */}
{form.touched[field.name] && form.errors[field.name] && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{form.errors[field.name]}
</Typography>
)}
</Box>
);
};
// Example usage with Formik
function TeamAssignmentForm({ allUsers, initialTeamMembers, onSubmit }) {
const validationSchema = Yup.object({
teamMembers: Yup.array()
.min(1, 'Please assign at least one team member')
.required('Team members are required')
});
return (
<Formik
initialValues={{
teamMembers: initialTeamMembers
}}
validationSchema={validationSchema}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<Typography variant="h5" gutterBottom>
Team Assignment
</Typography>
<Field
name="teamMembers"
component={FormikTransferList}
leftItems={allUsers}
rightItems={initialTeamMembers}
leftLabel="Available Users"
rightLabel="Team Members"
/>
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
type="submit"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving...' : 'Save Team'}
</Button>
</Box>
</Form>
)}
</Formik>
);
}
Best Practices and Common Issues
Let's cover some best practices and common issues when working with the TransferList pattern.
Best Practices
-
Provide Clear Labels: Always use descriptive labels for both lists and buttons to make the purpose clear.
-
Include Search for Large Lists: Add a search field when dealing with large lists to help users find items quickly.
-
Group Related Items: Organize items into categories or groups for better user experience.
-
Show Item Counts: Display the number of items in each list and how many are selected.
-
Preserve Selection State: When filtering items, preserve the selection state for better user experience.
-
Use Meaningful Icons: Add icons to represent the type of items being transferred.
-
Provide Visual Feedback: Use animations or transitions when items are moved between lists.
-
Optimize for Performance: Use virtualization for large lists and memoization for frequent renders.
Common Issues and Solutions
Issue 1: Performance Problems with Large Lists
Solution:
// Use virtualization and pagination
import { FixedSizeList } from 'react-window';
// Paginated TransferList
function PaginatedTransferList({ items, pageSize = 50 }) {
const [page, setPage] = useState(0);
const [displayedItems, setDisplayedItems] = useState([]);
useEffect(() => {
const start = page * pageSize;
const end = start + pageSize;
setDisplayedItems(items.slice(start, end));
}, [items, page, pageSize]);
// Pagination controls and virtualized list implementation
}
Issue 2: Handling Complex Data Structures
Solution:
// Use proper key functions and comparators
const isItemEqual = (a, b) => a.id === b.id;
const not = (a, b) => a.filter(value => !b.some(item => isItemEqual(value, item)));
const intersection = (a, b) => a.filter(value => b.some(item => isItemEqual(value, item)));
// Use these functions in your transfer handlers
Issue 3: Inconsistent State After Updates
Solution:
// Use functional updates to ensure state consistency
const handleCheckedRight = () => {
const itemsToMove = intersection(checked, left);
setRight(prevRight => [...prevRight, ...itemsToMove]);
setLeft(prevLeft => not(prevLeft, itemsToMove));
setChecked(prevChecked => not(prevChecked, itemsToMove));
};
Issue 4: Accessibility Issues
Solution:
// Ensure keyboard navigation works properly
<ListItem
role="option"
button
onClick={handleToggle(item)}
onKeyPress={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleToggle(item)();
e.preventDefault();
}
}}
aria-selected={isChecked}
tabIndex={0}
>
{/* ListItem content */}
</ListItem>
Issue 5: Form Integration Issues
Solution:
// Make sure to properly sync with form state
useEffect(() => {
// When external form state changes, update the component
if (!isEqual(formValue, right)) {
const newRight = [...formValue];
const newLeft = allItems.filter(item =>
!formValue.some(val => val.id === item.id)
);
setRight(newRight);
setLeft(newLeft);
}
}, [formValue]);
Wrapping Up
In this article, we've explored how to use MUI's TransferList pattern to build robust item assignment interfaces. We covered everything from basic implementation to advanced use cases like team member assignment and permission management.
The TransferList pattern is incredibly versatile and can be adapted to many different scenarios where users need to select items from a larger set. By customizing the appearance, enhancing accessibility, and optimizing performance, you can create a seamless user experience that makes complex assignment tasks intuitive and efficient.
Remember to consider your specific use case and user needs when implementing a TransferList. Whether you're building a simple tag selector or a complex permission system, the principles and techniques we've covered will help you create a polished, user-friendly interface.