Skip to content

Commit 9fed87c

Browse files
crisbetojelbourn
authored andcommitted
feat(menu): support lazy rendering and passing in context data (#9271)
* 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 36be23c commit 9fed87c

File tree

9 files changed

+289
-29
lines changed

9 files changed

+289
-29
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 outletElement: 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.outletElement.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.outletElement.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.outletElement.parentNode != null) {
101+
this.outletElement.parentNode.removeChild(this.outletElement);
101102
}
102103
}
103104

src/lib/menu/menu-content.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
// Because we support opening the same menu from different triggers (which in turn have their
59+
// own `OverlayRef` panel), we have to re-insert the host element every time, otherwise we
60+
// risk it staying attached to a pane that's no longer in the DOM.
61+
element.parentNode!.insertBefore(this._outlet.outletElement, element);
62+
this._portal.attach(this._outlet, context);
63+
}
64+
65+
ngOnDestroy() {
66+
if (this._outlet) {
67+
this._outlet.dispose();
68+
}
69+
}
70+
}

src/lib/menu/menu-directive.ts

Lines changed: 16 additions & 2 deletions
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
import {FocusOrigin} from '@angular/cdk/a11y';
@@ -129,6 +131,12 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
129131
/** List of the items inside of a menu. */
130132
@ContentChildren(MatMenuItem) items: QueryList<MatMenuItem>;
131133

134+
/**
135+
* Menu content that will be rendered lazily.
136+
* @docs-private
137+
*/
138+
@ContentChild(MatMenuContent) lazyContent: MatMenuContent;
139+
132140
/** Whether the menu should overlap its trigger. */
133141
@Input()
134142
get overlapTrigger(): boolean { return this._overlapTrigger; }
@@ -234,8 +242,14 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
234242
* @param origin Action from which the focus originated. Used to set the correct styling.
235243
*/
236244
focusFirstItem(origin: FocusOrigin = 'program'): void {
237-
// TODO(crisbeto): make the origin required when doing breaking changes.
238-
this._keyManager.setFocusOrigin(origin).setFirstItemActive();
245+
// When the content is rendered lazily, it takes a bit before the items are inside the DOM.
246+
if (this.lazyContent) {
247+
this._ngZone.onStable.asObservable()
248+
.pipe(take(1))
249+
.subscribe(() => this._keyManager.setFocusOrigin(origin).setFirstItemActive());
250+
} else {
251+
this._keyManager.setFocusOrigin(origin).setFirstItemActive();
252+
}
239253
}
240254

241255
/**

src/lib/menu/menu-module.ts

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

1820

1921
@NgModule({
@@ -23,9 +25,10 @@ import {MAT_MENU_SCROLL_STRATEGY_PROVIDER, MatMenuTrigger} from './menu-trigger'
2325
MatCommonModule,
2426
MatRippleModule,
2527
OverlayModule,
28+
PortalModule,
2629
],
27-
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatCommonModule],
28-
declarations: [MatMenu, MatMenuItem, MatMenuTrigger],
30+
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent, MatCommonModule],
31+
declarations: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent],
2932
providers: [
3033
MAT_MENU_SCROLL_STRATEGY_PROVIDER,
3134
{

src/lib/menu/menu-panel.ts

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

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

src/lib/menu/menu-trigger.ts

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

113+
/** Data to be passed along to any lazily-rendered content. */
114+
@Input('matMenuTriggerData') menuData: any;
115+
113116
/** Event emitted when the associated menu is opened. */
114117
@Output() menuOpened: EventEmitter<void> = new EventEmitter<void>();
115118

@@ -199,14 +202,21 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
199202

200203
/** Opens the menu. */
201204
openMenu(): void {
202-
if (!this._menuOpen) {
203-
this._createOverlay().attach(this._portal);
204-
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
205-
this._initMenu();
205+
if (this._menuOpen) {
206+
return;
207+
}
206208

207-
if (this.menu instanceof MatMenu) {
208-
this.menu._startAnimation();
209-
}
209+
this._createOverlay().attach(this._portal);
210+
211+
if (this.menu.lazyContent) {
212+
this.menu.lazyContent.attach(this.menuData);
213+
}
214+
215+
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
216+
this._initMenu();
217+
218+
if (this.menu instanceof MatMenu) {
219+
this.menu._startAnimation();
210220
}
211221
}
212222

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)