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
| Input | Type | Default | Description |
|---|---|---|---|
width | string | '280px' | Menu width in any CSS unit |
mobileBreakpoint | number | 768 | Breakpoint (px) for mobile/desktop mode |
MenuService API
Signals (Read-only)
| Signal | Type | Description |
|---|---|---|
isOpen | Signal<boolean> | Whether menu is currently open |
isPinned | Signal<boolean> | Whether menu is pinned (desktop only) |
mode | Signal<'mobile' | 'desktop'> | Current responsive mode |
showOverlay | Signal<boolean> | Whether overlay is visible (mobile only) |
Methods
| Method | Parameters | Returns | Description |
|---|---|---|---|
toggle() | - | void | Toggle menu open/closed |
open() | - | void | Open menu |
close() | - | void | Close menu |
pin() | - | void | Pin menu (desktop only) |
unpin() | - | void | Unpin 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
| Key | Action |
|---|---|
ESC | Close menu |
Tab | Navigate forward (trapped) |
Shift + Tab | Navigate 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:
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
Menu Not Visible
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>