Skip to content

Commit

Permalink
feat(menu): support lazy rendering and passing in context data (#9271)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
crisbeto authored and jelbourn committed Jan 25, 2018
1 parent 36be23c commit 9fed87c
Show file tree
Hide file tree
Showing 9 changed files with 289 additions and 29 deletions.
11 changes: 6 additions & 5 deletions src/cdk/portal/dom-portal-outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {BasePortalOutlet, ComponentPortal, TemplatePortal} from './portal';
*/
export class DomPortalOutlet extends BasePortalOutlet {
constructor(
private _hostDomElement: Element,
/** Element into which the content is projected. */
public outletElement: Element,
private _componentFactoryResolver: ComponentFactoryResolver,
private _appRef: ApplicationRef,
private _defaultInjector: Injector) {
Expand Down Expand Up @@ -59,7 +60,7 @@ export class DomPortalOutlet extends BasePortalOutlet {
}
// At this point the component has been instantiated, so we move it to the location in the DOM
// where we want it to be rendered.
this._hostDomElement.appendChild(this._getComponentRootNode(componentRef));
this.outletElement.appendChild(this._getComponentRootNode(componentRef));

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

this.setDisposeFn((() => {
let index = viewContainer.indexOf(viewRef);
Expand All @@ -96,8 +97,8 @@ export class DomPortalOutlet extends BasePortalOutlet {
*/
dispose(): void {
super.dispose();
if (this._hostDomElement.parentNode != null) {
this._hostDomElement.parentNode.removeChild(this._hostDomElement);
if (this.outletElement.parentNode != null) {
this.outletElement.parentNode.removeChild(this.outletElement);
}
}

Expand Down
70 changes: 70 additions & 0 deletions src/lib/menu/menu-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {
Directive,
TemplateRef,
ComponentFactoryResolver,
ApplicationRef,
Injector,
ViewContainerRef,
Inject,
OnDestroy,
} from '@angular/core';
import {TemplatePortal, DomPortalOutlet} from '@angular/cdk/portal';
import {DOCUMENT} from '@angular/common';

/**
* Menu content that will be rendered lazily once the menu is opened.
*/
@Directive({
selector: 'ng-template[matMenuContent]'
})
export class MatMenuContent implements OnDestroy {
private _portal: TemplatePortal<any>;
private _outlet: DomPortalOutlet;

constructor(
private _template: TemplateRef<any>,
private _componentFactoryResolver: ComponentFactoryResolver,
private _appRef: ApplicationRef,
private _injector: Injector,
private _viewContainerRef: ViewContainerRef,
@Inject(DOCUMENT) private _document: any) {}

/**
* Attaches the content with a particular context.
* @docs-private
*/
attach(context: any = {}) {
if (!this._portal) {
this._portal = new TemplatePortal(this._template, this._viewContainerRef);
} else if (this._portal.isAttached) {
this._portal.detach();
}

if (!this._outlet) {
this._outlet = new DomPortalOutlet(this._document.createElement('div'),
this._componentFactoryResolver, this._appRef, this._injector);
}

const element: HTMLElement = this._template.elementRef.nativeElement;

// Because we support opening the same menu from different triggers (which in turn have their
// own `OverlayRef` panel), we have to re-insert the host element every time, otherwise we
// risk it staying attached to a pane that's no longer in the DOM.
element.parentNode!.insertBefore(this._outlet.outletElement, element);
this._portal.attach(this._outlet, context);
}

ngOnDestroy() {
if (this._outlet) {
this._outlet.dispose();
}
}
}
18 changes: 16 additions & 2 deletions src/lib/menu/menu-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ContentChild,
ContentChildren,
ElementRef,
EventEmitter,
Expand All @@ -38,6 +39,7 @@ import {matMenuAnimations} from './menu-animations';
import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors';
import {MatMenuItem} from './menu-item';
import {MatMenuPanel} from './menu-panel';
import {MatMenuContent} from './menu-content';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {FocusOrigin} from '@angular/cdk/a11y';
Expand Down Expand Up @@ -129,6 +131,12 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
/** List of the items inside of a menu. */
@ContentChildren(MatMenuItem) items: QueryList<MatMenuItem>;

/**
* Menu content that will be rendered lazily.
* @docs-private
*/
@ContentChild(MatMenuContent) lazyContent: MatMenuContent;

/** Whether the menu should overlap its trigger. */
@Input()
get overlapTrigger(): boolean { return this._overlapTrigger; }
Expand Down Expand Up @@ -234,8 +242,14 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnDestroy {
* @param origin Action from which the focus originated. Used to set the correct styling.
*/
focusFirstItem(origin: FocusOrigin = 'program'): void {
// TODO(crisbeto): make the origin required when doing breaking changes.
this._keyManager.setFocusOrigin(origin).setFirstItemActive();
// When the content is rendered lazily, it takes a bit before the items are inside the DOM.
if (this.lazyContent) {
this._ngZone.onStable.asObservable()
.pipe(take(1))
.subscribe(() => this._keyManager.setFocusOrigin(origin).setFirstItemActive());
} else {
this._keyManager.setFocusOrigin(origin).setFirstItemActive();
}
}

/**
Expand Down
7 changes: 5 additions & 2 deletions src/lib/menu/menu-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {OverlayModule} from '@angular/cdk/overlay';
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {MatCommonModule, MatRippleModule} from '@angular/material/core';
import {PortalModule} from '@angular/cdk/portal';
import {MAT_MENU_DEFAULT_OPTIONS, MatMenu} from './menu-directive';
import {MatMenuItem} from './menu-item';
import {MAT_MENU_SCROLL_STRATEGY_PROVIDER, MatMenuTrigger} from './menu-trigger';
import {MatMenuContent} from './menu-content';


@NgModule({
Expand All @@ -23,9 +25,10 @@ import {MAT_MENU_SCROLL_STRATEGY_PROVIDER, MatMenuTrigger} from './menu-trigger'
MatCommonModule,
MatRippleModule,
OverlayModule,
PortalModule,
],
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatCommonModule],
declarations: [MatMenu, MatMenuItem, MatMenuTrigger],
exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent, MatCommonModule],
declarations: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent],
providers: [
MAT_MENU_SCROLL_STRATEGY_PROVIDER,
{
Expand Down
2 changes: 2 additions & 0 deletions src/lib/menu/menu-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {EventEmitter, TemplateRef} from '@angular/core';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {Direction} from '@angular/cdk/bidi';
import {FocusOrigin} from '@angular/cdk/a11y';
import {MatMenuContent} from './menu-content';

/**
* Interface for a custom menu panel that can be used with `matMenuTriggerFor`.
Expand All @@ -27,4 +28,5 @@ export interface MatMenuPanel {
resetActiveItem: () => void;
setPositionClasses: (x: MenuPositionX, y: MenuPositionY) => void;
setElevation?(depth: number): void;
lazyContent?: MatMenuContent;
}
24 changes: 17 additions & 7 deletions src/lib/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
/** References the menu instance that the trigger is associated with. */
@Input('matMenuTriggerFor') menu: MatMenuPanel;

/** Data to be passed along to any lazily-rendered content. */
@Input('matMenuTriggerData') menuData: any;

/** Event emitted when the associated menu is opened. */
@Output() menuOpened: EventEmitter<void> = new EventEmitter<void>();

Expand Down Expand Up @@ -199,14 +202,21 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {

/** Opens the menu. */
openMenu(): void {
if (!this._menuOpen) {
this._createOverlay().attach(this._portal);
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
this._initMenu();
if (this._menuOpen) {
return;
}

if (this.menu instanceof MatMenu) {
this.menu._startAnimation();
}
this._createOverlay().attach(this._portal);

if (this.menu.lazyContent) {
this.menu.lazyContent.attach(this.menuData);
}

this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
this._initMenu();

if (this.menu instanceof MatMenu) {
this.menu._startAnimation();
}
}

Expand Down
62 changes: 51 additions & 11 deletions src/lib/menu/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ By itself, the `<mat-menu>` element does not render anything. The menu is attach
via application of the `matMenuTriggerFor` directive:
```html
<mat-menu #appMenu="matMenu">
<button mat-menu-item> Settings </button>
<button mat-menu-item> Help </button>
<button mat-menu-item>Settings</button>
<button mat-menu-item>Help</button>
</mat-menu>

<button mat-icon-button [matMenuTriggerFor]="appMenu">
<mat-icon>more_vert</mat-icon>
<mat-icon>more_vert</mat-icon>
</button>
```

Expand All @@ -36,16 +36,16 @@ Menus support displaying `mat-icon` elements before the menu item text.
```html
<mat-menu #menu="matMenu">
<button mat-menu-item>
<mat-icon> dialpad </mat-icon>
<span> Redial </span>
<mat-icon>dialpad</mat-icon>
<span>Redial</span>
</button>
<button mat-menu-item disabled>
<mat-icon> voicemail </mat-icon>
<span> Check voicemail </span>
<mat-icon>voicemail</mat-icon>
<span>Check voicemail</span>
</button>
<button mat-menu-item>
<mat-icon> notifications_off </mat-icon>
<span> Disable alerts </span>
<mat-icon>notifications_off</mat-icon>
<span>Disable alerts</span>
</button>
</mat-menu>
```
Expand All @@ -59,8 +59,8 @@ The position can be changed using the `xPosition` (`before | after`) and `yPosit

```html
<mat-menu #appMenu="matMenu" yPosition="above">
<button mat-menu-item> Settings </button>
<button mat-menu-item> Help </button>
<button mat-menu-item>Settings</button>
<button mat-menu-item>Help</button>
</mat-menu>

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

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

### Lazy rendering
By default, the menu content will be initialized even when the panel is closed. To defer
initialization until the menu is open, the content can be provided as an `ng-template`
with the `matMenuContent` attribute:

```html
<mat-menu #appMenu="matMenu">
<ng-template matMenuContent>
<button mat-menu-item>Settings</button>
<button mat-menu-item>Help</button>
</ng-template>
</mat-menu>

<button mat-icon-button [matMenuTriggerFor]="appMenu">
<mat-icon>more_vert</mat-icon>
</button>
```

### Passing in data to a menu
When using lazy rendering, additional context data can be passed to the menu panel via
the `matMenuTriggerData` input. This allows for a single menu instance to be rendered
with a different set of data, depending on the trigger that opened it:

```html
<mat-menu #appMenu="matMenu" let-user="user">
<ng-template matMenuContent>
<button mat-menu-item>Settings</button>
<button mat-menu-item>Log off {{name}}</button>
</ng-template>
</mat-menu>

<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{name: 'Sally'}">
<mat-icon>more_vert</mat-icon>
</button>

<button mat-icon-button [matMenuTriggerFor]="appMenu" [matMenuTriggerData]="{name: 'Bob'}">
<mat-icon>more_vert</mat-icon>
</button>
```

### Keyboard interaction
- <kbd>DOWN_ARROW</kbd>: Focuses the next menu item
- <kbd>UP_ARROW</kbd>: Focuses previous menu item
Expand Down
Loading

0 comments on commit 9fed87c

Please sign in to comment.