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>; detach(): Promise { - this._attachedPortal.setAttachedHost(null); + if (this._attachedPortal) { this._attachedPortal.setAttachedHost(null); } + this._attachedPortal = null; if (this._disposeFn != null) { this._disposeFn(); diff --git a/src/core/style/_sidenav-mixins.scss b/src/core/style/_sidenav-mixins.scss new file mode 100644 index 000000000000..bc61616b0227 --- /dev/null +++ b/src/core/style/_sidenav-mixins.scss @@ -0,0 +1,8 @@ +/* This mixin ensures an element spans the whole viewport.*/ +@mixin md-fullscreen { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} \ No newline at end of file diff --git a/src/demo-app/menu/menu-demo.html b/src/demo-app/menu/menu-demo.html index de51d1ed1e73..3550aa04037c 100644 --- a/src/demo-app/menu/menu-demo.html +++ b/src/demo-app/menu/menu-demo.html @@ -1,5 +1,31 @@ - - - - - \ No newline at end of file +
+ + +
diff --git a/src/demo-app/menu/menu-demo.scss b/src/demo-app/menu/menu-demo.scss index e69de29bb2d1..cf1116ee1bad 100644 --- a/src/demo-app/menu/menu-demo.scss +++ b/src/demo-app/menu/menu-demo.scss @@ -0,0 +1,8 @@ +.demo-menu { + display: flex; + + .menu-section { + width: 300px; + margin: 20px; + } +} \ No newline at end of file diff --git a/src/demo-app/menu/menu-demo.ts b/src/demo-app/menu/menu-demo.ts index d754ac5d99ba..7fde8189ebd9 100644 --- a/src/demo-app/menu/menu-demo.ts +++ b/src/demo-app/menu/menu-demo.ts @@ -1,11 +1,29 @@ import {Component} from '@angular/core'; -import {MD_MENU_DIRECTIVES} from '@angular2-material/menu/menu'; +import {MD_MENU_DIRECTIVES} from '@angular2-material/menu/menu-trigger'; +import {MD_ICON_DIRECTIVES} from '@angular2-material/icon/icon'; +import {MD_BUTTON_DIRECTIVES} from '@angular2-material/button/button'; +import {MD_TOOLBAR_DIRECTIVES} from '@angular2-material/toolbar/toolbar'; @Component({ moduleId: module.id, selector: 'menu-demo', templateUrl: 'menu-demo.html', styleUrls: ['menu-demo.css'], - directives: [MD_MENU_DIRECTIVES] + directives: [ + MD_MENU_DIRECTIVES, + MD_ICON_DIRECTIVES, + MD_BUTTON_DIRECTIVES, + MD_TOOLBAR_DIRECTIVES + ] }) -export class MenuDemo {} +export class MenuDemo { + selected = ''; + items = [ + {text: 'Refresh'}, + {text: 'Settings'}, + {text: 'Help'}, + {text: 'Sign Out', disabled: true} + ]; + + select(text: string) { this.selected = text; } +} diff --git a/src/e2e-app/e2e-app/e2e-app.html b/src/e2e-app/e2e-app/e2e-app.html index cff90869bb84..8afb09392e21 100644 --- a/src/e2e-app/e2e-app/e2e-app.html +++ b/src/e2e-app/e2e-app/e2e-app.html @@ -1,5 +1,6 @@ Button -Tabs Icon +Menu +Tabs diff --git a/src/e2e-app/e2e-app/routes.ts b/src/e2e-app/e2e-app/routes.ts index 02ddb4d40efa..558e7e27cdb7 100644 --- a/src/e2e-app/e2e-app/routes.ts +++ b/src/e2e-app/e2e-app/routes.ts @@ -3,12 +3,14 @@ import {Home} from './e2e-app'; import {ButtonE2E} from '../button/button-e2e'; import {BasicTabs} from '../tabs/tabs-e2e'; import {IconE2E} from '../icon/icon-e2e'; +import {MenuE2E} from '../menu/menu-e2e'; export const routes: RouterConfig = [ {path: '', component: Home}, {path: 'button', component: ButtonE2E}, - {path: 'tabs', component: BasicTabs}, - {path: 'icon', component: IconE2E} + {path: 'menu', component: MenuE2E}, + {path: 'icon', component: IconE2E}, + {path: 'tabs', component: BasicTabs} ]; export const E2E_APP_ROUTE_PROVIDER = provideRouter(routes); diff --git a/src/e2e-app/menu/menu-e2e.html b/src/e2e-app/menu/menu-e2e.html new file mode 100644 index 000000000000..6bd271ecd02b --- /dev/null +++ b/src/e2e-app/menu/menu-e2e.html @@ -0,0 +1,15 @@ +
+
+
{{ selected }}
+ + + + + + + + +
+
+ + diff --git a/src/e2e-app/menu/menu-e2e.ts b/src/e2e-app/menu/menu-e2e.ts new file mode 100644 index 000000000000..c392674ec868 --- /dev/null +++ b/src/e2e-app/menu/menu-e2e.ts @@ -0,0 +1,12 @@ +import {Component} from '@angular/core'; +import {MD_MENU_DIRECTIVES} from '@angular2-material/menu/menu-trigger'; + +@Component({ + moduleId: module.id, + selector: 'menu-e2e', + templateUrl: 'menu-e2e.html', + directives: [MD_MENU_DIRECTIVES] +}) +export class MenuE2E { + selected: string = ''; +} diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index 67949181b47f..f06f0ce4e8ae 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -13,6 +13,7 @@ const components = [ 'icon', 'input', 'list', + 'menu', 'progress-bar', 'progress-circle', 'radio',