Skip to main content

SideMenu Component

A responsive navigation component that adapts to different screen sizes with mobile overlay and desktop pinning modes.

Features

  • Responsive: Mobile overlay (< 768px) and desktop pinning (≥ 768px)
  • Accessible: Focus trap, ESC key handling, ARIA attributes
  • Keyboard Navigation: Tab navigation within menu
  • State Management: Integrated with MenuService (signals-based)
  • Customizable: Slots for header, content, footer
  • Theme Support: PrimeNG theming integration

Installation

npm install @tess-ui-library/shared primeng primeicons

Basic Usage

1. Import Module

import { Component } from '@angular/core';
import { SideMenuComponent } from '@tess-ui-library/shared';
import { MenuService } from '@tess-ui-library/core';

@Component({
selector: 'app-root',
standalone: true,
imports: [SideMenuComponent],
providers: [MenuService],
template: `
<tess-side-menu>
<!-- Navigation content -->
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
</nav>
</tess-side-menu>

<main>
<!-- Main content -->
</main>
`
})
export class AppComponent {}

2. With MenuService Integration

import { Component, inject } from '@angular/core';
import { MenuService } from '@tess-ui-library/core';
import { SideMenuComponent } from '@tess-ui-library/shared';
import { ButtonModule } from 'primeng/button';

@Component({
selector: 'app-layout',
standalone: true,
imports: [SideMenuComponent, ButtonModule],
providers: [MenuService],
template: `
<header>
<button pButton
icon="pi pi-bars"
(click)="menuService.toggle()">
</button>
<h1>My App</h1>
</header>

<tess-side-menu>
<nav class="menu-nav">
@for (item of menuItems; track item.id) {
<a [href]="item.path"
class="menu-item"
(click)="onMenuItemClick()">
<i [class]="item.icon"></i>
<span>{{ item.label }}</span>
</a>
}
</nav>

<div class="menu-footer">
<button pButton
label="Logout"
icon="pi pi-sign-out"
severity="danger">
</button>
</div>
</tess-side-menu>

<main [class.menu-pinned]="menuService.isPinned()">
<!-- Main content with margin adjustment -->
</main>
`,
styles: [`
main.menu-pinned {
margin-left: 280px;
transition: margin-left 0.3s ease-in-out;
}
`]
})
export class LayoutComponent {
menuService = inject(MenuService);

menuItems = [
{ id: '1', label: 'Dashboard', path: '/dashboard', icon: 'pi pi-home' },
{ id: '2', label: 'Analytics', path: '/analytics', icon: 'pi pi-chart-line' },
{ id: '3', label: 'Team', path: '/team', icon: 'pi pi-users' },
{ id: '4', label: 'Settings', path: '/settings', icon: 'pi pi-cog' }
];

onMenuItemClick() {
// In mobile mode, close menu after navigation
if (this.menuService.mode() === 'mobile') {
this.menuService.close();
}
}
}

API Reference

Component Inputs

InputTypeDefaultDescription
widthstring'280px'Menu width in any CSS unit
mobileBreakpointnumber768Breakpoint (px) for mobile/desktop mode

Signals (Read-only)

SignalTypeDescription
isOpenSignal<boolean>Whether menu is currently open
isPinnedSignal<boolean>Whether menu is pinned (desktop only)
modeSignal<'mobile' | 'desktop'>Current responsive mode
showOverlaySignal<boolean>Whether overlay is visible (mobile only)

Methods

MethodParametersReturnsDescription
toggle()-voidToggle menu open/closed
open()-voidOpen menu
close()-voidClose menu
pin()-voidPin menu (desktop only)
unpin()-voidUnpin menu

Responsive Behavior

Mobile Mode (< 768px)

  • Menu opens as overlay (z-index: 1000)
  • Backdrop visible when open (semi-transparent black)
  • Closes when:
    • User clicks backdrop
    • User presses ESC key
    • Navigation item clicked
  • Pinning disabled

Desktop Mode (≥ 768px)

  • Menu can be pinned (persistent sidebar)
  • No backdrop when unpinned
  • When unpinned:
    • Opens as temporary overlay
    • Closes on click outside or ESC
  • When pinned:
    • Persists on screen
    • Main content margin adjusted

Accessibility

Focus Management

// Focus trap: Tab cycles through menu items
@HostListener('keydown', ['$event'])
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Tab') {
this.trapFocus(event);
}
}

ARIA Attributes

<aside 
role="navigation"
[attr.aria-label]="'Main navigation'"
[attr.aria-hidden]="!isOpen()">
<!-- Menu content -->
</aside>

Keyboard Support

KeyAction
ESCClose menu
TabNavigate forward (trapped)
Shift + TabNavigate backward (trapped)

Styling

CSS Custom Properties

:root {
--tess-sidemenu-width: 280px;
--tess-sidemenu-bg: #ffffff;
--tess-sidemenu-border-color: #dee2e6;
--tess-sidemenu-shadow: 0 0 10px rgba(0,0,0,0.1);
--tess-sidemenu-transition: transform 0.3s ease-in-out;
--tess-overlay-bg: rgba(0, 0, 0, 0.5);
}

Custom Classes

// Custom menu item styling
.menu-item {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
color: #495057;
text-decoration: none;
transition: background-color 0.2s;

&:hover {
background-color: #f8f9fa;
}

&.active {
background-color: #e3f2fd;
color: #2196F3;
font-weight: 600;
}

i {
margin-right: 0.75rem;
font-size: 1.25rem;
}
}

Examples

StackBlitz Demo

Try the interactive example:

Open in StackBlitz →

With Router Integration

import { Component, inject } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { MenuService } from '@tess-ui-library/core';
import { SideMenuComponent } from '@tess-ui-library/shared';

@Component({
selector: 'app-layout',
standalone: true,
imports: [RouterModule, SideMenuComponent],
template: `
<tess-side-menu>
<nav>
@for (item of menuItems; track item.path) {
<a [routerLink]="item.path"
routerLinkActive="active"
(click)="onNavigate()">
<i [class]="item.icon"></i>
<span>{{ item.label }}</span>
</a>
}
</nav>
</tess-side-menu>

<main>
<router-outlet />
</main>
`
})
export class LayoutComponent {
private menuService = inject(MenuService);
private router = inject(Router);

menuItems = [
{ path: '/dashboard', label: 'Dashboard', icon: 'pi pi-home' },
{ path: '/profile', label: 'Profile', icon: 'pi pi-user' }
];

onNavigate() {
// Close menu in mobile mode after navigation
if (this.menuService.mode() === 'mobile') {
this.menuService.close();
}
}
}

Best Practices

1. Provide MenuService at Component Level

// ✅ GOOD: MenuService scoped to layout component
@Component({
providers: [MenuService],
// ...
})
export class LayoutComponent {}

// ❌ BAD: Global MenuService in root
export const appConfig: ApplicationConfig = {
providers: [MenuService] // Avoid this
};

2. Adjust Main Content Margin

// Adjust main content when menu is pinned (desktop)
main {
transition: margin-left 0.3s ease-in-out;

&.menu-pinned {
margin-left: var(--tess-sidemenu-width, 280px);
}
}

3. Handle Navigation

// Close menu after navigation in mobile mode
onMenuItemClick() {
if (this.menuService.mode() === 'mobile') {
this.menuService.close();
}

// Navigate
this.router.navigate(['/path']);
}

Testing

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SideMenuComponent } from '@tess-ui-library/shared';
import { MenuService } from '@tess-ui-library/core';

describe('SideMenuComponent', () => {
let component: SideMenuComponent;
let fixture: ComponentFixture<SideMenuComponent>;
let menuService: MenuService;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SideMenuComponent],
providers: [MenuService]
}).compileComponents();

fixture = TestBed.createComponent(SideMenuComponent);
component = fixture.componentInstance;
menuService = TestBed.inject(MenuService);
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should open menu when service toggle called', () => {
menuService.toggle();
expect(menuService.isOpen()).toBe(true);
});

it('should close on ESC key in mobile mode', () => {
menuService.open();
const event = new KeyboardEvent('keydown', { key: 'Escape' });
fixture.nativeElement.dispatchEvent(event);
expect(menuService.isOpen()).toBe(false);
});

it('should trap focus when open', () => {
menuService.open();
const firstFocusable = fixture.nativeElement.querySelector('a');
const lastFocusable = fixture.nativeElement.querySelectorAll('a')[1];

// Tab from last element should focus first
lastFocusable.focus();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab' });
fixture.nativeElement.dispatchEvent(tabEvent);

expect(document.activeElement).toBe(firstFocusable);
});
});

Troubleshooting

Problem: Menu doesn't appear when toggled.

Solution: Ensure MenuService is provided at component level:

@Component({
providers: [MenuService], // ← Required
// ...
})

Content Overlaps Menu

Problem: Main content doesn't adjust when menu is pinned.

Solution: Add margin to main content:

main.menu-pinned {
margin-left: 280px; // Match menu width
}

Focus Trap Not Working

Problem: Tab key doesn't cycle through menu items.

Solution: Ensure focusable elements exist in menu:

<tess-side-menu>
<nav>
<a href="#">Item 1</a> <!-- Focusable -->
<a href="#">Item 2</a> <!-- Focusable -->
</nav>
</tess-side-menu>