Skip to content

Commit 9a32489

Browse files
kararobertmesserle
authored andcommitted
feat(menu): add menu trigger support (#867)
* feat(menu): add menu trigger support * addressed comments * lazy create menu
1 parent f0965ba commit 9a32489

File tree

19 files changed

+432
-41
lines changed

19 files changed

+432
-41
lines changed

e2e/components/menu/menu.e2e.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
describe('menu', function () {
2+
beforeEach(function() {
3+
browser.get('/menu');
4+
});
5+
6+
it('should open menu when the trigger is clicked', function () {
7+
expectMenuPresent(false);
8+
element(by.id('trigger')).click();
9+
10+
expectMenuPresent(true);
11+
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
12+
});
13+
14+
it('should align menu when open', function() {
15+
element(by.id('trigger')).click();
16+
expectMenuAlignedWith('trigger');
17+
});
18+
19+
it('should close menu when area outside menu is clicked', function () {
20+
element(by.id('trigger')).click();
21+
element(by.tagName('body')).click();
22+
expectMenuPresent(false);
23+
});
24+
25+
it('should close menu when menu item is clicked', function () {
26+
element(by.id('trigger')).click();
27+
element(by.id('one')).click();
28+
expectMenuPresent(false);
29+
});
30+
31+
it('should run click handlers on regular menu items', function() {
32+
element(by.id('trigger')).click();
33+
element(by.id('one')).click();
34+
expect(element(by.id('text')).getText()).toEqual('one');
35+
36+
element(by.id('trigger')).click();
37+
element(by.id('two')).click();
38+
expect(element(by.id('text')).getText()).toEqual('two');
39+
});
40+
41+
it('should run not run click handlers on disabled menu items', function() {
42+
element(by.id('trigger')).click();
43+
element(by.id('three')).click();
44+
expect(element(by.id('text')).getText()).toEqual('');
45+
});
46+
47+
it('should support multiple triggers opening the same menu', function() {
48+
element(by.id('trigger-two')).click();
49+
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
50+
expectMenuAlignedWith('trigger-two');
51+
52+
element(by.tagName('body')).click();
53+
expectMenuPresent(false);
54+
55+
element(by.id('trigger')).click();
56+
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
57+
expectMenuAlignedWith('trigger');
58+
59+
element(by.tagName('body')).click();
60+
expectMenuPresent(false);
61+
});
62+
63+
function expectMenuPresent(bool: boolean) {
64+
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
65+
expect(isPresent).toBe(bool);
66+
});
67+
}
68+
69+
function expectMenuAlignedWith(id: string) {
70+
element(by.id(id)).getLocation().then((loc) => {
71+
expectMenuLocation({x: loc.x, y: loc.y});
72+
});
73+
}
74+
75+
function expectMenuLocation({x,y}: {x: number, y: number}) {
76+
element(by.css('.md-menu')).getLocation().then((loc) => {
77+
expect(loc.x).toEqual(x);
78+
expect(loc.y).toEqual(y);
79+
});
80+
}
81+
});

src/components/menu/menu-errors.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {MdError} from '@angular2-material/core/errors/error';
2+
3+
/**
4+
* Exception thrown when menu trigger doesn't have a valid md-menu instance
5+
*/
6+
export class MdMenuMissingError extends MdError {
7+
constructor() {
8+
super(`md-menu-trigger: must pass in an md-menu instance.
9+
10+
Example:
11+
<md-menu #menu="mdMenu"></md-menu>
12+
<button [md-menu-trigger-for]="menu"></button>
13+
`);
14+
}
15+
}

src/components/menu/menu-item.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {Directive, Input, HostBinding} from '@angular/core';
2+
3+
/**
4+
* This directive is intended to be used inside an md-menu tag.
5+
* It exists mostly to set the role attribute.
6+
*/
7+
@Directive({
8+
selector: 'button[md-menu-item]',
9+
host: {'role': 'menuitem'}
10+
})
11+
export class MdMenuItem {}
12+
13+
/**
14+
* This directive is intended to be used inside an md-menu tag.
15+
* It sets the role attribute and adds support for the disabled property to anchors.
16+
*/
17+
@Directive({
18+
selector: 'a[md-menu-item]',
19+
host: {
20+
'role': 'menuitem',
21+
'(click)': 'checkDisabled($event)'
22+
}
23+
})
24+
export class MdMenuAnchor {
25+
_disabled: boolean;
26+
27+
@HostBinding('attr.disabled')
28+
@Input()
29+
get disabled(): boolean {
30+
return this._disabled;
31+
}
32+
33+
set disabled(value: boolean) {
34+
this._disabled = (value === false || value === undefined) ? null : true;
35+
}
36+
37+
@HostBinding('attr.aria-disabled')
38+
get isAriaDisabled(): string {
39+
return String(this.disabled);
40+
}
41+
42+
@HostBinding('tabIndex')
43+
get tabIndex(): number {
44+
return this.disabled ? -1 : 0;
45+
}
46+
47+
checkDisabled(event: Event) {
48+
if (this.disabled) {
49+
event.preventDefault();
50+
event.stopPropagation();
51+
}
52+
}
53+
}

src/components/menu/menu-trigger.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
Directive,
3+
ElementRef,
4+
Input,
5+
Output,
6+
EventEmitter,
7+
HostListener,
8+
ViewContainerRef,
9+
AfterViewInit,
10+
OnDestroy
11+
} from '@angular/core';
12+
import {MdMenu} from './menu';
13+
import {MdMenuItem, MdMenuAnchor} from './menu-item';
14+
import {MdMenuMissingError} from './menu-errors';
15+
import {
16+
Overlay,
17+
OverlayState,
18+
OverlayRef,
19+
OVERLAY_PROVIDERS,
20+
TemplatePortal
21+
} from '@angular2-material/core/core';
22+
import {
23+
ConnectedPositionStrategy
24+
} from '@angular2-material/core/overlay/position/connected-position-strategy';
25+
26+
/**
27+
* This directive is intended to be used in conjunction with an md-menu tag. It is
28+
* responsible for toggling the display of the provided menu instance.
29+
*/
30+
@Directive({
31+
selector: '[md-menu-trigger-for]',
32+
host: {'aria-haspopup': 'true'},
33+
providers: [OVERLAY_PROVIDERS],
34+
exportAs: 'mdMenuTrigger'
35+
})
36+
export class MdMenuTrigger implements AfterViewInit, OnDestroy {
37+
private _portal: TemplatePortal;
38+
private _overlayRef: OverlayRef;
39+
menuOpen: boolean = false;
40+
41+
@Input('md-menu-trigger-for') menu: MdMenu;
42+
@Output() onMenuOpen = new EventEmitter();
43+
@Output() onMenuClose = new EventEmitter();
44+
45+
constructor(private _overlay: Overlay, private _element: ElementRef,
46+
private _viewContainerRef: ViewContainerRef) {}
47+
48+
ngAfterViewInit() {
49+
this._checkMenu();
50+
this.menu.close.subscribe(() => this.closeMenu());
51+
}
52+
53+
ngOnDestroy() { this.destroyMenu(); }
54+
55+
@HostListener('click')
56+
toggleMenu(): Promise<void> {
57+
return this.menuOpen ? this.closeMenu() : this.openMenu();
58+
}
59+
60+
openMenu(): Promise<void> {
61+
return this._createOverlay()
62+
.then(() => this._overlayRef.attach(this._portal))
63+
.then(() => this._setIsMenuOpen(true));
64+
}
65+
66+
closeMenu(): Promise<void> {
67+
if (!this._overlayRef) { return Promise.resolve(); }
68+
69+
return this._overlayRef.detach()
70+
.then(() => this._setIsMenuOpen(false));
71+
}
72+
73+
destroyMenu(): void {
74+
this._overlayRef.dispose();
75+
}
76+
77+
// set state rather than toggle to support triggers sharing a menu
78+
private _setIsMenuOpen(isOpen: boolean): void {
79+
this.menuOpen = isOpen;
80+
this.menu._setClickCatcher(isOpen);
81+
this.menuOpen ? this.onMenuOpen.emit(null) : this.onMenuClose.emit(null);
82+
}
83+
84+
/**
85+
* This method checks that a valid instance of MdMenu has been passed into
86+
* md-menu-trigger-for. If not, an exception is thrown.
87+
*/
88+
private _checkMenu() {
89+
if (!this.menu || !(this.menu instanceof MdMenu)) {
90+
throw new MdMenuMissingError();
91+
}
92+
}
93+
94+
/**
95+
* This method creates the overlay from the provided menu's template and saves its
96+
* OverlayRef so that it can be attached to the DOM when openMenu is called.
97+
*/
98+
private _createOverlay(): Promise<any> {
99+
if (this._overlayRef) { return Promise.resolve(); }
100+
101+
this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef);
102+
return this._overlay.create(this._getOverlayConfig())
103+
.then(overlay => this._overlayRef = overlay);
104+
}
105+
106+
/**
107+
* This method builds the configuration object needed to create the overlay, the OverlayState.
108+
* @returns OverlayState
109+
*/
110+
private _getOverlayConfig(): OverlayState {
111+
const overlayState = new OverlayState();
112+
overlayState.positionStrategy = this._getPosition();
113+
return overlayState;
114+
}
115+
116+
/**
117+
* This method builds the position strategy for the overlay, so the menu is properly connected
118+
* to the trigger.
119+
* @returns ConnectedPositionStrategy
120+
*/
121+
private _getPosition(): ConnectedPositionStrategy {
122+
return this._overlay.position().connectedTo(
123+
this._element,
124+
{originX: 'start', originY: 'top'},
125+
{overlayX: 'start', overlayY: 'top'}
126+
);
127+
}
128+
}
129+
130+
export const MD_MENU_DIRECTIVES = [MdMenu, MdMenuItem, MdMenuTrigger, MdMenuAnchor];

src/components/menu/menu.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
<div class="md-menu">
2-
<ng-content></ng-content>
3-
</div>
4-
1+
<template>
2+
<div class="md-menu" (click)="_emitCloseEvent()">
3+
<ng-content></ng-content>
4+
</div>
5+
</template>
6+
<div class="md-menu-click-catcher" *ngIf="_showClickCatcher" (click)="_emitCloseEvent()"></div>

src/components/menu/menu.scss

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
// TODO(kara): update vars for desktop when MD team responds
2-
2+
// TODO(kara): animation for menu opening
33
@import 'variables';
44
@import 'elevation';
55
@import 'default-theme';
66
@import 'button-mixins';
7+
@import 'sidenav-mixins';
78
@import 'list-shared';
89

910
// menu width must be a multiple of 56px
@@ -24,21 +25,24 @@ $md-menu-side-padding: 16px !default;
2425
overflow: scroll;
2526
-webkit-overflow-scrolling: touch; // for momentum scroll on mobile
2627

27-
background: md-color($md-background, 'background');
28+
background: md-color($md-background, 'card');
2829
}
2930

3031
[md-menu-item] {
3132
@include md-button-reset();
3233
@include md-truncate-line();
3334

34-
display: block;
35+
display: flex;
36+
flex-direction: row;
37+
align-items: center;
3538
width: 100%;
3639
height: $md-menu-item-height;
3740
padding: 0 $md-menu-side-padding;
3841

3942
font-size: $md-menu-font-size;
4043
font-family: $md-font-family;
4144
text-align: start;
45+
text-decoration: none; // necessary to reset anchor tags
4246
color: md-color($md-foreground, 'text');
4347

4448
&[disabled] {
@@ -51,3 +55,6 @@ $md-menu-side-padding: 16px !default;
5155
}
5256
}
5357

58+
.md-menu-click-catcher {
59+
@include md-fullscreen();
60+
}

src/components/menu/menu.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {inject} from '@angular/core/testing';
22
import {TestComponentBuilder} from '@angular/compiler/testing';
33
import {Component} from '@angular/core';
44

5-
import {MD_MENU_DIRECTIVES} from './menu';
5+
import {MD_MENU_DIRECTIVES} from './menu-trigger';
66

77
describe('MdMenu', () => {
88
let builder: TestComponentBuilder;

src/components/menu/menu.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import {Component, Directive, ViewEncapsulation} from '@angular/core';
1+
// TODO(kara): keyboard events for menu navigation
2+
// TODO(kara): prevent-close functionality
3+
// TODO(kara): set position of menu
4+
5+
import {
6+
Component,
7+
ViewEncapsulation,
8+
Output,
9+
ViewChild,
10+
TemplateRef,
11+
EventEmitter
12+
} from '@angular/core';
213

314
@Component({
415
moduleId: module.id,
@@ -9,13 +20,23 @@ import {Component, Directive, ViewEncapsulation} from '@angular/core';
920
encapsulation: ViewEncapsulation.None,
1021
exportAs: 'mdMenu'
1122
})
12-
export class MdMenu {}
23+
export class MdMenu {
24+
private _showClickCatcher: boolean = false;
1325

14-
@Directive({
15-
selector: '[md-menu-item]',
16-
host: {'role': 'menuitem'}
17-
})
18-
export class MdMenuItem {}
26+
@Output() close = new EventEmitter;
27+
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
28+
29+
/**
30+
* This function toggles the display of the menu's click catcher element.
31+
* This element covers the viewport when the menu is open to detect clicks outside the menu.
32+
* TODO: internal
33+
*/
34+
_setClickCatcher(bool: boolean): void {
35+
this._showClickCatcher = bool;
36+
}
1937

20-
export const MD_MENU_DIRECTIVES = [MdMenu, MdMenuItem];
38+
private _emitCloseEvent(): void {
39+
this.close.emit(null);
40+
}
41+
}
2142

0 commit comments

Comments
 (0)