Angular directives `routerLink` and `routerLinkActive` are frequently used in Angular applications for menus, toolbar based menus and a number of additional application cases.
The combination of the directives `routerLink` and `routerLinkActive` is usually used to highlight a button, link or menu entry depending on the current route. It works perfectly as long as you have a 1:1 relation between menu entry (or menu button) and a route in your application.
But if your menu entry or menu button covers one or more routing areas, the application of Angular directives `routerLink` and `routerLinkActive` may become difficult or not possible.
This is where the custom directive `AppAreaDirective` may help a lot.
Point is that the routerLink
-directive is required to define the link or links, but also causes routing actions on clicking the tagged element.
What we actually need is a directive monitoring router navigation and if the current route starts with one or more fragments, the related native element shall be tagged with additional CSS classes. If not, the named CSS classes need to be removed.
Given that, the code of the directive AppAreaDirective
is:
import { Directive, ElementRef, Input, OnInit } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { AppAreaNavigationService } from '../services/app-area-navigation.service';
import { tap } from 'rxjs';
@UntilDestroy()
@Directive({
selector: '[appArea]',
standalone: true
})
export class AppAreaDirective implements OnInit {
/**
* App areas monitored by this directive
*/
public appAreas: string[] = [];
/**
* Classes to be applied when an app area is active
*/
public appAreaActiveClasses: string[] = [];
/**
* Setter for app areas
* - Determines the app areas monitored by this directive
* - Accepts either a single app area or an array of app areas
* - App areas define routing paths to be monitored
*/
@Input({ required: true })
set appArea(appAreas: string[] | string) {
if (appAreas == null) {
this.appAreas = [];
} else {
this.appAreas = Array.isArray(appAreas) ? appAreas : [appAreas];
}
}
/**
* Setter for app area active class
* - Determines the class to be applied when an app area is active
* - Accepts either a single class or an array of classes
*/
@Input({ required: true })
set appAreaActive(appAreaActiveClasses: string[] | string) {
if (appAreaActiveClasses == null) {
this.appAreaActiveClasses = [];
} else {
this.appAreaActiveClasses = Array.isArray(appAreaActiveClasses) ? appAreaActiveClasses : [appAreaActiveClasses];
}
}
/**
* Constructor providing DI
* @param el - ElementRef providing access to the host element
*/
constructor(
private readonly el: ElementRef,
private readonly appAreaNavigationService: AppAreaNavigationService,
) { }
/**
* Initialization method
* - Subscribes to the lastNavigationEnd$ behavior subject
* - Applies the app area active classes when the current route matches an app area
*/
ngOnInit(): void {
this.appAreaNavigationService.lastNavigationEnd$.pipe(
untilDestroyed(this),
tap((navigationEnd) => {
if (navigationEnd == null) {
return;
}
const url = navigationEnd.urlAfterRedirects;
const isActive = this.appAreas.some((appArea) => url.startsWith(appArea));
if (isActive) {
this.el.nativeElement.classList.add(...this.appAreaActiveClasses);
} else {
this.el.nativeElement.classList.remove(...this.appAreaActiveClasses);
}
})
).subscribe();
}
}
As you can see, this directive works in a total different way than e.g. the Angular implementations for routerLink
and routerLinkActive
. The current route is monitored and provided by the service AppAreaNavigationService
implemented as:
import { Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { BehaviorSubject, tap } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class AppAreaNavigationService {
/**
* Behavior subject providing the last NavigationEnd event
*/
public lastNavigationEnd$: BehaviorSubject<NavigationEnd | null> = new BehaviorSubject<NavigationEnd | null>(null);
/**
* Constructor providing DI and router monitoring
*/
constructor(
private router: Router,
) {
// monitor the router
this.router.events.pipe(
takeUntilDestroyed(),
tap((event) => {
if (event instanceof NavigationEnd) {
//this.routerHistory.push(event.urlAfterRedirects);
this.lastNavigationEnd$.next(event);
}
})
).subscribe();
}
}
The application in template files is simple, e.g:
<button mat-menu-item [matMenuTriggerFor]="appMenuMobileOther" [appArea]="['/xyz','/zyx]" appAreaActive="app-area-active">More</button>
And of course you will need to define appropriate styles, e.g.:
a.menu-bar-button.mat-mdc-unelevated-button:not(:disabled),
button.menu-bar-button.mat-mdc-unelevated-button:not(:disabled)
{
background-color: mat.get-theme-color($m3-light-theme, primary, 20);
filter: brightness(90%);
&.router-link-active, &.app-area-active {
background-color: mat.get-theme-color($m3-light-theme, primary, 20);
filter: brightness(100%);
}
}
.app-toolbar-menu {
.mat-mdc-menu-item:not([disabled]).router-link-active,
.mat-mdc-menu-item:not([disabled]).app-area-active {
background-color: mat.get-theme-color($m3-light-theme, primary, 20);
color: mat.get-theme-color($m3-light-theme, primary, 100);
svg.mat-mdc-menu-submenu-icon {
color: mat.get-theme-color($m3-light-theme, primary, 100);
}
}
}