feat(menu): add menu trigger support (#867)

* feat(menu): add menu trigger support

* addressed comments

* lazy create menu
This commit is contained in:
Kara
2016-07-21 15:45:53 -07:00
committed by Robert Messerle
parent f0965ba53f
commit 9a32489d1a
19 changed files with 432 additions and 41 deletions
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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];
+6 -4
View File
@@ -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>
+10 -3
View File
@@ -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();
}
+1 -1
View File
@@ -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;
+29 -8
View File
@@ -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<any>;
export const MD_MENU_DIRECTIVES = [MdMenu, MdMenuItem];
/**
* 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;
}
private _emitCloseEvent(): void {
this.close.emit(null);
}
}
+3 -13
View File
@@ -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;
+2 -1
View File
@@ -195,7 +195,8 @@ export abstract class BasePortalHost implements PortalHost {
abstract attachTemplatePortal(portal: TemplatePortal): Promise<Map<string, any>>;
detach(): Promise<void> {
this._attachedPortal.setAttachedHost(null);
if (this._attachedPortal) { this._attachedPortal.setAttachedHost(null); }
this._attachedPortal = null;
if (this._disposeFn != null) {
this._disposeFn();
+8
View File
@@ -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;
}
+31 -5
View File
@@ -1,5 +1,31 @@
<md-menu>
<button md-menu-item>Refresh</button>
<button md-menu-item>Settings</button>
<button md-menu-item disabled>Help</button>
</md-menu>
<div class="demo-menu">
<div class="menu-section">
<p>You clicked on: {{ selected }}</p>
<md-toolbar>
<button md-icon-button [md-menu-trigger-for]="menu">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>
<md-menu #menu="mdMenu">
<button md-menu-item *ngFor="let item of items" (click)="select(item.text)" [disabled]="item.disabled">
{{ item.text }}
</button>
</md-menu>
</div>
<div class="menu-section">
<p> Clicking these will navigate:</p>
<md-toolbar>
<button md-icon-button [md-menu-trigger-for]="anchorMenu">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>
<md-menu #anchorMenu="mdMenu">
<a md-menu-item *ngFor="let item of items" href="http://www.google.com" [disabled]="item.disabled">
{{ item.text }}
</a>
</md-menu>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
.demo-menu {
display: flex;
.menu-section {
width: 300px;
margin: 20px;
}
}
+21 -3
View File
@@ -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; }
}
+2 -1
View File
@@ -1,5 +1,6 @@
<a md-list-item [routerLink]="['button']">Button</a>
<a md-list-item [routerLink]="['tabs']">Tabs</a>
<a md-list-item [routerLink]="['icon']">Icon</a>
<a md-list-item [routerLink]="['menu']">Menu</a>
<a md-list-item [routerLink]="['tabs']">Tabs</a>
<router-outlet></router-outlet>
+4 -2
View File
@@ -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);
+15
View File
@@ -0,0 +1,15 @@
<div>
<div style="float:left">
<div id="text">{{ selected }}</div>
<button [md-menu-trigger-for]="menu" id="trigger">TRIGGER</button>
<button [md-menu-trigger-for]="menu" id="trigger-two">TRIGGER 2</button>
<md-menu #menu="mdMenu">
<button md-menu-item id="one" (click)="selected='one'">One</button>
<button md-menu-item id="two" (click)="selected='two'">Two</button>
<button md-menu-item id="three" (click)="selected='three'" disabled>Three</button>
</md-menu>
</div>
</div>
+12
View File
@@ -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 = '';
}
+1
View File
@@ -13,6 +13,7 @@ const components = [
'icon',
'input',
'list',
'menu',
'progress-bar',
'progress-circle',
'radio',