Skip to content

Commit 0ee8dbb

Browse files
committed
feat(menu): add menu trigger support
1 parent d52e6e0 commit 0ee8dbb

File tree

20 files changed

+411
-41
lines changed

20 files changed

+411
-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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {Directive, Input, HostBinding, HostListener} 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: {'role': 'menuitem'}
20+
})
21+
export class MdMenuAnchor {
22+
_disabled: boolean;
23+
24+
@HostBinding('attr.disabled')
25+
@Input()
26+
get disabled(): boolean {
27+
return this._disabled;
28+
}
29+
30+
set disabled(value: boolean) {
31+
this._disabled = (value === false || value === undefined) ? null : true;
32+
}
33+
34+
@HostBinding('attr.aria-disabled')
35+
get isAriaDisabled(): string {
36+
return this.disabled ? 'true' : 'false';
37+
}
38+
39+
@HostBinding('tabIndex')
40+
get tabIndex(): number {
41+
return this.disabled ? -1 : 0;
42+
}
43+
44+
@HostListener('click', ['$event'])
45+
checkDisabled(event: any) {
46+
if (this.disabled) {
47+
event.preventDefault();
48+
event.stopPropagation();
49+
}
50+
}
51+
}

src/components/menu/menu-trigger.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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 _overlay: 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 _overlayBuilder: Overlay, private _element: ElementRef,
46+
private _viewContainerRef: ViewContainerRef) {}
47+
48+
ngAfterViewInit() {
49+
this._checkMenu();
50+
this._createOverlay();
51+
this.menu.close.subscribe(() => this.closeMenu());
52+
}
53+
54+
ngOnDestroy() { this.destroyMenu(); }
55+
56+
@HostListener('click')
57+
toggleMenu(): Promise<void> {
58+
return this.menuOpen ? this.closeMenu() : this.openMenu();
59+
}
60+
61+
openMenu(): Promise<void> {
62+
return this._overlay.attach(this._portal)
63+
.then(() => this._setMenuState(true));
64+
}
65+
66+
closeMenu(): Promise<void> {
67+
return this._overlay.detach()
68+
.then(() => this._setMenuState(false));
69+
}
70+
71+
destroyMenu(): void {
72+
this._overlay.dispose();
73+
}
74+
75+
// set state rather than toggle to support triggers sharing a menu
76+
private _setMenuState(bool: boolean): void {
77+
this.menuOpen = bool;
78+
this.menu._setClickCatcher(bool);
79+
this.menuOpen ? this.onMenuOpen.emit(null) : this.onMenuClose.emit(null);
80+
}
81+
82+
private _checkMenu() {
83+
if (!this.menu || !(this.menu instanceof MdMenu)) {
84+
throw new MdMenuMissingError();
85+
}
86+
}
87+
88+
private _createOverlay(): void {
89+
this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef);
90+
this._overlayBuilder.create(this._getOverlayConfig())
91+
.then(overlay => this._overlay = overlay);
92+
}
93+
94+
private _getOverlayConfig(): OverlayState {
95+
const overlayState = new OverlayState();
96+
overlayState.positionStrategy = this._getPosition();
97+
return overlayState;
98+
}
99+
100+
private _getPosition(): ConnectedPositionStrategy {
101+
return this._overlayBuilder.position().connectedTo(
102+
this._element,
103+
{originX: 'start', originY: 'top'},
104+
{overlayX: 'start', overlayY: 'top'}
105+
);
106+
}
107+
}
108+
109+
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, 'menu');
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+
* @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)