In this article we will learn how to build a generic menu with Angular. We are going to concentrate on its logic, without paying too much attention to the way its presented on the screen.

Our goal is to create a menu, items of which could be defined in a way shown below, so that we could have as many subtrees as we want without having to nest them.

<app-menu-item [menuFor]="main">Click Me</app-menu-item>

<ng-template #main>
  <app-menu>
    <app-menu-item [menuFor]="vehicles">Vehicles</app-menu-item>
    <app-menu-item [menuFor]="bikes">Bikes</app-menu-item>
  </app-menu>
</ng-template>

<ng-template #vehicles>
  <app-menu>
    <app-menu-item>Cars</app-menu-item>
    <app-menu-item>Buses</app-menu-item>
    <app-menu-item>Trucks</app-menu-item>
  </app-menu>
</ng-template>

<ng-template #bikes>
  <app-menu>
    <app-menu-item>Road</app-menu-item>
    <app-menu-item>MTB</app-menu-item>
    <app-menu-item>City</app-menu-item>
  </app-menu>
</ng-template>

Setting up

First thing we are going to do is to set up Angular app and create a module that will later embrace all elements necessary for the menu to work. While generating a new project, no routing is necessary and standard CSS will work.

ng new menu-demo-app
cd menu-demo-app
ng generate module menu

Now we are going to create two components, one that is going to act as a container for menu items on each subtree level ( <app-menu></app-menu> ) and the other for each menu item ( <app-menu-item></app-menu-item> ). Former is a container for the latter.

ng generate component menu/menu
ng generate component menu/menu-item

We need to make sure that both components are declared and exported in MenuModule. It should look this way:

@NgModule({
  declarations: [MenuComponent, MenuItemComponent],
  exports: [MenuComponent, MenuItemComponent],
  imports: [CommonModule]
})
export class MenuModule {}

Styling items container

Our MenuComponent needs to be positioned absolutely, so that it did not break our document flow (marked with a yellow rectangle on the image below). Also, its display has to be set to inline-block, so that nested tree is displayed next to its parent (green rectangle, vehicles submenu on the right hand side of the vehicles item).

We will use HostBinding decorator for that, setting host component's display and position. It should look this way:

export class MenuComponent {
  @HostBinding('style.display') public display = 'inline-block';
  @HostBinding('style.position') public position = 'absolute';
  
  //...
  
}

Now we have to project the content of our container. I can be done using the <ng-content> tag. Angular replaces this tag with the content that we put between our host component tags. If we put <app-menu>Hello World<app-menu>, it will simply display 'Hello World' in our host component.

<div class="menu">
  <ng-content></ng-content>
</div>

We wrap projected content around a div and add some styling to it so that all projected elements (menu items) appear in one column, one below another.

.menu {
  display: flex;
  flex-direction: column;
}

Building menu item component

Good job! Now let's concentrate on MenuItemComponent. This component needs to display the content of our sub-menu item and, if necessary, trigger display of a nested menu related to it on click. We are going to project the content using the <ng-content> tag again. Also we need to tell the component to display nested submenu that is related to the menu item. We can do so using <ng-container> tag. This tag is going to act as a placeholder to the template we are going to inject into our MenuItemComponent.

<button (click)="onClick()" class="button__container">
  <ng-content></ng-content>
</button>

<ng-container #viewContainerRef></ng-container>

Lets define a CSS class for the button container:

.button__container {
  min-width: 110px;
}

We add a template variable viewContainerRef so that we could have a reference to it in our component class. We have to define input property for the MenuItemComponent to bind parent item with sub-menu it should trigger. We will add a CSS style so that all button elements had the same width. Now let's add a handler for the button click. It will render a template passed into the input property menuFor.

export class MenuItemComponent {
  @Input() public menuFor: TemplateRef<MenuComponent>;

  @ViewChild('viewContainerRef', { read: ViewContainerRef }) public viewContainerRef: ViewContainerRef;

  constructor() {}

  public onClick(): void {
    this.addTemplateToContainer(this.menuFor);
  }

  private addTemplateToContainer(template: TemplateRef<any>): void {
    this.viewContainerRef.createEmbeddedView(template);
  }
    
  // ...
  
}

ViewContainerRef represents a container to which we can attach a view. In our case we are going to attach the view that was passed into our menuFor input property. To do so, we use createEmbeddedView method existing on ViewContainerRef, which does the actual insertion of a view into our container.


Seeing it working

At that moment we should be able to see a nested menu after clicking on a parent element. But before that we have to set up a playground for that. Let's add the MenuModule to the imports array in AppModule:

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, MenuModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Let's also replace the old content of AppComponent's template with our menu and some sub menus defined:

<app-menu-item [menuFor]="main">Click Me</app-menu-item>

<ng-template #main>
  <app-menu>
    <app-menu-item [menuFor]="vehicles">Vehicles</app-menu-item>
    <app-menu-item [menuFor]="bikes">Bikes</app-menu-item>
  </app-menu>
</ng-template>

<ng-template #vehicles>
  <app-menu>
    <app-menu-item>Cars</app-menu-item>
    <app-menu-item>Buses</app-menu-item>
    <app-menu-item>Trucks</app-menu-item>
  </app-menu>
</ng-template>

<ng-template #bikes>
  <app-menu>
    <app-menu-item [menuFor]="roadBikes">Road</app-menu-item>
    <app-menu-item>MTB</app-menu-item>
    <app-menu-item>City</app-menu-item>
  </app-menu>
</ng-template>

<ng-template #roadBikes>
  <app-menu>
    <app-menu-item>Race</app-menu-item>
    <app-menu-item>Gravel</app-menu-item>
    <app-menu-item>Aero</app-menu-item>
    <app-menu-item>Time Trial</app-menu-item>
  </app-menu>
</ng-template>

Let's now type ng serve, hit Enter and check how it works. At this point you should see a menu similar to this:

You probably spotted some problems (not the complete list):

  1. The first menu container (with vehicles and bikes) should be displayed below Click Me button,
  2. It's impossible to close the menu by clicking the menu item button,
  3. Clicking outside of the menu should close it.

Positioning of menu item component

Let's tackle the first problem. The MenuItemComponent element needs to know whether it's the very root of the menu tree, or if it's a leaf. Depending on its position in the tree we are going to apply different CSS styles to it. To figure it out we need to inject an optional dependency to our MenuItemComponent which is going to be a parent component. If there is no parent component of type MenuComponent, it means the MenuItem is the root, otherwise it's the leaf. Let's add two CSS classes to MenuItemComponent, one to style it in case it's a root, the other, if it's a leaf:

.button__container {
  min-width: 110px;
}

.button__container--root {
  display: block;
}

.button__container--leaf {
  margin-left: 1px;
  display: inline-block;
  border: 1px solid grey;
}

Once we have them we need to add a property getter, which is going to return either of them depending on its position in the menu tree:

public get containerCssClass(): string {
  return this.isRoot() 
    ? 'button__container--root' 
    : 'button__container--leaf';
}

constructor( @Optional() private parent: MenuComponent ) {}

private isRoot(): boolean {
  return isNullOrUndefined(this.parent);
}

One last thing is to alter the menu item template a bit, so that it accepts a dynamically bound CSS class. After the change it should look like this:

<button 
  (click)="onClick()"
  [ngClass]="containerCssClass"
  class="button__container">
  <ng-content></ng-content>
</button>

<ng-container #viewContainerRef></ng-container>

Opening and closing on menu item click

Problem number one, check! Now let's concentrate on another problem. When we click the Click Me button, the menu is displayed, however, clicking on it again should close it. But it does not. Once the menu is shown, it won't disappear:

To solve that let's check whether the placeholder for our container is already used to display another menu. If so, it means we need to clear the container. Let's alter the onClick handler and add two private methods.

public onClick(): void {
  if (this.containerIsEmpty()) {
    this.addTemplateToContainer(this.menuFor);
  } else {
    this.clearContainer();
  }
}

private containerIsEmpty(): boolean {
  return this.viewContainerRef.length === 0;
}

private clearContainer(): void {
  this.viewContainerRef.clear();
}

When we click the Click Me button, the menu shows up. When we click again, it disappears. Seems like it works, but we still need to close a sub tree when we click on a leaf that is adjacent to its parent. Pay attention to clicks on Vehicles and Bikes in the GIF below. Their sub menus should be closed.

To fix that we need to alter the MenuComponent so that it knows which menu item triggered a sub menu open. Additionally, to make it capable of telling the menu item to clear their content (close the sub menu that it opened). Let's add those two methods. After the change, our MenuComponent should look like this:

export class MenuComponent {
  @HostBinding('style.display') public display = 'inline-block';
  @HostBinding('style.position') public position = 'absolute';

  private activeMenuItem: MenuItemComponent;

  constructor() {}

  public registerOpenedMenu(menuItem: MenuItemComponent): void {
    this.activeMenuItem = menuItem;
  }

  public closeOpenedMenuIfExists(): void {
    if (this.activeMenuItem) {
      this.activeMenuItem.clearContainer();
    }
  }
}

Now lets go to MenuItemComponent. We need to change the access modifier of the clearContainer method to public and handle registration of opened sub menu in our parent component. Now in the onClick event handler we first close the already opened menu (if it exists), then register the menu item that triggered another menu to open. The updated click handler, clickContainer, and the new private methods added should look like this:

public onClick(): void {
  if (this.containerIsEmpty()) {
    this.closeAlreadyOpenedMenuInTheSameSubtree();
    this.registerOpenedMenu();
    this.addTemplateToContainer(this.menuFor);
  } else {
    this.clearContainer();
  }
}

// access modifier changed
public clearContainer(): void {
  this.viewContainerRef.clear();
}

private closeAlreadyOpenedMenuInTheSameSubtree(): void {
  if (this.parent) {
    this.parent.closeOpenedMenuIfExists();
  }
}

private registerOpenedMenu(): void {
  if (this.parent) {
    this.parent.registerOpenedMenu(this);
  }
}

OK, seems we are done with that. Now our menu should work like this:


Closing on the outside click

Now we need to close our menu if we click outside of it (problem number three). To detect it we are going to use the DOCUMENT injection token (imported from @angular/common) and EventManager service available in @angular/platform-browser. The former to query menu item and the latter to attach and remove a click handler. Let's inject them in MenuItemComponent. Its constructor should be extended with two elements, documentRef and eventManager. We will attach a global click listener on clicking the root element of our menu. Once the menu is closed we will remove the click listener. Method addGlobalEventListener returns a callback function that we need to save and call later in order to remove the listener. Let's add some more code to our menu item.

private removeGlobalEventListener: Function;

constructor(
  @Optional() private parent: MenuComponent,
  @Inject(DOCUMENT) private documentRef: Document,
  private eventManager: EventManager,
) {}

// ...

public onClick(): void {
  if (this.containerIsEmpty()) {
    // we add a handler for the root element
    this.addHandlersForRootElement();
    this.closeAlreadyOpenedMenuInTheSameSubtree();
    this.registerOpenedMenu();
    this.addTemplateToContainer(this.menuFor);
  } else {
    // and remove it in case we want to close the menu
    this.removeClickOutsideListener();

    this.clearContainer();
  }
}

// ...

private addHandlersForRootElement() {
  if (this.isRoot()) {
    this.addClickOutsideListener();
  }
}

private addClickOutsideListener(): void {
  this.removeGlobalEventListener = this.eventManager.addGlobalEventListener(
    'window',
    'click',
    this.closeMenuOnOutsideClick.bind(this)
  );
}

private removeClickOutsideListener(): void {
  if (this.removeGlobalEventListener) {
    this.removeGlobalEventListener();
  }
}

private closeMenuOnOutsideClick({ target }): void {
  // currently just a placeholder
  console.log('hello world');
}

If we click on the menu whose sub menu is currently closed (its container is empty), we need to check if it was the root element. If so, we add a click event listener. If – on the other hand – menu is already visible and we click it again, we need to the remove click listener and only then proceed with clearing the container.

With the handlers attached we can work on closing the menu on outside clicks. We are going to use querySelector to get the root menu. If the clicked target was outside of it, we remove the click handler and broadcast a menu clear. BroadcastMenuClear is a method that we need to define.

// updated method
private closeMenuOnOutsideClick({ target }): void {
  const appMenuItem = this.documentRef.querySelector(
    'app-menu-item > app-menu'
  );
  if (appMenuItem && !appMenuItem.parentElement.contains(target)) {
    this.removeClickOutsideListener();
    this.broadcastMenuClear();
  }
}

private broadcastMenuClear(): void {
  // a placeholder
}

It's going to call a service that is responsible for informing everyone interested in knowing that a menu should be closed. Let's create the service. Type ng generate service menu/menuState and hit Enter. After that, let's expose an observable and define a method that will make it emit the next value.

@Injectable({
  providedIn: 'root',
})
export class MenuStateService {
  public state$: Observable<void>;

  private _state = new Subject<any>();

  constructor() {
    this.state$ = this._state.asObservable();
  }

  public clearMenu(): void {
    this._state.next();
  }
}

Once we have a service, we need to use it in MenuItemComponent. We need to alter broadcastMenuClear method and make it call clearMenu from the menu state service. This way we are able to dispatch the next value on the observable, but no one is listening to the messages yet. Let's add a method that will subscribe to the messages. We will call it subscribeToClearMenuMessages and add it to addHandlersForRootElement.

constructor(
  @Optional() private parent: MenuComponent,
  @Inject(DOCUMENT) private documentRef: Document,
  private eventManager: EventManager,
  // new service injected below:
  private menuStateService: MenuStateService
) {}

// ...

// altered addHandlersForRootElement method
private addHandlersForRootElement() {
  if (this.isRoot()) {
    // we subscribe to menu state changes in here
    this.subscribeToClearMenuMessages();
    this.addClickOutsideListener();
  }
}

// ...

// updated method`s body
private broadcastMenuClear(): void {
  this.menuStateService.clearMenu();
}

// ...

private subscribeToClearMenuMessages(): void {
  this.menuStateService.state$.subscribe(() => {
    this.clearContainer();
  });
}

Our menu is almost working. Now we have to handle its leaves. If a leaf has been clicked, we need to close the menu. Let's change our click handler.

public onClick(): void {
  if (this.isLeaf()) {
    this.broadcastMenuClear();
  } else if (this.containerIsEmpty()) {
    this.addHandlersForRootElement();
    this.closeAlreadyOpenedMenuInTheSameSubtree();
    this.registerOpenedMenu();
    this.addTemplateToContainer(this.menuFor);
  } else {
    this.removeClickOutsideListener();
    this.clearContainer();
  }
}

// ...

private isLeaf(): boolean {
  return !this.isRoot() && !this.hasNestedSubMenu();
}

private hasNestedSubMenu(): boolean {
  return !!this.menuFor;
}

One last thing is cleaning up. Lets add ngOnDestroy to MenuItemComponent and remove the click outside listener.

export class MenuItemComponent implements OnDestroy {

//...

// new private property
private menuStateSubscription: Subscription;

// ...

public ngOnDestroy(): void {
  this.removeClickOutsideListener();
  this.unsubscribe();
}

// ...

// updated subscribeToClearMenuMessages method
private subscribeToClearMenuMessages(): void {
  this.menuStateSubscription = this.menuStateService.state$.subscribe(() => {
    this.clearContainer();
  });
}

// added unsubscribe method
private unsubscribe(): void {
  if (this.menuStateSubscription) {
    this.menuStateSubscription.unsubscribe();
  }
}

Conclusion

If you proceeded with all the steps thoroughly, you should see your menu working as below:

This may be used as a foundation for building your own component of that kind. If you are going to reuse it, you probably might want to work on its CSS since right now it looks like it's from the 90s.

In this tutorial we covered several techniques commonly used during Angular application development. We used content projection which allows us to insert an HTML snippet between our component's tags. Another key technique is creation and insertion of HTML into a predefined spot in component's template. We also learnt how to access a component's parent, make it act as an optional dependency and apply some CSS dynamically.

Below are links to the solution on GitHub, and a live demo on StackBlitz.

https://github.com/ragtam/menu-demo-app

https://stackblitz.com/github/ragtam/menu-demo-app