Component Architecture

This article will be slightly longer than my usual posts, so please bear with me. While working on a recent task, I needed to make a menu expand automatically whenever the user lands on a sub-page belonging to a menu group. The video below illustrates what I'm talking about:

Final demo

In that example, "Pages 3" and "Pages 4" are grouped under the label "Nested menu," and the menu opens on its own when the user navigates to one of those pages.

It's a neat effect. In this post, I'll walk you through how I implemented it. It's not particularly difficult, but there is one important caveat to keep in mind.

That caveat is that the approach I'm about to describe is tailored to our specific component library setup. If you're trying to replicate this behavior in your own project, your final implementation might look different. With that in mind, let me first describe our setup before getting into the code.

Two layers of components

The application I'm working on is split into two distinct categories:

  1. Application-specific components
  2. Design System components

Design System components

As you might expect, the Design System contains small, focused components that serve particular UI needs, and these are the building blocks used by the application itself.

Within the Design System, we have components such as nav-list and nav-list-item, as well as an expand-on-active-link directive where the core functionality lives.

The nav-list-item component

This component acts as a wrapper around Material's mat-list-item and needs to fulfill two main requirements:

  1. It must be able to handle internal links.
  2. It must also be able to handle external links.

The component class includes a link Input and contains logic to determine whether the provided link is internal or external. That logic isn't the focus of this post, but you can find it in the final GitHub repository.

At this stage, its template looks like this:

<!-- nav-list-item.component.html -->
<a
  *ngIf="isExternalLink; else internalLink"
  mat-list-item
  mat-ripple
  [href]="link"
  [attr.target]="target"
  ><ng-container *ngTemplateOutlet="templateContent"></ng-container
></a>

<ng-template #internalLink>
  <a mat-list-item mat-ripple [routerLink]="link" routerLinkActive="active"
    ><ng-container *ngTemplateOutlet="templateContent"></ng-container
  ></a>
</ng-template>

<ng-template #templateContent>
  <ng-content></ng-content>
</ng-template>
The nav-list component

The nav-list component wraps Material's mat-nav-list. It has an expandable Input property. When this property is set to true, the mat-nav-list and its projected content are placed inside a mat-expansion-panel. If it's false, the mat-nav-list is rendered directly.

Here is its template at this point:

<!-- mat-nav-list.component.html -->
<ng-container *ngIf="expandable; else navListTemplate">
  <mat-expansion-panel class="mat-elevation-z0">
    <mat-expansion-panel-header>
      <mat-panel-title>{% raw %}{{ title }}{% endraw %}</mat-panel-title>
    </mat-expansion-panel-header>
    <ng-container *ngTemplateOutlet="navListTemplate"></ng-container>
  </mat-expansion-panel>
</ng-container>

<ng-template #navListTemplate>
  <mat-nav-list><ng-content></ng-content></mat-nav-list>
</ng-template>

We'll revisit the expand-on-active-link directive in more detail later on.

Application-specific components

This layer is where the Design System components are actually used to build features.

The sidebar-nav component

This component simply brings the nav-list and nav-list-item components together. Its template looks like this:

<!-- sidebar-nav.component.html -->
<nav-list>
  <nav-list-item link="/page-1">Page 1</nav-list-item>
  <nav-list-item link="/page-2">Page 2</nav-list-item>
  <nav-list-item link="https://angular.io/">Angular</nav-list-item>
</nav-list>

<nav-list [expandable]="true" [title]="'Nested menu'">
  <nav-list-item link="/page-3">Page 3</nav-list-item>
  <nav-list-item link="/page-4">Page 4</nav-list-item>
</nav-list>

Looking at the code, the first nav-list renders a standard list of links, while the second nav-list renders an expandable group of links under the "Nested menu" heading. Let's see it in action.

Menu that must be expanded manually

The expandable menu currently requires manual interaction to open. If a menu item is highlighted, it stays hidden until the user expands the menu, which can be disorienting. Our goal is to fix this by making the menu auto-expand. Let's see how we can achieve that.

Implementing Auto-expand

First, we need to establish the functional requirements:

  1. An expandable menu should automatically open when the user navigates to a sub-page that is part of that menu group.
  2. An already expanded menu should remain open if the user subsequently navigates to another top-level page.

There are likely several ways to approach this. One approach would be to subscribe to the NavigationEnd router event and try to determine which nav-list to expand based on the routes. Another approach, the one I went with, is to listen for the isActiveChange event on each routerLink and expand the closest nav-list accordingly.

So, a few adjustments are needed.

Updating the nav-list-item component

Recall that this component supports both internal and external links. Every internal link uses the routerLink directive, which conveniently has an isActiveChange Output property that emits true when a link becomes active and false when it becomes inactive. For now, we will just forward this emitted value to a new Output property on the nav-list-item class. We'll clarify the reasoning behind this shortly.

So, the component class and its template now look like this:

<!-- nav-list-item.component.html -->
<!-- ... -->
<a [routerLink]="link" (isActiveChange)="isActive.emit($event)" ...>...</a>

And the template:

// nav-list-item.component.ts
@Component({
  // ...,
  selector: 'nav-list-item',
})
export class NavListItemComponent {
  // ...

  @Output() isActive = new EventEmitter<boolean>();
}

Updating the nav-list component

Here, we need to query the template for all projected nav-list-item components. The @ContentChildren decorator is perfect for this.

// nav-list.component.ts
@Component({
  // ...
  selector: 'nav-list',
})
export class NavListComponent {
  // ...

  @ContentChildren(NavListItemComponent)
  navListItemComponents: QueryList<NavListItemComponent> | null = null;
}

Once we have a reference to all nav-list-item components, we will pass them to a custom directive (shown below). This directive will listen for the isActive event on each link within a sub-menu and expand the associated mat-expansion-panel if any of them emit true.

Let's start by modifying the nav-list template, then we'll examine the custom directive.

<!-- nav-list.component.html -->
<!-- ... -->
<mat-expansion-panel
  expandOnActiveLink
  [navListItemComponents]="navListItemComponents"
  ...
>
  <!-- ... -->
</mat-expansion-panel>

Notice that the custom expandOnActiveLink directive is placed only on the mat-expansion-panel. The directive accepts one Input, called navListItemComponents, which takes the list of nav-list-item components.

The expand-on-active-link directive

This is where Angular's directives become especially useful. When you attach a directive to a component, you can inject an instance of that component into the directive's constructor. We'll take advantage of that capability.

The strategy is to inject an instance of MatExpansionPanel into the directive and use its open method to expand the panel whenever any of the projected nav-list-item components emits true from its isActive Output.

First, let's look at the directive code, then we'll break it down:

// expand-on-active-link.directive.ts
@Directive({
  selector: '[expandOnActiveLink]',
  exportAs: 'expandOnActiveLink',
  standalone: true,
})
export class ExpandOnActiveLinkDirective implements AfterContentInit {
  @Input()
  navListItemComponents: QueryList<NavListItemComponent> | null = null;

  constructor(private panel: MatExpansionPanel) {}

  ngAfterContentInit(): void {
    const navListItems = this.navListItemComponents?.toArray();

    if (navListItems) {
      from(navListItems)
        .pipe(
          mergeMap((item) => item.isActive),
          filter((isActive) => isActive)
        )
        .subscribe(() => {
          // Looks like there's a bug in `mat-drawer` component
          // that prevents `mat-expansion-panel` from expanding
          // This littl' fella fixes it :)
          setTimeout(() => this.panel.open(), 0);
        });
    }
  }
}

There are a few points worth mentioning here. First, we access the navListItemComponents in the ngAfterContentInit lifecycle hook because ContentChildren queries are resolved right before this hook is called. Second, the from function takes the array of nav-list-item components and emits each one to the mergeMap operator. The mergeMap operator then subscribes to the isActive Output of each component, merging their events into a single stream. The subsequent filter operator ensures only true emissions continue down the line. Finally, the injected panel instance is called to open the MatExpansionPanel. The setTimeout is included as a workaround because, at the time of writing, there appears to be a bug in Material that prevents mat-expansion-panel from expanding when it's nested inside a mat-drawer.

Final Demo

That was quite a journey! Here is the final StackBlitz demo, along with the corresponding GitHub repository.

Conclusion

I hope you found this walkthrough helpful. It's a solution that's quite specific to one particular component setup, but it was an interesting problem to solve nonetheless.

But wait, we're not finished just yet! Now it's your turn. Do you have any ideas for improving upon this solution? Is there a different approach you would have taken? Feel free to let me know on Twitter.