Skip to content

Commit

Permalink
feat(menu): add menu trigger support (#867)
Browse files Browse the repository at this point in the history
* feat(menu): add menu trigger support

* addressed comments

* lazy create menu
  • Loading branch information
kara authored and robertmesserle committed Jul 21, 2016
1 parent f0965ba commit 9a32489
Show file tree
Hide file tree
Showing 19 changed files with 432 additions and 41 deletions.
81 changes: 81 additions & 0 deletions e2e/components/menu/menu.e2e.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
15 changes: 15 additions & 0 deletions src/components/menu/menu-errors.ts
Original file line number Diff line number Diff line change
@@ -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:
<md-menu #menu="mdMenu"></md-menu>
<button [md-menu-trigger-for]="menu"></button>
`);
}
}
53 changes: 53 additions & 0 deletions src/components/menu/menu-item.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
130 changes: 130 additions & 0 deletions src/components/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return this.menuOpen ? this.closeMenu() : this.openMenu();
}

openMenu(): Promise<void> {
return this._createOverlay()
.then(() => this._overlayRef.attach(this._portal))
.then(() => this._setIsMenuOpen(true));
}

closeMenu(): Promise<void> {
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<any> {
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];
10 changes: 6 additions & 4 deletions src/components/menu/menu.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<div class="md-menu">
<ng-content></ng-content>
</div>

<template>
<div class="md-menu" (click)="_emitCloseEvent()">
<ng-content></ng-content>
</div>
</template>
<div class="md-menu-click-catcher" *ngIf="_showClickCatcher" (click)="_emitCloseEvent()"></div>
13 changes: 10 additions & 3 deletions src/components/menu/menu.scss
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,21 +25,24 @@ $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;

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] {
Expand All @@ -51,3 +55,6 @@ $md-menu-side-padding: 16px !default;
}
}

.md-menu-click-catcher {
@include md-fullscreen();
}
2 changes: 1 addition & 1 deletion src/components/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 29 additions & 8 deletions src/components/menu/menu.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<any>;

/**
* 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);
}
}

Loading

0 comments on commit 9a32489

Please sign in to comment.