Skip to content

Commit 5e636f9

Browse files
committed
feat(menu): support lazy rendering and passing in context data
* Introduces the `matMenuContent` directive that allows for menu content to be rendered lazily. * Adds the `matMenuTriggerData` input to the `MatMenuTrigger` that allows for contextual data to be passed in to the lazily-rendered menu panel. This allows for the menu instance to be re-used between triggers. Fixes #9251.
1 parent af44b9d commit 5e636f9

File tree

9 files changed

+286
-28
lines changed

9 files changed

+286
-28
lines changed

src/cdk/portal/dom-portal-outlet.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal';
2222
*/
2323
export class DomPortalOutlet extends BasePortalOutlet {
2424
constructor(
25-
private _hostDomElement: Element,
25+
/** Element into which the content is projected. */
26+
public hostDomElement: Element,
2627
private _componentFactoryResolver: ComponentFactoryResolver,
2728
private _appRef: ApplicationRef,
2829
private _defaultInjector: Injector) {
@@ -59,7 +60,7 @@ export class DomPortalOutlet extends BasePortalOutlet {
5960
}
6061
// At this point the component has been instantiated, so we move it to the location in the DOM
6162
// where we want it to be rendered.
62-
this._hostDomElement.appendChild(this._getComponentRootNode(componentRef));
63+
this.hostDomElement.appendChild(this._getComponentRootNode(componentRef));
6364

6465
return componentRef;
6566
}
@@ -78,7 +79,7 @@ export class DomPortalOutlet extends BasePortalOutlet {
7879
// But for the DomPortalOutlet the view can be added everywhere in the DOM
7980
// (e.g Overlay Container) To move the view to the specified host element. We just
8081
// re-append the existing root nodes.
81-
viewRef.rootNodes.forEach(rootNode => this._hostDomElement.appendChild(rootNode));
82+
viewRef.rootNodes.forEach(rootNode => this.hostDomElement.appendChild(rootNode));
8283

8384
this.setDisposeFn((() => {
8485
let index = viewContainer.indexOf(viewRef);
@@ -96,8 +97,8 @@ export class DomPortalOutlet extends BasePortalOutlet {
9697
*/
9798
dispose(): void {
9899
super.dispose();
99-
if (this._hostDomElement.parentNode != null) {
100-
this._hostDomElement.parentNode.removeChild(this._hostDomElement);
100+
if (this.hostDomElement.parentNode != null) {
101+
this.hostDomElement.parentNode.removeChild(this.hostDomElement);
101102
}
102103
}
103104

src/lib/menu/menu-content.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
Directive,
11+
TemplateRef,
12+
ComponentFactoryResolver,
13+
ApplicationRef,
14+
Injector,
15+
ViewContainerRef,
16+
Inject,
17+
OnDestroy,
18+
} from '@angular/core';
19+
import {TemplatePortal, DomPortalOutlet} from '@angular/cdk/portal';
20+
import {DOCUMENT} from '@angular/common';
21+
22+
/**
23+
* Menu content that will be rendered lazily once the menu is opened.
24+
*/
25+
@Directive({
26+
selector: 'ng-template[matMenuContent]'
27+
})
28+
export class MatMenuContent implements OnDestroy {
29+
private _portal: TemplatePortal<any>;
30+
private _outlet: DomPortalOutlet;
31+
32+
constructor(
33+
private _template: TemplateRef<any>,
34+
private _componentFactoryResolver: ComponentFactoryResolver,
35+
private _appRef: ApplicationRef,
36+
private _injector: Injector,
37+
private _viewContainerRef: ViewContainerRef,
38+
@Inject(DOCUMENT) private _document: any) {}
39+
40+
/**
41+
* Attaches the content with a particular context.
42+
* @docs-private
43+
*/
44+
attach(context: any = {}) {
45+
if (!this._portal) {
46+
this._portal = new TemplatePortal(this._template, this._viewContainerRef);
47+
} else if (this._portal.isAttached) {
48+
this._portal.detach();
49+
}
50+
51+
if (!this._outlet) {
52+
this._outlet = new DomPortalOutlet(this._document.createElement('div'),
53+
this._componentFactoryResolver, this._appRef, this._injector);
54+
}
55+
56+
const element: HTMLElement = this._template.elementRef.nativeElement;
57+
58+
// Since the menu might be attached to a different DOM node, we have to re-insert it every time.
59+
element.parentNode!.insertBefore(this._outlet.hostDomElement, element);
60+
this._portal.attach(this._outlet, context);
61+
}
62+
63+
ngOnDestroy() {
64+
if (this._outlet) {
65+
this._outlet.dispose();
66+
}
67+
}
68+
}

src/lib/menu/menu-directive.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
AfterContentInit,
1818
ChangeDetectionStrategy,
1919
Component,
20+
ContentChild,
2021
ContentChildren,
2122
ElementRef,
2223
EventEmitter,
@@ -38,6 +39,7 @@ import {matMenuAnimations} from './menu-animations';
3839
import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors';
3940
import {MatMenuItem} from './menu-item';
4041
import {MatMenuPanel} from './menu-panel';
42+
import {MatMenuContent} from './menu-content';
4143
import {MenuPositionX, MenuPositionY} from './menu-positions';
4244
import {coerceBooleanProperty} from '@angular/cdk/coercion';
4345

@@ -128,6 +130,12 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
128130
/** List of the items inside of a menu. */
129131
@ContentChildren(MatMenuItem) items: QueryList<MatMenuItem>;
130132

133+
/**
134+
* Menu content that will be rendered lazily.
135+
* @docs-private
136+
*/
137+
@ContentChild(MatMenuContent) lazyContent: MatMenuContent;
138+
131139
/** Whether the menu should overlap its trigger. */
132140
@Input()
133141
set overlapTrigger(value: boolean) {
@@ -232,7 +240,14 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
232240
* to focus the first item when the menu is opened by the ENTER key.
233241
*/
234242
focusFirstItem() {
235-
this._keyManager.setFirstItemActive();
243+
// When the content is rendered lazily, it takes a bit before the items are inside the DOM.
244+
if (this.lazyContent) {
245+
this._ngZone.onStable.asObservable()
246+
.pipe(take(1))
247+
.subscribe(() => this._keyManager.setFirstItemActive());
248+
} else {
249+
this._keyManager.setFirstItemActive();
250+
}
236251
}
237252

238253
/**

src/lib/menu/menu-module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,24 @@ import {NgModule} from '@angular/core';
1010
import {CommonModule} from '@angular/common';
1111
import {MatCommonModule} from '@angular/material/core';
1212
import {OverlayModule} from '@angular/cdk/overlay';
13+
import {PortalModule} from '@angular/cdk/portal';
1314
import {MatMenu, MAT_MENU_DEFAULT_OPTIONS} from './menu-directive';
1415
import {MatMenuItem} from './menu-item';
1516
import {MatMenuTrigger, MAT_MENU_SCROLL_STRATEGY_PROVIDER} from './menu-trigger';
1617
import {MatRippleModule} from '@angular/material/core';
18+
import {MatMenuContent} from './menu-content';
1719

1820

1921
@NgModule({
2022
imports: [
23+
PortalModule,
2124
OverlayModule,
2225
CommonModule,
2326
MatRippleModule,
2427
MatCommonModule,
2528
],
26-
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatCommonModule],
27-
declarations: [MatMenu, MatMenuItem, MatMenuTrigger],
29+
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent, MatCommonModule],
30+
declarations: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent],
2831
providers: [
2932
MAT_MENU_SCROLL_STRATEGY_PROVIDER,
3033
{

src/lib/menu/menu-panel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {EventEmitter, TemplateRef} from '@angular/core';
1010
import {MenuPositionX, MenuPositionY} from './menu-positions';
1111
import {Direction} from '@angular/cdk/bidi';
12+
import {MatMenuContent} from './menu-content';
1213

1314
/**
1415
* Interface for a custom menu panel that can be used with `matMenuTriggerFor`.
@@ -26,4 +27,5 @@ export interface MatMenuPanel {
2627
resetActiveItem: () => void;
2728
setPositionClasses: (x: MenuPositionX, y: MenuPositionY) => void;
2829
setElevation?(depth: number): void;
30+
lazyContent?: MatMenuContent;
2931
}

src/lib/menu/menu-trigger.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
106106
/** References the menu instance that the trigger is associated with. */
107107
@Input('matMenuTriggerFor') menu: MatMenuPanel;
108108

109+
/** Data to be passed along to any lazily-rendered content. */
110+
@Input('matMenuTriggerData') menuData: any;
111+
109112
/** Event emitted when the associated menu is opened. */
110113
@Output() menuOpened = new EventEmitter<void>();
111114

@@ -191,14 +194,21 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
191194

192195
/** Opens the menu. */
193196
openMenu(): void {
194-
if (!this._menuOpen) {
195-
this._createOverlay().attach(this._portal);
196-
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
197-
this._initMenu();
197+
if (this._menuOpen) {
198+
return;
199+
}
198200

199-
if (this.menu instanceof MatMenu) {
200-
this.menu._startAnimation();
201-
}
201+
this._createOverlay().attach(this._portal);
202+
203+
if (this.menu.lazyContent) {
204+
this.menu.lazyContent.attach(this.menuData);
205+
}
206+
207+
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
208+
this._initMenu();
209+
210+
if (this.menu instanceof MatMenu) {
211+
this.menu._startAnimation();
202212
}
203213
}
204214

src/lib/menu/menu.md

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ By itself, the `<mat-menu>` element does not render anything. The menu is attach
66
via application of the `matMenuTriggerFor` directive:
77
```html
88
<mat-menu #appMenu="matMenu">
9-
<button mat-menu-item> Settings </button>
10-
<button mat-menu-item> Help </button>
9+
<button mat-menu-item>Settings</button>
10+
<button mat-menu-item>Help</button>
1111
</mat-menu>
1212

1313
<button mat-icon-button [matMenuTriggerFor]="appMenu">
14-
<mat-icon>more_vert</mat-icon>
14+
<mat-icon>more_vert</mat-icon>
1515
</button>
1616
```
1717

@@ -36,16 +36,16 @@ Menus support displaying `mat-icon` elements before the menu item text.
3636
```html
3737
<mat-menu #menu="matMenu">
3838
<button mat-menu-item>
39-
<mat-icon> dialpad </mat-icon>
40-
<span> Redial </span>
39+
<mat-icon>dialpad</mat-icon>
40+
<span>Redial</span>
4141
</button>
4242
<button mat-menu-item disabled>
43-
<mat-icon> voicemail </mat-icon>
44-
<span> Check voicemail </span>
43+
<mat-icon>voicemail</mat-icon>
44+
<span>Check voicemail</span>
4545
</button>
4646
<button mat-menu-item>
47-
<mat-icon> notifications_off </mat-icon>
48-
<span> Disable alerts </span>
47+
<mat-icon>notifications_off</mat-icon>
48+
<span>Disable alerts</span>
4949
</button>
5050
</mat-menu>
5151
```
@@ -59,8 +59,8 @@ The position can be changed using the `xPosition` (`before | after`) and `yPosit
5959

6060
```html
6161
<mat-menu #appMenu="matMenu" yPosition="above">
62-
<button mat-menu-item> Settings </button>
63-
<button mat-menu-item> Help </button>
62+
<button mat-menu-item>Settings</button>
63+
<button mat-menu-item>Help</button>
6464
</mat-menu>
6565

6666
<button mat-icon-button [matMenuTriggerFor]="appMenu">
@@ -93,6 +93,46 @@ that should trigger the sub-menu:
9393

9494
<!-- example(nested-menu) -->
9595

96+
### Lazy rendering
97+
By default, the menu content will be initialized even when the panel is closed. To defer
98+
initialization until the menu is open, the content can be provided as an `ng-template`
99+
with the `matMenuContent` attribute:
100+
101+
```html
102+
<mat-menu #appMenu="matMenu">
103+
<ng-template matMenuContent>
104+
<button mat-menu-item>Settings</button>
105+
<button mat-menu-item>Help</button>
106+
</ng-template>
107+
</mat-menu>
108+
109+
<button mat-icon-button [matMenuTriggerFor]="appMenu">
110+
<mat-icon>more_vert</mat-icon>
111+
</button>
112+
```
113+
114+
### Passing in data to a menu
115+
When using lazy rendering, additional context data can be passed to the menu panel via
116+
the `matMenuTriggerData` input. This allows for a single menu instance to be rendered
117+
with a different set of data, depending on the trigger that opened it:
118+
119+
```html
120+
<mat-menu #appMenu="matMenu" let-user="user">
121+
<ng-template matMenuContent>
122+
<button mat-menu-item>Settings</button>
123+
<button mat-menu-item>Log off {{name}}</button>
124+
</ng-template>
125+
</mat-menu>
126+
127+
<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{name: 'Sally'}">
128+
<mat-icon>more_vert</mat-icon>
129+
</button>
130+
131+
<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{name: 'Bob'}">
132+
<mat-icon>more_vert</mat-icon>
133+
</button>
134+
```
135+
96136
### Keyboard interaction
97137
- <kbd>DOWN_ARROW</kbd>: Focuses the next menu item
98138
- <kbd>UP_ARROW</kbd>: Focuses previous menu item

0 commit comments

Comments
 (0)