diff --git a/e2e/components/menu/menu.e2e.ts b/e2e/components/menu/menu.e2e.ts
new file mode 100644
index 000000000000..ddc47b9740fb
--- /dev/null
+++ b/e2e/components/menu/menu.e2e.ts
@@ -0,0 +1,81 @@
+describe('menu', function () {
+ beforeEach(function() {
+ browser.get('/menu');
+ });
+
+ it('should open menu when the trigger is clicked', function () {
+ expectMenuPresent(false);
+ element(by.id('trigger')).click();
+
+ expectMenuPresent(true);
+ expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
+ });
+
+ it('should align menu when open', function() {
+ element(by.id('trigger')).click();
+ expectMenuAlignedWith('trigger');
+ });
+
+ it('should close menu when area outside menu is clicked', function () {
+ element(by.id('trigger')).click();
+ element(by.tagName('body')).click();
+ expectMenuPresent(false);
+ });
+
+ it('should close menu when menu item is clicked', function () {
+ element(by.id('trigger')).click();
+ element(by.id('one')).click();
+ expectMenuPresent(false);
+ });
+
+ it('should run click handlers on regular menu items', function() {
+ element(by.id('trigger')).click();
+ element(by.id('one')).click();
+ expect(element(by.id('text')).getText()).toEqual('one');
+
+ element(by.id('trigger')).click();
+ element(by.id('two')).click();
+ expect(element(by.id('text')).getText()).toEqual('two');
+ });
+
+ it('should run not run click handlers on disabled menu items', function() {
+ element(by.id('trigger')).click();
+ element(by.id('three')).click();
+ expect(element(by.id('text')).getText()).toEqual('');
+ });
+
+ it('should support multiple triggers opening the same menu', function() {
+ element(by.id('trigger-two')).click();
+ expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
+ expectMenuAlignedWith('trigger-two');
+
+ element(by.tagName('body')).click();
+ expectMenuPresent(false);
+
+ element(by.id('trigger')).click();
+ expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
+ expectMenuAlignedWith('trigger');
+
+ element(by.tagName('body')).click();
+ expectMenuPresent(false);
+ });
+
+ function expectMenuPresent(bool: boolean) {
+ return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
+ expect(isPresent).toBe(bool);
+ });
+ }
+
+ function expectMenuAlignedWith(id: string) {
+ element(by.id(id)).getLocation().then((loc) => {
+ expectMenuLocation({x: loc.x, y: loc.y});
+ });
+ }
+
+ function expectMenuLocation({x,y}: {x: number, y: number}) {
+ element(by.css('.md-menu')).getLocation().then((loc) => {
+ expect(loc.x).toEqual(x);
+ expect(loc.y).toEqual(y);
+ });
+ }
+});
diff --git a/src/components/menu/menu-errors.ts b/src/components/menu/menu-errors.ts
new file mode 100644
index 000000000000..0dff1b769f45
--- /dev/null
+++ b/src/components/menu/menu-errors.ts
@@ -0,0 +1,15 @@
+import {MdError} from '@angular2-material/core/errors/error';
+
+/**
+ * Exception thrown when menu trigger doesn't have a valid md-menu instance
+ */
+export class MdMenuMissingError extends MdError {
+ constructor() {
+ super(`md-menu-trigger: must pass in an md-menu instance.
+
+ Example:
+
+
+ `);
+ }
+}
diff --git a/src/components/menu/menu-item.ts b/src/components/menu/menu-item.ts
new file mode 100644
index 000000000000..28b198d00816
--- /dev/null
+++ b/src/components/menu/menu-item.ts
@@ -0,0 +1,53 @@
+import {Directive, Input, HostBinding} from '@angular/core';
+
+/**
+ * This directive is intended to be used inside an md-menu tag.
+ * It exists mostly to set the role attribute.
+ */
+@Directive({
+ selector: 'button[md-menu-item]',
+ host: {'role': 'menuitem'}
+})
+export class MdMenuItem {}
+
+/**
+ * This directive is intended to be used inside an md-menu tag.
+ * It sets the role attribute and adds support for the disabled property to anchors.
+ */
+@Directive({
+ selector: 'a[md-menu-item]',
+ host: {
+ 'role': 'menuitem',
+ '(click)': 'checkDisabled($event)'
+ }
+})
+export class MdMenuAnchor {
+ _disabled: boolean;
+
+ @HostBinding('attr.disabled')
+ @Input()
+ get disabled(): boolean {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = (value === false || value === undefined) ? null : true;
+ }
+
+ @HostBinding('attr.aria-disabled')
+ get isAriaDisabled(): string {
+ return String(this.disabled);
+ }
+
+ @HostBinding('tabIndex')
+ get tabIndex(): number {
+ return this.disabled ? -1 : 0;
+ }
+
+ checkDisabled(event: Event) {
+ if (this.disabled) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+}
diff --git a/src/components/menu/menu-trigger.ts b/src/components/menu/menu-trigger.ts
new file mode 100644
index 000000000000..e0972f412901
--- /dev/null
+++ b/src/components/menu/menu-trigger.ts
@@ -0,0 +1,130 @@
+import {
+ Directive,
+ ElementRef,
+ Input,
+ Output,
+ EventEmitter,
+ HostListener,
+ ViewContainerRef,
+ AfterViewInit,
+ OnDestroy
+} from '@angular/core';
+import {MdMenu} from './menu';
+import {MdMenuItem, MdMenuAnchor} from './menu-item';
+import {MdMenuMissingError} from './menu-errors';
+import {
+ Overlay,
+ OverlayState,
+ OverlayRef,
+ OVERLAY_PROVIDERS,
+ TemplatePortal
+} from '@angular2-material/core/core';
+import {
+ ConnectedPositionStrategy
+} from '@angular2-material/core/overlay/position/connected-position-strategy';
+
+/**
+ * This directive is intended to be used in conjunction with an md-menu tag. It is
+ * responsible for toggling the display of the provided menu instance.
+ */
+@Directive({
+ selector: '[md-menu-trigger-for]',
+ host: {'aria-haspopup': 'true'},
+ providers: [OVERLAY_PROVIDERS],
+ exportAs: 'mdMenuTrigger'
+})
+export class MdMenuTrigger implements AfterViewInit, OnDestroy {
+ private _portal: TemplatePortal;
+ private _overlayRef: OverlayRef;
+ menuOpen: boolean = false;
+
+ @Input('md-menu-trigger-for') menu: MdMenu;
+ @Output() onMenuOpen = new EventEmitter();
+ @Output() onMenuClose = new EventEmitter();
+
+ constructor(private _overlay: Overlay, private _element: ElementRef,
+ private _viewContainerRef: ViewContainerRef) {}
+
+ ngAfterViewInit() {
+ this._checkMenu();
+ this.menu.close.subscribe(() => this.closeMenu());
+ }
+
+ ngOnDestroy() { this.destroyMenu(); }
+
+ @HostListener('click')
+ toggleMenu(): Promise {
+ return this.menuOpen ? this.closeMenu() : this.openMenu();
+ }
+
+ openMenu(): Promise {
+ return this._createOverlay()
+ .then(() => this._overlayRef.attach(this._portal))
+ .then(() => this._setIsMenuOpen(true));
+ }
+
+ closeMenu(): Promise {
+ if (!this._overlayRef) { return Promise.resolve(); }
+
+ return this._overlayRef.detach()
+ .then(() => this._setIsMenuOpen(false));
+ }
+
+ destroyMenu(): void {
+ this._overlayRef.dispose();
+ }
+
+ // set state rather than toggle to support triggers sharing a menu
+ private _setIsMenuOpen(isOpen: boolean): void {
+ this.menuOpen = isOpen;
+ this.menu._setClickCatcher(isOpen);
+ this.menuOpen ? this.onMenuOpen.emit(null) : this.onMenuClose.emit(null);
+ }
+
+ /**
+ * This method checks that a valid instance of MdMenu has been passed into
+ * md-menu-trigger-for. If not, an exception is thrown.
+ */
+ private _checkMenu() {
+ if (!this.menu || !(this.menu instanceof MdMenu)) {
+ throw new MdMenuMissingError();
+ }
+ }
+
+ /**
+ * This method creates the overlay from the provided menu's template and saves its
+ * OverlayRef so that it can be attached to the DOM when openMenu is called.
+ */
+ private _createOverlay(): Promise {
+ if (this._overlayRef) { return Promise.resolve(); }
+
+ this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef);
+ return this._overlay.create(this._getOverlayConfig())
+ .then(overlay => this._overlayRef = overlay);
+ }
+
+ /**
+ * This method builds the configuration object needed to create the overlay, the OverlayState.
+ * @returns OverlayState
+ */
+ private _getOverlayConfig(): OverlayState {
+ const overlayState = new OverlayState();
+ overlayState.positionStrategy = this._getPosition();
+ return overlayState;
+ }
+
+ /**
+ * This method builds the position strategy for the overlay, so the menu is properly connected
+ * to the trigger.
+ * @returns ConnectedPositionStrategy
+ */
+ private _getPosition(): ConnectedPositionStrategy {
+ return this._overlay.position().connectedTo(
+ this._element,
+ {originX: 'start', originY: 'top'},
+ {overlayX: 'start', overlayY: 'top'}
+ );
+ }
+}
+
+export const MD_MENU_DIRECTIVES = [MdMenu, MdMenuItem, MdMenuTrigger, MdMenuAnchor];
diff --git a/src/components/menu/menu.html b/src/components/menu/menu.html
index a4238be74be6..016c533be26f 100644
--- a/src/components/menu/menu.html
+++ b/src/components/menu/menu.html
@@ -1,4 +1,6 @@
-
-
+
+
+
+
\ No newline at end of file
diff --git a/src/components/menu/menu.scss b/src/components/menu/menu.scss
index d4d33130da0e..bece3d37d4de 100644
--- a/src/components/menu/menu.scss
+++ b/src/components/menu/menu.scss
@@ -1,9 +1,10 @@
// TODO(kara): update vars for desktop when MD team responds
-
+// TODO(kara): animation for menu opening
@import 'variables';
@import 'elevation';
@import 'default-theme';
@import 'button-mixins';
+@import 'sidenav-mixins';
@import 'list-shared';
// menu width must be a multiple of 56px
@@ -24,14 +25,16 @@ $md-menu-side-padding: 16px !default;
overflow: scroll;
-webkit-overflow-scrolling: touch; // for momentum scroll on mobile
- background: md-color($md-background, 'background');
+ background: md-color($md-background, 'card');
}
[md-menu-item] {
@include md-button-reset();
@include md-truncate-line();
- display: block;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
width: 100%;
height: $md-menu-item-height;
padding: 0 $md-menu-side-padding;
@@ -39,6 +42,7 @@ $md-menu-side-padding: 16px !default;
font-size: $md-menu-font-size;
font-family: $md-font-family;
text-align: start;
+ text-decoration: none; // necessary to reset anchor tags
color: md-color($md-foreground, 'text');
&[disabled] {
@@ -51,3 +55,6 @@ $md-menu-side-padding: 16px !default;
}
}
+.md-menu-click-catcher {
+ @include md-fullscreen();
+}
\ No newline at end of file
diff --git a/src/components/menu/menu.spec.ts b/src/components/menu/menu.spec.ts
index f51c2e0723a3..e80a8a48d3df 100644
--- a/src/components/menu/menu.spec.ts
+++ b/src/components/menu/menu.spec.ts
@@ -2,7 +2,7 @@ import {inject} from '@angular/core/testing';
import {TestComponentBuilder} from '@angular/compiler/testing';
import {Component} from '@angular/core';
-import {MD_MENU_DIRECTIVES} from './menu';
+import {MD_MENU_DIRECTIVES} from './menu-trigger';
describe('MdMenu', () => {
let builder: TestComponentBuilder;
diff --git a/src/components/menu/menu.ts b/src/components/menu/menu.ts
index e4f90393ddaf..57641dc6eade 100644
--- a/src/components/menu/menu.ts
+++ b/src/components/menu/menu.ts
@@ -1,4 +1,15 @@
-import {Component, Directive, ViewEncapsulation} from '@angular/core';
+// TODO(kara): keyboard events for menu navigation
+// TODO(kara): prevent-close functionality
+// TODO(kara): set position of menu
+
+import {
+ Component,
+ ViewEncapsulation,
+ Output,
+ ViewChild,
+ TemplateRef,
+ EventEmitter
+} from '@angular/core';
@Component({
moduleId: module.id,
@@ -9,13 +20,23 @@ import {Component, Directive, ViewEncapsulation} from '@angular/core';
encapsulation: ViewEncapsulation.None,
exportAs: 'mdMenu'
})
-export class MdMenu {}
+export class MdMenu {
+ private _showClickCatcher: boolean = false;
-@Directive({
- selector: '[md-menu-item]',
- host: {'role': 'menuitem'}
-})
-export class MdMenuItem {}
+ @Output() close = new EventEmitter;
+ @ViewChild(TemplateRef) templateRef: TemplateRef;
+
+ /**
+ * This function toggles the display of the menu's click catcher element.
+ * This element covers the viewport when the menu is open to detect clicks outside the menu.
+ * TODO: internal
+ */
+ _setClickCatcher(bool: boolean): void {
+ this._showClickCatcher = bool;
+ }
-export const MD_MENU_DIRECTIVES = [MdMenu, MdMenuItem];
+ private _emitCloseEvent(): void {
+ this.close.emit(null);
+ }
+}
diff --git a/src/components/sidenav/sidenav.scss b/src/components/sidenav/sidenav.scss
index 96cde4db078c..6432b1b7611a 100644
--- a/src/components/sidenav/sidenav.scss
+++ b/src/components/sidenav/sidenav.scss
@@ -2,7 +2,7 @@
@import 'mixins';
@import 'variables';
@import 'elevation';
-
+@import 'sidenav-mixins';
// We use invert() here to have the darken the background color expected to be used. If the
// background is light, we use a dark backdrop. If the background is dark, we use a light backdrop.
@@ -42,16 +42,6 @@ $md-sidenav-push-background-color: md-color($md-background, dialog) !default;
}
}
-/* This mixin ensures a sidenav element spans the whole viewport.*/
-@mixin md-sidenav-fullscreen {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
-}
-
-
:host {
// We need a stacking context here so that the backdrop and drawers are clipped to the
// MdSidenavLayout. This creates a new z-index stack so we use low numbered z-indices.
@@ -64,7 +54,7 @@ $md-sidenav-push-background-color: md-color($md-background, dialog) !default;
// TODO(hansl): Update this with a more robust solution.
&[fullscreen] {
- @include md-sidenav-fullscreen();
+ @include md-fullscreen();
&.md-sidenav-opened {
overflow: hidden;
@@ -78,7 +68,7 @@ $md-sidenav-push-background-color: md-color($md-background, dialog) !default;
overflow: hidden;
& > .md-sidenav-backdrop {
- @include md-sidenav-fullscreen();
+ @include md-fullscreen();
display: block;
diff --git a/src/core/portal/portal.ts b/src/core/portal/portal.ts
index d1fbffb7716b..7317fa33321d 100644
--- a/src/core/portal/portal.ts
+++ b/src/core/portal/portal.ts
@@ -195,7 +195,8 @@ export abstract class BasePortalHost implements PortalHost {
abstract attachTemplatePortal(portal: TemplatePortal): Promise