Skip to content

feat(menu): support lazy rendering and passing in context data #9271

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would an end user need to ever interact with this class (i.e. should it be docs-private)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it, because we support providing your own menu instance that matches the MatMenuPanel interface.

*/
@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 @@ -232,8 +240,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 @@ -107,6 +107,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 @@ -194,14 +197,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">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of avoiding the extra matMenuContent by letting people add matMenu to an ng-template element? Something like

<ng-template matMenu>
  ...
</ng-template>

The selector for the menu would change to mat-menu, ng-template[matMenu]. The menu would then optionally inject TemplateRef and go through the extra steps if one is present.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I was going for initially, but Angular throws an error when you have a component on an ng-template. The alternative would be to use a directive, but we can't do that either, because we need to wrap the menu content in a div to add the proper styling and for the animation to work.

<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