Angular router link active nested menu

Viewed 10248

I'm trying to make a nested menu with angular routes.

What I need is to apply class to a nested route if it's active and apply class to a parent component if its child is active.

How do I achieve this? For now I'm doing recursive menu building for ease of use when I need multilevel nesting.

component.html

<a (click)="toggleActive()" [class.active]="isActive" [routerLink]="menuItem.link" *ngIf="menuItem.link; else noLink">
  <i *ngIf="menuItem.faClass" class="fa fa-{{menuItem.faClass}}"></i>
  {{menuItem.name}} <span *ngIf="menuItem.children && menuItem.children.length > 0" class="fa fa-chevron-down"></span>
</a>

<ng-container *ngIf="menuItem.children && menuItem.children.length > 0">
  <ul class="nav child_menu" [class.active]="isActive" routerLinkActive="active"
      *ngFor="let item of menuItem.children">
    <li menu-item [menuItem]="item" (checkActive)="updateActiveState()"></li>
  </ul>
</ng-container>

<ng-template #noLink>
  <a (click)="toggleActive()" [class.active]="isActive">
    <i *ngIf="menuItem.faClass" class="fa fa-{{menuItem.faClass}}"></i>
    {{menuItem.name}} <span *ngIf="menuItem.children && menuItem.children.length > 0" class="fa fa-chevron-down"></span>
  </a>
</ng-template>

component.ts

@Component({
  selector: '[menu-item]',
  templateUrl: './menu-item.component.html',
  styleUrls: ['./menu-item.component.scss'],
  host: {
    '[class.active]': 'hostActive && isActive',
    '[class.active-sm]': 'hostActiveSm && isActive'
  }
})
export class MenuItemComponent implements OnInit, AfterViewInit, OnChanges {

  public menuSize;
  public isActive = false;

  @Input() menuItem: MenuItem;
  @Output() checkActive: EventEmitter<void> = new EventEmitter<void>();
  @ViewChild(MenuItemComponent) menuComponent: MenuItemComponent;

  private hostActive = true;
  private hostActiveSm = false;

  @HostBinding('class') hostClass = this.hostActive && this.isActive ? 'active' : this.hostActiveSm && this.isActive ? 'active-sm' : '';

  constructor(
    private router: Router,
    private uss: UiStateService,
  ) {
  }

  ngOnInit() {
    this.uss.menuSubject.subscribe(msg => {
      this.menuSize = msg;
      if (this.menuSize === MenuSizes.sm) {
        this.hostActive = false;
        this.hostActiveSm = true;
      } else {
        this.hostActive = true;
        this.hostActiveSm = false;
      }
      this.updateActiveState();
    });

    this.router.events.subscribe((e: Event) => {
      if (e instanceof NavigationEnd) {
        this.updateActiveState();
      }
    });
  }

  ngOnChanges() {
  }

  ngAfterViewInit() {
    this.updateActiveState();
  }

  public toggleActive(): void {
    this.isActive = !this.isActive;
    // if (this.menuComponent) {
    //   this.menuComponent.isActive = true;
    // }
  }

  private updateActiveState(): void {
    // reset previous state
    this.isActive = false;
    if (this.menuComponent) {
      this.menuComponent.isActive = false;
    }

    // Check state of item with no els
    const url = this.router.url;
    console.log('URL', url, 'Menu link', this.menuItem.link);
    console.log(url.match('/' + this.menuItem.link + '$/'));
    if (this.menuItem && this.menuItem.link && url.match('/' + this.menuItem.link + '$/')) {
      this.isActive = true;
      this.checkActive.emit();
    }

    if (this.menuComponent) {
      console.log('Menu component');
      console.log(this.menuComponent, this.menuComponent.menuItem.link);
      this.isActive = true;
    }
  }

}

Is there a way of knowing from a component whether its route is active? The trick is that I'm using [routerLink] and not a routerLink so that I can pass . as a link to the root page.

Update

The best I could recreate stackblitz Try to visit "Dasboard". It should add an "active" class to parent li. If you add it yourself you would see the applied class on it's element and a colored bar on the right.

4 Answers

Maybe you can try access ActivatedRoute in any component

import { Router, ActivatedRoute} from '@angular/router';    

 constructor(private router: Router, private activatedRoute:ActivatedRoute) {
    console.log(activatedRoute.snapshot.url)  // array of states
    console.log(activatedRoute.snapshot.url[0].path) 
 }

Hope that will help!

StackBlitz

All of this functionality is built inside Angular's router module natively. This is the whole idea behind child routes. If a route is truly a parent component, it should be a parent in the routing. By doing this, you can simply check if the parent route is active instead of any of the routes arbitrarily assigned in the app component.

Additionally, this has the huge benefit of allowing dashboard to simply route to ['../sibling']

In my child routes specifically /home/home.routes, you will see a commented line that would enable the dashboard to automatically load if that was the desired behaviour.

You are already using routerLinkActive, in your dynamic menu-item, but to have the most straightforward approach, it should be on the item bound to the routerLink as outlined here.

The biggest issue you will likely encounter is that nested routerLink elements do not play nicely in the DOM as discussed in this question so you need to stop propagation.

app-component.html

<ul app-menu-item 
  class="nav side-menu"
  [menu]="menu">
</ul>

menu-item.html:

<li
  *ngFor="let item of menu"
  [routerLink]="item.link"
  routerLinkActive="active"
  (click)="stop($event)">

    {{item.name}}

    <ul app-menu-item
      *ngIf="item.children"
      class="nav side-menu"
      [menu]="item.children">
    </ul>
</li>

I will add, though it doesn't answer the title in your question, that this problem is most easily solved by using parent routes, and then setting up menu-item.html as follows:

<li *ngFor="let item of menu">
  <a [routerLink]="item.link" routerLinkActive="active">
    {{item.name}}
  </a>
  <ul class="nav side-menu"
  *ngIf="item.children"
  app-menu-item [menu]="item.children">
  </ul>
</li>

and then using a sibling selector in your scss to show or hide the menu.

I've managed to workaround it. StackBlitz

Mainly it's achieved by using setTimeOut without a time in navigateEnd event so that I can check the state of isActive and get the value(if there is no setTimeout the value is gotten "from the past" meaning it's not updated when it's taken). It works in a bit tricky way but it works

This is way simpler to achieve by assigning template variable to the child and checking it on the parent like this -

<li [ngClass]="{ 'is-expanded': rla.isActive }">
  <!-- is-expanded or active or whatever class in your template makes it active. -->
  <a>Parent Item</a>
  <ul class="treeview-menu">
    <li>
      <a routerLink="/child" routerLinkActive="active" #rla="routerLinkActive"
        >Child</a
      >
    </li>
  </ul>
</li>
Related