Jeroen
Jeroen

Reputation: 169

ng-template with material menu

I'm working on a boilerplate project with a configurable menu structure. The menu in it is bound to a recursive data type. I want to solve this via ng-container and ng-template as it looks to me that this is the only way to execute certain steps recursivly.

I have already developed several components using material so I would like to stick with material components as well and not use another menu component.

The problem I have is that if the mat-menu items are defined in a ng-template the menu doesn't get rendered properly. [https://i.imgur.com/WQed1yb.gif]

A menu-trigger-for menu item doesn't seem to be rendered the way it should. It lacks the sub-menu icon, and hovering over it doesn't show up the sub-menu. Instead a click is required. Furthermore the parent menu disappears when the sub-menu is opened which can complicate navigating through the menu.

Besides the mentioned issue the menu by itself does seem to work though.

I tried lazy loading based on a example here: https://www.angularjswiki.com/material/menu/ but that didn't solve the issue.

My HTML template is as follows:

<button mat-button [matMenuTriggerFor]="itemMenu">
  {{ startMenuItem.description }}
</button>
<mat-menu #itemMenu="matMenu">
  <ng-container *ngFor="let childItem of startMenuItem.children">
    <ng-container
      *ngTemplateOutlet="recursiveMenuTmpl; context: { $implicit: childItem }"
    >
    </ng-container>
  </ng-container>
</mat-menu>

<ng-template #recursiveMenuTmpl let-parentItem>
  <ng-container *ngIf="parentItem.children">
    <ng-container
      *ngTemplateOutlet="menuItem; context: { $implicit: parentItem }"
    ></ng-container>
  </ng-container>
  <ng-container *ngIf="!parentItem.children">
    <ng-container
      *ngTemplateOutlet="singleItem; context: { $implicit: parentItem }"
    ></ng-container>
  </ng-container>
</ng-template>

<ng-template #menuItem let-rootItem>
  <button [matMenuTriggerFor]="itemMenu" mat-menu-item>
    {{ rootItem.description }}
  </button>
  <mat-menu #itemMenu="matMenu">
    <ng-container *ngFor="let childItem of rootItem.children">
      <ng-container
        *ngTemplateOutlet="recursiveMenuTmpl; context: { $implicit: childItem }"
      >
      </ng-container>
    </ng-container>
  </mat-menu>
</ng-template>

<ng-template #singleItem let-item>
  <button *ngIf="item.route" mat-menu-item [routerLink]="item.uri">
    {{ item.description }}
  </button>
  <button *ngIf="!item.route" mat-menu-item (click)="onOpenUrl(item.uri)">
    {{ item.description }}
  </button>
</ng-template>

and my component

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-dynamic-menu',
  templateUrl: './dynamic-menu.component.html',
  styleUrls: ['./dynamic-menu.component.css'],
})
export class DynamicMenuComponent implements OnInit {
  public startMenuItem: MenuItem = {
    description: 'Menu',
    uri: '',
    route: false,
    children: [
      {
        description: 'Welcome',
        uri: '',
        route: false,
        children: [],
      },
      {
        description: 'Other',
        uri: '',
        route: false,
        children: [],
      },
      {
        description: 'Sign on/off',
        uri: '',
        route: false,
        children: [
          {
            description: 'Login',
            uri: '',
            route: false,
            children: [],
          },
          {
            description: 'Logout',
            uri: '',
            route: false,
            children: [],
          },
        ],
      },
    ],
  };

  constructor() {}

  public onOpenUrl(url: string) {
    document.location.href = url;
  }

  ngOnInit(): void {
  }
}

Upvotes: 4

Views: 2548

Answers (2)

Ben K.
Ben K.

Reputation: 509

Tried to solve this with templates too, after hours of attempts and web searches I managed to make it work by using a recursive component. I edited an old Angular 4 solution of someone else. Sharing a working example tested on Angular 13.

https://stackblitz.com/edit/dynamic-nested-hover-mat-menu-nosubitem-support-ifcyo8

Upvotes: 0

Jeroen
Jeroen

Reputation: 169

After doing some additional research i discovered that there was actually a bug in my code. The ngIf in my template that checks if a menu item has child items checked the existence of the child array and not if the array has actual content. I fixed that but it unfortunately didn't solve my original problem.

Further analysis of the behavior of the material menu showed that the parent child relationships weren't behaving as expected. This eventually directed me to an issue already raised for Angular (https://github.com/angular/angular/issues/14842). I very much suspect that this issue is causing the menu to misbehave. Understanding this shortcoming I reworked my template such that only one ng-template is used.

The problem material menu has is that is lacking content children (at least in my case as the children are in the ng-template). However if the reference from mat-menu is passed along with the template then a custom directive (or at least that is how I fixed it) in the template can do a content children and set the result in the parent mat-menu. Setting _allItems in the parent mat-menu with the content children and calling the _updateDirectDescendants was sufficient.

Unfortunately dependency injection with directives in templates is problematic as well (see: https://github.com/angular/angular/issues/2907) which prevents parent relationships from being set in the menu-items and menu-trigger. If the parentMenu of all menuItems is set to it's corresponding parent menu (via means of passing on the reference via the template context) that issue is solved as well for the menuItems. On the menu trigger the _parentMaterialMenu needs to be set. Finally for the matMenuItems that trigger a sub-menu the triggerSubMenu should be set to true.


So the wrap it up:

  1. Use one template or at least make sure the menu and the descendant menu-items are in one template.
  2. Get the child menuItems (e.g. via a custom directive) a set them in _allItems in the menu. Afterwards call _updateDirectDescendants on the menu.
  3. Set all menuItems to have the parentMenu set to the actual parent menu.
  4. On the menuTrigger the _parentMaterialMenu needs to be set to the actual parent menu.
  5. The menuItems that trigger a submenu should have there _triggerSubMenu set to true.

And that's it, the menu should work as normal. Note that this fix doesn't rely on Angular API alone. It works for Angular material version 12, but it might also work on other versions as well.

The following code is a working example:

Dynamic menu component html template

<button mat-button [matMenuTriggerFor]="itemMenu">{{ startMenuItem.description }}
    </button>
    <mat-menu #itemMenu="matMenu" #parentMenuRef>
        <ng-container *ngTemplateOutlet="recursiveMenuTmpl; context: { $implicit: startMenuItem, parentMenuRef: parentMenuRef }"></ng-container>
    </mat-menu>
    <ng-template #recursiveMenuTmpl let-rootItem let-parentMenuRef="parentMenuRef">
      <ng-container appMenupatch [parentMenu]=parentMenuRef>
        <ng-container *ngFor="let childItem of rootItem.children" >
          <ng-container *ngIf="hasSubItems(childItem)">
            <button #triggerButton="matMenuItem" #trigger="matMenuTrigger" [matMenuTriggerFor]="patch(itemMenu, triggerButton, trigger, parentMenuRef)" mat-menu-item>
              {{ childItem.description }}
            </button>
            <mat-menu #itemMenu="matMenu" #parentMenuRefNew>
              <ng-container *ngTemplateOutlet="recursiveMenuTmpl; context: { $implicit: childItem, parentMenuRef: parentMenuRefNew }"></ng-container>
            </mat-menu>
          </ng-container>
          <ng-container *ngIf="!hasSubItems(childItem)">
            <button *ngIf="childItem.route" mat-menu-item [routerLink]="childItem.uri">
              {{ childItem.description }}
            </button>
            <button *ngIf="!childItem.route" mat-menu-item (click)="onOpenUrl(childItem.uri)">
              {{ childItem.description }}
            </button>
          </ng-container>
        </ng-container>
      </ng-container>
    </ng-template>

Dynamic menu component ts

import { Component, OnInit } from '@angular/core';
import { MatMenu, MatMenuItem, MatMenuPanel, _MatMenuBase } from '@angular/material/menu';

export interface MenuItem {
  description: string,
  uri: string,
  route: boolean,
  children: MenuItem[]
}

@Component({
  selector: 'app-dynamic-menu',
  templateUrl: './dynamic-menu.component.html',
  styleUrls: ['./dynamic-menu.component.css'],
})

export class DynamicMenuComponent implements OnInit {
  public startMenuItem: MenuItem = {
    description: 'Menu',
    uri: '',
    route: false,
    children: []
   };

  constructor() {}

  public onOpenUrl(url: string) {
    document.location.href = url;
  }

  public hasSubItems(item: MenuItem):boolean{
    return (Array.isArray(item.children) && item.children.length>0);
  }

  public patch(item: MatMenu, triggerbutton: MatMenuItem, trigger: any, parentMenu: MatMenuPanel):MatMenu
  {
    if (parentMenu)
    {
        triggerbutton._triggersSubmenu=true;
        trigger._parentMaterialMenu=parentMenu;
    }
    return item;
  }

  ngOnInit(): void {
    let menuItem = {
      description: 'Canine',
      route: true,
      children: 
    [
      {
        description: 'Dog',
        route: false
      },
      {
        description: 'Wolve',
        route: false
      }
    ]
    } as MenuItem;
    this.startMenuItem.children.push(menuItem);
    menuItem = {
      description: 'Rodent',
      route: true,
      children:[
        {
          description: 'Rabbit',
          route: false
        },
        {
          description: 'Beaver',
          route: false
        },
        {
          description: 'Mouse',
          route: false
        }
      ]
    } as MenuItem;
    this.startMenuItem.children.push(menuItem);
    menuItem = {
      description: 'Bird of prey',
      route: true,
      children:
      [
        {
          description: 'Eagle',
          route: false
        },
        {
          description: 'Falcon',
          route: false
        },
        {
          description: 'Harrier',
          route: false
        }
      ]
    } as MenuItem;
    this.startMenuItem.children.push(menuItem);
  }
}

Menu-patch directive

import { Directive, Input, ContentChildren, QueryList } from '@angular/core';
import { MatMenuItem } from '@angular/material/menu';

@Directive({
  selector: '[appMenupatch]'
})
export class MenupatchDirective {
  @ContentChildren(MatMenuItem, {descendants: true}) _allItems: QueryList<MatMenuItem>;
  @Input() parentMenu: any;
  constructor() {
  }

  ngAfterViewChecked(){
    this._allItems.forEach(item=>{
      item._parentMenu=this.parentMenu;
    })
    this.parentMenu._allItems=this._allItems;
    this.parentMenu._updateDirectDescendants();
  }

}

The model declarations should contain:

 - DynamicMenuComponent
 - MenupatchDirective

The imports should have the material module components:

- MatMenuModule
- MatButtonModule

Upvotes: 4

Related Questions