463 lines
11 KiB
Markdown
463 lines
11 KiB
Markdown
# Sidebar Component Documentation
|
|
|
|
## Architecture
|
|
|
|
```
|
|
src/
|
|
├── components/Sidebar/
|
|
│ ├── Sidebar.tsx # Main container component
|
|
│ ├── SidebarItem.tsx # Individual menu item
|
|
│ ├── SidebarSubmenu.tsx # Submenu component
|
|
│ └── SidebarUser.tsx # User info display
|
|
├── machines/
|
|
│ └── sidebarMachine.ts # State machine definition
|
|
└── hooks/machines/
|
|
│ └── useSidebarMachine.ts # React hook integration
|
|
└── contexts/
|
|
└── SidebarData.tsx # Navigation data provider
|
|
```
|
|
|
|
## State Machine Explanation
|
|
|
|
### Core Concepts
|
|
|
|
**Events** - Things that can happen:
|
|
- `TOGGLE_ITEM` - User clicks a menu item
|
|
- `CLOSE_ALL` - Close all submenus
|
|
- `NAVIGATE` - User navigates to a new route
|
|
|
|
**Context** - Data the machine remembers:
|
|
```typescript
|
|
interface SidebarContext {
|
|
openItemId: string | null; // Which submenu is open (only one allowed)
|
|
activePath: string; // Current route for highlighting
|
|
}
|
|
```
|
|
|
|
**States** - Possible configurations:
|
|
- `collapsed` - No submenus are open
|
|
- `expanded` - One submenu is open
|
|
|
|
### State Transitions
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> collapsed
|
|
collapsed --> expanded : TOGGLE_ITEM(id)
|
|
expanded --> collapsed : TOGGLE_ITEM(same_id)
|
|
expanded --> expanded : TOGGLE_ITEM(different_id)
|
|
collapsed --> collapsed : CLOSE_ALL
|
|
expanded --> collapsed : CLOSE_ALL
|
|
```
|
|
|
|
### Machine Definition
|
|
|
|
```typescript
|
|
export const sidebarMachine = setup({
|
|
types: {
|
|
context: {} as SidebarContext,
|
|
events: {} as SidebarEvent,
|
|
},
|
|
}).createMachine({
|
|
id: 'sidebar',
|
|
initial: 'collapsed',
|
|
context: {
|
|
openItemId: null,
|
|
activePath: '/',
|
|
},
|
|
states: {
|
|
collapsed: {
|
|
on: {
|
|
TOGGLE_ITEM: {
|
|
target: 'expanded',
|
|
actions: assign({
|
|
openItemId: ({ event }) => event.itemId,
|
|
}),
|
|
},
|
|
},
|
|
},
|
|
expanded: {
|
|
on: {
|
|
TOGGLE_ITEM: [
|
|
{
|
|
// Same item clicked - close it
|
|
guard: ({ context, event }) => context.openItemId === event.itemId,
|
|
target: 'collapsed',
|
|
actions: assign({ openItemId: null }),
|
|
},
|
|
{
|
|
// Different item clicked - switch to it
|
|
target: 'expanded',
|
|
actions: assign({
|
|
openItemId: ({ event }) => event.itemId,
|
|
}),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
## React Hook Integration
|
|
|
|
### useSidebarMachine Hook
|
|
|
|
The `useSidebarMachine` hook bridges XState with React components:
|
|
|
|
```typescript
|
|
export const useSidebarMachine = () => {
|
|
// 1. Create machine actor
|
|
const [state, send] = useActor(sidebarMachine);
|
|
|
|
// 2. Sync with React Router
|
|
const location = useLocation();
|
|
useEffect(() => {
|
|
send({ type: 'NAVIGATE', path: location.pathname });
|
|
}, [location.pathname, send]);
|
|
|
|
// 3. Return easy-to-use API
|
|
return {
|
|
// State queries
|
|
hasOpenSubmenu: sidebarState.hasOpenSubmenu,
|
|
openItemId: sidebarState.openItemId,
|
|
currentState: state.value,
|
|
|
|
// Actions
|
|
toggleItem: (itemId: string) => send({ type: 'TOGGLE_ITEM', itemId }),
|
|
closeAll: () => send({ type: 'CLOSE_ALL' }),
|
|
isItemOpen: (itemId: string) => sidebarState.isItemOpen(itemId),
|
|
isItemActive: (itemPath?: string) => location.pathname === itemPath,
|
|
};
|
|
};
|
|
```
|
|
|
|
### Hook Benefits
|
|
|
|
1. **Encapsulation**: State machine logic is hidden from components
|
|
2. **Type Safety**: TypeScript ensures correct usage
|
|
3. **Automatic Sync**: Route changes update the machine automatically
|
|
4. **Simple API**: Components only need simple functions
|
|
|
|
## Data Loading (SidebarData)
|
|
|
|
### Structure
|
|
|
|
```typescript
|
|
interface SidebarItemType {
|
|
id: string;
|
|
name: string;
|
|
link?: string;
|
|
icon?: React.ComponentType;
|
|
submenu?: SidebarItemType[];
|
|
}
|
|
```
|
|
|
|
### Implementation
|
|
|
|
```typescript
|
|
const useSidebarData = () => {
|
|
const { t } = useLanguage();
|
|
|
|
return useMemo(() => [
|
|
{
|
|
id: '1',
|
|
name: t('nav.team'),
|
|
link: '/team-bereich',
|
|
icon: MdOutlineWorkOutline,
|
|
},
|
|
{
|
|
id: '2',
|
|
name: t('nav.dashboard'),
|
|
link: '/dashboard',
|
|
icon: LuTicket,
|
|
submenu: [ // Optional submenu
|
|
{ id: '2-1', name: 'Analytics', link: '/dashboard/analytics' },
|
|
{ id: '2-2', name: 'Reports', link: '/dashboard/reports' },
|
|
],
|
|
},
|
|
], [t]);
|
|
};
|
|
```
|
|
|
|
### Key Features
|
|
|
|
- **Internationalization**: Uses language context for translations
|
|
- **Memoization**: Prevents unnecessary re-renders
|
|
- **Flexible Structure**: Supports nested submenus
|
|
- **Icon Support**: React component icons
|
|
|
|
## Component Interactions
|
|
|
|
### Sidebar (Main Container)
|
|
|
|
```typescript
|
|
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
|
|
const sidebarMachine = useSidebarMachine();
|
|
|
|
return (
|
|
<div className={styles.sidebarContainer}>
|
|
{/* Logo and User sections */}
|
|
|
|
<div className={styles.sidebar}>
|
|
{data.map(item => (
|
|
<SidebarItem
|
|
key={item.id}
|
|
item={item}
|
|
isOpen={sidebarMachine.isItemOpen(item.id)}
|
|
onToggle={() => sidebarMachine.toggleItem(item.id)}
|
|
isActive={sidebarMachine.isItemActive(item.link)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Responsibilities:**
|
|
- Initialize state machine
|
|
- Pass state and actions to children
|
|
- Handle layout and styling
|
|
|
|
### SidebarItem (Individual Menu Item)
|
|
|
|
```typescript
|
|
const SidebarItem: React.FC<SidebarItemProps> = ({
|
|
item,
|
|
isOpen,
|
|
onToggle,
|
|
isActive
|
|
}) => {
|
|
const hasSubItems = item.submenu && item.submenu.length > 0;
|
|
|
|
const toggleSubmenu = (e: React.MouseEvent) => {
|
|
if (hasSubItems) {
|
|
e.preventDefault();
|
|
onToggle(); // Call parent's toggle function
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={styles.menu}>
|
|
<li className={`${isActive ? styles.active : ""}`}>
|
|
{hasSubItems ? (
|
|
<a href="#" onClick={toggleSubmenu}>
|
|
{item.name}
|
|
<IoIosArrowDown className={`${isOpen ? styles.rotated : ''}`} />
|
|
</a>
|
|
) : (
|
|
<Link to={item.link || "#"}>{item.name}</Link>
|
|
)}
|
|
</li>
|
|
{hasSubItems && <SidebarSubmenu item={item} isOpen={isOpen} />}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Key Changes from Original:**
|
|
- ❌ **Removed**: `useState` for local state
|
|
- ✅ **Added**: Props from parent state machine
|
|
- ✅ **Benefit**: No independent state management
|
|
|
|
### SidebarSubmenu (Nested Menu)
|
|
|
|
```typescript
|
|
const SidebarSubmenu: React.FC<SidebarSubmenuProps> = ({ item, isOpen }) => {
|
|
return (
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: "auto", opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
>
|
|
{/* Submenu items */}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
};
|
|
```
|
|
|
|
**Responsibilities:**
|
|
- Render submenu when `isOpen` is true
|
|
- Handle animations with Framer Motion
|
|
- Manage text overflow behavior
|
|
|
|
## Data Flow
|
|
|
|
```
|
|
1. User clicks menu item
|
|
↓
|
|
2. SidebarItem calls onToggle()
|
|
↓
|
|
3. onToggle sends TOGGLE_ITEM event to machine
|
|
↓
|
|
4. Machine transitions states and updates context
|
|
↓
|
|
5. Hook returns new state
|
|
↓
|
|
6. Sidebar re-renders with new isOpen values
|
|
↓
|
|
7. SidebarSubmenu shows/hides based on isOpen
|
|
```
|
|
|
|
## Adding New States
|
|
|
|
### Example: Adding "Pinned" State
|
|
|
|
1. **Update Events:**
|
|
```typescript
|
|
export type SidebarEvent =
|
|
| { type: 'TOGGLE_ITEM'; itemId: string }
|
|
| { type: 'PIN_SIDEBAR' } // New event
|
|
| { type: 'UNPIN_SIDEBAR' } // New event
|
|
| { type: 'CLOSE_ALL' }
|
|
| { type: 'NAVIGATE'; path: string };
|
|
```
|
|
|
|
2. **Update Context:**
|
|
```typescript
|
|
export interface SidebarContext {
|
|
openItemId: string | null;
|
|
activePath: string;
|
|
isPinned: boolean; // New context property
|
|
}
|
|
```
|
|
|
|
3. **Add New States:**
|
|
```typescript
|
|
states: {
|
|
collapsed: {
|
|
on: {
|
|
PIN_SIDEBAR: 'pinnedCollapsed',
|
|
// ... existing transitions
|
|
},
|
|
},
|
|
expanded: {
|
|
on: {
|
|
PIN_SIDEBAR: 'pinnedExpanded',
|
|
// ... existing transitions
|
|
},
|
|
},
|
|
pinnedCollapsed: { // New state
|
|
on: {
|
|
UNPIN_SIDEBAR: 'collapsed',
|
|
TOGGLE_ITEM: 'pinnedExpanded',
|
|
},
|
|
},
|
|
pinnedExpanded: { // New state
|
|
on: {
|
|
UNPIN_SIDEBAR: 'expanded',
|
|
// ... similar to expanded
|
|
},
|
|
},
|
|
},
|
|
```
|
|
|
|
4. **Update Hook:**
|
|
```typescript
|
|
return {
|
|
// ... existing returns
|
|
isPinned: state.matches('pinnedCollapsed') || state.matches('pinnedExpanded'),
|
|
pinSidebar: () => send({ type: 'PIN_SIDEBAR' }),
|
|
unpinSidebar: () => send({ type: 'UNPIN_SIDEBAR' }),
|
|
};
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### State Machine Design
|
|
|
|
1. **Keep States Simple**: Each state should represent a distinct UI mode
|
|
2. **Use Guards Wisely**: For conditional transitions based on context
|
|
3. **Minimize Context**: Only store data that affects behavior
|
|
4. **Clear Event Names**: Use descriptive, action-oriented names
|
|
|
|
### Component Architecture
|
|
|
|
1. **Single Source of Truth**: State machine holds all navigation state
|
|
2. **Props Down**: Pass state and actions as props
|
|
3. **Events Up**: Send events to the machine, not direct state updates
|
|
4. **Separation of Concerns**: Components handle UI, machine handles logic
|
|
|
|
### Performance
|
|
|
|
1. **Memoize Data**: Use `useMemo` for sidebar data
|
|
2. **Avoid Deep Objects**: Keep context flat when possible
|
|
3. **Selective Subscriptions**: Only subscribe to needed state slices
|
|
|
|
## Debugging
|
|
|
|
### Development Tools
|
|
|
|
1. **Debug Props**: Hook returns `_debugState` and `_debugSend`
|
|
2. **State Logging**: Log state changes in development
|
|
3. **XState DevTools**: Use `@xstate/inspect` for visual debugging
|
|
|
|
### Common Issues
|
|
|
|
1. **Multiple Items Open**: Check guard conditions in TOGGLE_ITEM
|
|
2. **State Not Updating**: Ensure events are sent correctly
|
|
3. **Route Sync Issues**: Verify NAVIGATE event is sent on route change
|
|
|
|
## Testing
|
|
|
|
### Unit Testing State Machine
|
|
|
|
```typescript
|
|
import { sidebarMachine } from '../machines/sidebarMachine';
|
|
|
|
test('should open submenu when item toggled', () => {
|
|
const state = sidebarMachine.transition('collapsed', {
|
|
type: 'TOGGLE_ITEM',
|
|
itemId: 'menu-1',
|
|
});
|
|
|
|
expect(state.value).toBe('expanded');
|
|
expect(state.context.openItemId).toBe('menu-1');
|
|
});
|
|
```
|
|
|
|
### Integration Testing
|
|
|
|
```typescript
|
|
import { render, fireEvent } from '@testing-library/react';
|
|
import { SidebarWithData } from '../components/Sidebar';
|
|
|
|
test('should close submenu when same item clicked twice', () => {
|
|
const { getByText } = render(<SidebarWithData />);
|
|
const menuItem = getByText('Dashboard');
|
|
|
|
fireEvent.click(menuItem); // Open
|
|
fireEvent.click(menuItem); // Close
|
|
|
|
expect(/* submenu is closed */).toBeTruthy();
|
|
});
|
|
```
|
|
|
|
## Migration Guide
|
|
|
|
### From useState to State Machine
|
|
|
|
**Before:**
|
|
```typescript
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const toggle = () => setIsOpen(!isOpen);
|
|
```
|
|
|
|
**After:**
|
|
```typescript
|
|
const { isItemOpen, toggleItem } = useSidebarMachine();
|
|
// Use: isItemOpen(itemId) and toggleItem(itemId)
|
|
```
|
|
|
|
### Benefits of Migration
|
|
|
|
1. **Predictable State**: No more impossible state combinations
|
|
2. **Centralized Logic**: All sidebar behavior in one place
|
|
3. **Better Testing**: State machine logic is easily testable
|
|
4. **Type Safety**: TypeScript prevents invalid transitions
|
|
5. **Debugging**: Clear state visualization and logging
|