frontend_nyla/documentation/sidebar.md
2025-06-27 12:43:07 +02:00

11 KiB

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:

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

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

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:

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

interface SidebarItemType {
  id: string;
  name: string;
  link?: string;
  icon?: React.ComponentType;
  submenu?: SidebarItemType[];
}

Implementation

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)

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)

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)

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:
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 };
  1. Update Context:
export interface SidebarContext {
  openItemId: string | null;
  activePath: string;
  isPinned: boolean;  // New context property
}
  1. Add New States:
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
    },
  },
},
  1. Update Hook:
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

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

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:

const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);

After:

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