Sidebar Module

Multi-instance sidebar navigation with intelligent toggle directives

Sidebar Module

Multi-instance sidebar navigation system with intelligent toggle directives for the GoGrab Space application.

This section documents the sidebar module that powers multiple independent sidebar instances across different layouts, each with its own state management and toggle controls.


🎯 Quick Start

New to Sidebar Module?

The sidebar system supports two independent sidebar instances:

  1. Main-Layout Sidebar ("Tools" Navigation) - Controlled by top-nav button
  2. Main-Shell Sidebar ("Platform Navigation") - Controlled by breadcrumb button

Architecture Overview

Two Sidebar Instances

1. Main-Layout Sidebar ("Tools" Navigation)

2. Main-Shell Sidebar ("Platform Navigation")


Key Components

1. SidebarNavToggleDirective

Location: libs/libs-ui/ux-ui-core/src/lib/directives/sidebar-nav-toggle/

Purpose: Reusable directive for toggling sidebar navigation with instance-specific targeting.

Features:

Basic Usage:

<button sidebarNavToggle
        [sidebarNavToggleConfig]="{
          targetId: 'main-shell',
          behavior: 'restore',
          styling: {
            icon: 'fas fa-bars',
            text: 'Navigation',
            showText: true
          }
        }"
        (navigationStateChange)="onNavigationStateChange($event)">
</button>

Configuration Interface:

export interface SidebarNavToggleConfig {
  targetId?: string;           // Instance ID (default: 'main-shell')
  behavior?: 'restore' | 'cycle' | 'toggle';
  enabled?: boolean;
  styling?: {
    icon?: string;
    text?: string;
    showText?: boolean;
    buttonClasses?: string;
  };
  accessibility?: {
    ariaLabel?: string;
    tooltip?: string;
  };
}

Behavior Types:

  1. restore - Shows sidebar when hidden (one-way toggle)
  2. cycle - Cycles through: visible → collapsed → hidden → visible
  3. toggle - Toggles between visible and hidden only

2. Sidebar-Nav Component

Location: modules/structural/sidebar-nav/

Key Feature: Smart state management that:

Implementation:

// Persistence effect - only saves if NOT externally controlled
this.statePersistenceEffectRef = effect(() => {
  const cfg = this.effectiveConfig();
  const externalState = this.navigationState();

  // Only persist if persistState is enabled AND we're NOT controlled by parent
  if (cfg.threeState?.persistState && externalState === undefined) {
    const state = this._state(); // Use internal state
    const storageKey = cfg.threeState?.storageKey || 'sidebar-nav:state';
    localStorage.setItem(storageKey, JSON.stringify({
      state,
      timestamp: Date.now()
    }));
  }
});

Usage:

<!-- Parent-controlled (no self-persistence) -->
<module-sidebar-nav
  [navigationState]="parentNavigationState()"
  (navigationStateChange)="onNavigationStateChange($event)"
  [config]="sidebarConfig()">
</module-sidebar-nav>

<!-- Self-managed (with persistence) -->
<module-sidebar-nav
  [config]="{ threeState: { persistState: true } }">
</module-sidebar-nav>

3. ResponsiveHeightDirective

Location: libs/libs-ui/ux-ui-core/src/lib/directives/responsive-height/

Purpose: Dynamically adjusts element height based on top-nav state.

Usage:

<div class="structure-layout-redesigned"
     responsiveHeight
     [responsiveHeight]="{
       property: 'min-height',
       visibleOffset: 396,
       hiddenOffset: 316
     }">
</div>

Configuration:

export interface ResponsiveHeightConfig {
  property?: 'height' | 'min-height' | 'max-height' | 'width' | 'min-width' | 'max-width';
  baseValue?: string;          // Default: '100vh'
  visibleOffset?: number;      // Pixels to subtract when top-nav visible
  hiddenOffset?: number;       // Pixels to subtract when top-nav hidden
  storageKey?: string;         // Default: 'gograb-space:top-nav:state'
  reactive?: boolean;          // Default: true
}

State Management

Storage Keys (Instance-Specific)

// Main-layout sidebar
'gograb-space:navigation:state:main-layout'

// Main-shell sidebar
'gograb-space:navigation:state:main-shell'

// Top navigation (global)
'gograb-space:top-nav:state'

Why Instance-Specific?


Event Flow Diagrams

Main-Shell Sidebar Toggle (Breadcrumb Button)

┌─────────────────────────────────────────────────────────────┐
│ User clicks breadcrumb sidebar toggle button               │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Directive saves to:                                      │
│    gograb-space:navigation:state:main-shell                 │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Directive dispatches:                                    │
│    CustomEvent('navigation-state-changed')                  │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Directive emits:                                         │
│    navigationStateChange event                              │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Main-layout receives event                               │
│    → onSidebarNavToggle() forwards to main-shell storage    │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Main-shell listens to:                                   │
│    - CustomEvent('navigation-state-changed')                │
│    - StorageEvent (cross-tab)                               │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Main-shell updates: navigationState signal              │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Sidebar-nav receives: updated [navigationState] input   │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 8. Sidebar-nav reacts: updates display (NO self-persist)   │
└─────────────────────────────────────────────────────────────┘

Main-Layout Sidebar Toggle (Top-Nav Button)

┌─────────────────────────────────────────────────────────────┐
│ User clicks top-nav control button                         │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. Top-nav calls: toggleNavigation()                       │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. Top-nav emits: navigationToggle event                   │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Main-layout receives: onNavigationToggle()              │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Main-layout calls: setNavigationState()                 │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 5. Saves to: gograb-space:navigation:state:main-layout     │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 6. Updates: navigationState signal                         │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 7. Sidebar-nav receives: updated [navigationState] input   │
└─────────────────────┬───────────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────────┐
│ 8. Sidebar-nav reacts: updates display                     │
└─────────────────────────────────────────────────────────────┘

Implementation Details

Main-Shell Component

File: apps/gograb-space/src/app/core/layout/main-shell/main-shell.component.ts

export class MainShellComponent implements OnInit, OnDestroy {
  private storageKey = 'gograb-space:navigation:state:main-shell';
  readonly navigationState = signal<NavigationState>(this.loadInitialState());

  constructor() {
    // Listen to StorageEvent (cross-tab)
    window.addEventListener('storage', this.handleStorageChange);

    // Listen to custom event (same-tab)
    window.addEventListener('navigation-state-changed', this.handleCustomEvent);
  }

  onNavigationStateChange(newState: NavigationState): void {
    console.log('🔄 [MainShell] Navigation state changed:', newState);
    this.navigationState.set(newState);

    // Save to instance-specific storage
    try {
      localStorage.setItem(this.storageKey, JSON.stringify({
        state: newState,
        timestamp: Date.now()
      }));

      // Dispatch custom event for same-tab reactivity
      window.dispatchEvent(new CustomEvent('navigation-state-changed', {
        detail: { state: newState, source: 'main-shell', timestamp: Date.now() }
      }));
    } catch (error) {
      console.warn('⚠️ [MainShell] Failed to save navigation state:', error);
    }
  }

  private loadInitialState(): NavigationState {
    try {
      const stored = localStorage.getItem(this.storageKey);
      if (stored) {
        const { state } = JSON.parse(stored);
        return state;
      }
    } catch (error) {
      console.warn('⚠️ [MainShell] Failed to load initial state:', error);
    }
    return 'visible'; // Default
  }
}

Template: apps/gograb-space/src/app/core/layout/main-shell/main-shell.component.html

<module-sidebar-nav
  [navigationState]="navigationState()"
  (navigationStateChange)="onNavigationStateChange($event)"
  [config]="sidebarConfig()">
</module-sidebar-nav>

Main-Layout Bridge

File: apps/gograb-space/src/app/components/layout/main-layout/main-layout.component.ts

/**
 * Handle top-nav sidebar toggle (main-layout's own sidebar)
 */
onNavigationToggle(newState: NavigationState): void {
  console.log('🔄 [MainLayout] Navigation toggle received:', newState);
  this.setNavigationState(newState);
}

/**
 * Handle sidebar nav toggle from breadcrumb (targets main-shell's sidebar)
 */
onSidebarNavToggle(newState: NavigationState): void {
  console.log('🔄 [MainLayout] Sidebar nav toggle received (for main-shell):', newState);

  // Save to main-shell's storage key so main-shell picks it up
  try {
    const stateInfo = {
      state: newState,
      timestamp: Date.now()
    };
    localStorage.setItem('gograb-space:navigation:state:main-shell', JSON.stringify(stateInfo));

    // Dispatch custom event for main-shell to pick up immediately
    if (typeof window !== 'undefined') {
      window.dispatchEvent(new CustomEvent('navigation-state-changed', {
        detail: { state: newState, source: 'main-layout-breadcrumb', timestamp: Date.now() }
      }));
    }
  } catch (error) {
    console.warn('[MainLayout] Failed to save main-shell navigation state:', error);
  }
}

Template: apps/gograb-space/src/app/components/layout/main-layout/main-layout.component.html

<lib-breadcrumb
  [config]="{ /* ... */ }"
  [topNavToggleConfig]="{ /* ... */ }"
  (topNavStateChange)="onTopNavToggle($event)"
  (navigationStateChange)="onSidebarNavToggle($event)">
</lib-breadcrumb>

Breadcrumb Integration

File: libs/libs-ui/ux-ui-core/src/lib/breadcrumb/components/breadcrumb.component.ts

export class BreadcrumbComponent {
  readonly _sidebarNavToggleConfig = input<Partial<SidebarNavToggleConfig>>({});

  // Explicitly target main-shell's sidebar
  readonly sidebarNavToggleConfigValue = computed(() => ({
    ...this._sidebarNavToggleConfig(),
    targetId: 'main-shell', // Explicit targeting
  }));
}

Template: libs/libs-ui/ux-ui-core/src/lib/breadcrumb/components/breadcrumb.component.html

<div class="context-breadcrumb" role="navigation">
  <!-- Sidebar Navigation Toggle Button - Left side -->
  <button sidebarNavToggle
          class="breadcrumb-sidebar-toggle-btn"
          [sidebarNavToggleConfig]="sidebarNavToggleConfigValue()"
          (navigationStateChange)="onNavigationStateChange($event)">
  </button>

  <!-- Breadcrumb Navigation -->
  <nav class="breadcrumb-nav">
    <!-- breadcrumb items -->
  </nav>

  <!-- Top Nav Toggle -->
  @if (shouldShowMetrics() && currentFeature()) {
    <div class="feature-info">
      <button topNavToggle [topNavToggleConfig]="topNavToggleConfig()">
      </button>
    </div>
  }
</div>

Reactivity System

Event-Based Architecture (No Polling!)

The system uses pure event-based reactivity with NO continuous polling:

  1. StorageEvent - Cross-tab/window communication
  2. CustomEvent - Same-tab communication
  3. Angular Events - Component-to-component communication
  4. Signals - Reactive state updates

Exception: Top-Nav Toggle Button

The top-nav-toggle directive uses throttled polling (100ms) only for reactive UI updates:

ngOnInit(): void {
  // Trigger recomputation for reactive button visibility
  setInterval(() => {
    this.stateRefresh.update(v => v + 1);
  }, 100);
}

Why? Button visibility and styling need to react to external state changes immediately for smooth UX.


Testing Checklist

✅ Functionality Tests

✅ Performance Tests

✅ UX Tests


Common Issues & Solutions

Issue: Button Toggles Wrong Sidebar

Cause: Missing or incorrect targetId in directive config.

Solution: Explicitly set targetId:

sidebarNavToggleConfig: {
  targetId: 'main-shell' // or 'main-layout'
}

Issue: Button Not Reacting to State Changes

Cause: Directive not listening to custom events.

Solution: Ensure setupReactivity() listens to both StorageEvent and CustomEvent:

setupReactivity(): void {
  window.addEventListener('storage', this.handleStorageChange);
  window.addEventListener('navigation-state-changed', this.handleCustomEvent);
}

Issue: Sidebar Persisting When Externally Controlled

Cause: Sidebar-nav persisting even with external state.

Solution: Check for external state before persisting:

effect(() => {
  const externalState = this.navigationState();

  // Only persist if NOT externally controlled
  if (cfg.threeState?.persistState && externalState === undefined) {
    localStorage.setItem(storageKey, JSON.stringify({ state, timestamp }));
  }
});

Issue: Infinite Storage Reads

Cause: Polling instead of events, or effect triggering itself.

Solution: Use event-based reactivity and guard effects:

effect(() => {
  const externalState = this.navigationState();

  // Guard: only run if NOT externally controlled
  if (externalState === undefined) {
    // Safe to persist
  }
});

Issue: InvalidCharacterError with Icons

Cause: Adding icon string with spaces (e.g., "fas fa-bars") as single class.

Solution: Split icon string and add each class individually:

private updateButtonContent(): void {
  const icon = this.buttonIcon();
  if (icon) {
    // Split icon string and add each class separately
    const iconClasses = icon.split(' ').filter(c => c.trim());
    iconClasses.forEach(cls => {
      this.renderer.addClass(iconEl, cls);
    });
  }
}

Files Modified

Core Directives

Layout Components

UI Components

Modules


Future Improvements

1. Storage System Integration

Replace direct localStorage with @storage-system API for:

2. Directive Registry

Create central registry for all sidebar instances:

3. Animation Sync

Coordinate animations across multiple sidebars:

4. Smart Defaults

Auto-detect which sidebar to toggle based on:

5. Keyboard Shortcuts

Global shortcuts for each sidebar instance:


Architecture Benefits

1. Separation of Concerns

2. Reusability

3. Performance

4. Maintainability

5. Scalability


Status

Status: ✅ PRODUCTION READY Last Updated: November 12, 2025 Contributors: AI Assistant, User

What Works

Known Limitations

Next Steps

  1. Integrate with @storage-system
  2. Create directive registry
  3. Add keyboard shortcuts
  4. Implement smart defaults
  5. Add animation synchronization

For detailed implementation examples, see the source files listed above.