Reputation: 169
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
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
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:
menuItems
(e.g. via a custom directive) a set them in _allItems
in the menu. Afterwards call _updateDirectDescendants
on the menu.menuItems
to have the parentMenu
set to the actual parent menu.menuTrigger
the _parentMaterialMenu
needs to be set to the actual parent menu.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