feat(menu): add custom position support to menu (#893)

This commit is contained in:
Kara
2016-07-22 13:27:17 -07:00
committed by Robert Messerle
parent 1efbbb934f
commit 16eb6be2f7
14 changed files with 321 additions and 76 deletions
+57
View File
@@ -0,0 +1,57 @@
import ElementFinder = protractor.ElementFinder;
export class MenuPage {
constructor() {
browser.get('/menu');
}
menu() { return element(by.css('.md-menu')); }
trigger() { return element(by.id('trigger')); }
triggerTwo() { return element(by.id('trigger-two')); }
body() { return element(by.tagName('body')); }
items(index: number) {
return element.all(by.css('[md-menu-item]')).get(index);
}
textArea() { return element(by.id('text')); }
beforeTrigger() { return element(by.id('before-t')); }
aboveTrigger() { return element(by.id('above-t')); }
combinedTrigger() { return element(by.id('combined-t')); }
beforeMenu() { return element(by.css('.md-menu.before')); }
aboveMenu() { return element(by.css('.md-menu.above')); }
combinedMenu() { return element(by.css('.md-menu.combined')); }
expectMenuPresent(expected: boolean) {
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
expect(isPresent).toBe(expected);
});
}
expectMenuLocation(el: ElementFinder, {x,y}: {x: number, y: number}) {
el.getLocation().then((loc) => {
expect(loc.x).toEqual(x);
expect(loc.y).toEqual(y);
});
}
expectMenuAlignedWith(el: ElementFinder, id: string) {
element(by.id(id)).getLocation().then((loc) => {
this.expectMenuLocation(el, {x: loc.x, y: loc.y});
});
}
getResultText() {
return this.textArea().getText();
}
}
+92 -60
View File
@@ -1,81 +1,113 @@
describe('menu', function () {
import { MenuPage } from './menu-page';
describe('menu', () => {
let page: MenuPage;
beforeEach(function() {
browser.get('/menu');
page = new MenuPage();
});
it('should open menu when the trigger is clicked', function () {
expectMenuPresent(false);
element(by.id('trigger')).click();
it('should open menu when the trigger is clicked', () => {
page.expectMenuPresent(false);
page.trigger().click();
expectMenuPresent(true);
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
page.expectMenuPresent(true);
expect(page.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', () => {
page.trigger().click();
page.body().click();
page.expectMenuPresent(false);
});
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', () => {
page.trigger().click();
page.items(0).click();
page.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', () => {
page.trigger().click();
page.items(0).click();
expect(page.getResultText()).toEqual('one');
page.trigger().click();
page.items(1).click();
expect(page.getResultText()).toEqual('two');
});
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', () => {
page.trigger().click();
page.items(2).click();
expect(page.getResultText()).toEqual('');
});
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', () => {
page.triggerTwo().click();
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
page.expectMenuAlignedWith(page.menu(), 'trigger-two');
page.body().click();
page.expectMenuPresent(false);
page.trigger().click();
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
page.expectMenuAlignedWith(page.menu(), 'trigger');
page.body().click();
page.expectMenuPresent(false);
});
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);
it('should mirror classes on host to menu template in overlay', () => {
page.trigger().click();
page.menu().getAttribute('class').then((classes) => {
expect(classes).toEqual('md-menu custom');
});
}
});
function expectMenuAlignedWith(id: string) {
element(by.id(id)).getLocation().then((loc) => {
expectMenuLocation({x: loc.x, y: loc.y});
});
}
describe('position - ', () => {
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);
it('should default menu alignment to "after below" when not set', () => {
page.trigger().click();
// menu.x should equal trigger.x, menu.y should equal trigger.y
page.expectMenuAlignedWith(page.menu(), 'trigger');
});
}
it('should align overlay end to origin end when x-position is "before"', () => {
page.beforeTrigger().click();
page.beforeTrigger().getLocation().then((trigger) => {
// the menu's right corner must be attached to the trigger's right corner.
// menu = 112px wide. trigger = 60px wide. 112 - 60 = 52px of menu to the left of trigger.
// trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x (left corner)
// menu.y should equal trigger.y because only x position has changed.
page.expectMenuLocation(page.beforeMenu(), {x: trigger.x - 52, y: trigger.y});
});
});
it('should align overlay bottom to origin bottom when y-position is "above"', () => {
page.aboveTrigger().click();
page.aboveTrigger().getLocation().then((trigger) => {
// the menu's bottom corner must be attached to the trigger's bottom corner.
// menu.x should equal trigger.x because only y position has changed.
// menu = 64px high. trigger = 20px high. 64 - 20 = 44px of menu extending up past trigger.
// trigger.y (top corner) - 44px (menu above trigger) = expected menu.y (top corner)
page.expectMenuLocation(page.aboveMenu(), {x: trigger.x, y: trigger.y - 44});
});
});
it('should align menu to top left of trigger when "below" and "above"', () => {
page.combinedTrigger().click();
page.combinedTrigger().getLocation().then((trigger) => {
// trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x
// trigger.y (top corner) - 44px (menu above trigger) = expected menu.y
page.expectMenuLocation(page.combinedMenu(), {x: trigger.x - 52, y: trigger.y - 44});
});
});
});
});
+24
View File
@@ -13,3 +13,27 @@ export class MdMenuMissingError extends MdError {
`);
}
}
/**
* Exception thrown when menu's x-position value isn't valid.
* In other words, it doesn't match 'before' or 'after'.
*/
export class MdMenuInvalidPositionX extends MdError {
constructor() {
super(`x-position value must be either 'before' or after'.
Example: <md-menu x-position="before" #menu="mdMenu"></md-menu>
`);
}
}
/**
* Exception thrown when menu's y-position value isn't valid.
* In other words, it doesn't match 'above' or 'below'.
*/
export class MdMenuInvalidPositionY extends MdError {
constructor() {
super(`y-position value must be either 'above' or below'.
Example: <md-menu y-position="above" #menu="mdMenu"></md-menu>
`);
}
}
+4
View File
@@ -0,0 +1,4 @@
export type MenuPositionX = 'before' | 'after';
export type MenuPositionY = 'above' | 'below';
+9 -2
View File
@@ -22,6 +22,10 @@ import {
import {
ConnectedPositionStrategy
} from '@angular2-material/core/overlay/position/connected-position-strategy';
import {
HorizontalConnectionPos,
VerticalConnectionPos
} from '@angular2-material/core/overlay/position/connected-position';
/**
* This directive is intended to be used in conjunction with an md-menu tag. It is
@@ -119,10 +123,13 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
* @returns ConnectedPositionStrategy
*/
private _getPosition(): ConnectedPositionStrategy {
const positionX: HorizontalConnectionPos = this.menu.positionX === 'before' ? 'end' : 'start';
const positionY: VerticalConnectionPos = this.menu.positionY === 'above' ? 'bottom' : 'top';
return this._overlay.position().connectedTo(
this._element,
{originX: 'start', originY: 'top'},
{overlayX: 'start', overlayY: 'top'}
{originX: positionX, originY: positionY},
{overlayX: positionX, overlayY: positionY}
);
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="md-menu" (click)="_emitCloseEvent()">
<div class="md-menu" [ngClass]="_classList" (click)="_emitCloseEvent()">
<ng-content></ng-content>
</div>
</template>
+8 -2
View File
@@ -14,6 +14,7 @@ $md-menu-overlay-max-width: 280px !default; // 56 * 5
$md-menu-item-height: 48px !default;
$md-menu-font-size: 16px !default;
$md-menu-side-padding: 16px !default;
$md-menu-vertical-padding: 8px !default;
.md-menu {
@include md-elevation(2);
@@ -22,10 +23,12 @@ $md-menu-side-padding: 16px !default;
// max height must be 100% of the viewport height + one row height
max-height: calc(100vh + 48px);
overflow: scroll;
overflow: auto;
-webkit-overflow-scrolling: touch; // for momentum scroll on mobile
background: md-color($md-background, 'card');
padding-top: $md-menu-vertical-padding;
padding-bottom: $md-menu-vertical-padding;
}
[md-menu-item] {
@@ -35,7 +38,6 @@ $md-menu-side-padding: 16px !default;
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
height: $md-menu-item-height;
padding: 0 $md-menu-side-padding;
@@ -55,6 +57,10 @@ $md-menu-side-padding: 16px !default;
}
}
button[md-menu-item] {
width: 100%;
}
.md-menu-click-catcher {
@include md-fullscreen();
}
+49 -4
View File
@@ -3,13 +3,17 @@
// TODO(kara): set position of menu
import {
Attribute,
Component,
ViewEncapsulation,
EventEmitter,
Input,
Output,
ViewChild,
TemplateRef,
EventEmitter
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
@Component({
moduleId: module.id,
@@ -23,9 +27,36 @@ import {
export class MdMenu {
private _showClickCatcher: boolean = false;
@Output() close = new EventEmitter;
// config object to be passed into the menu's ngClass
private _classList: Object;
positionX: MenuPositionX = 'after';
positionY: MenuPositionY = 'below';
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
constructor(@Attribute('x-position') posX: MenuPositionX,
@Attribute('y-position') posY: MenuPositionY) {
if (posX) { this._setPositionX(posX); }
if (posY) { this._setPositionY(posY); }
}
/**
* This method takes classes set on the host md-menu element and applies them on the
* menu template that displays in the overlay container. Otherwise, it's difficult
* to style the containing menu from outside the component.
* @param classes list of class names
*/
@Input('class')
set classList(classes: string) {
this._classList = classes.split(' ').reduce((obj: any, className: string) => {
obj[className] = true;
return obj;
}, {});
}
@Output() close = new EventEmitter;
/**
* 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.
@@ -35,6 +66,20 @@ export class MdMenu {
this._showClickCatcher = bool;
}
private _setPositionX(pos: MenuPositionX): void {
if ( pos !== 'before' && pos !== 'after') {
throw new MdMenuInvalidPositionX();
}
this.positionX = pos;
}
private _setPositionY(pos: MenuPositionY): void {
if ( pos !== 'above' && pos !== 'below') {
throw new MdMenuInvalidPositionY();
}
this.positionY = pos;
}
private _emitCloseEvent(): void {
this.close.emit(null);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/* This mixin ensures an element spans the whole viewport.*/
@mixin md-fullscreen {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
+32
View File
@@ -28,4 +28,36 @@
</a>
</md-menu>
</div>
<div class="menu-section">
<p>
Position x: before
</p>
<md-toolbar class="end-icon">
<button md-icon-button [md-menu-trigger-for]="posXMenu">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>
<md-menu x-position="before" #posXMenu="mdMenu" class="before">
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
{{ item.text }}
</button>
</md-menu>
</div>
<div class="menu-section">
<p>
Position y: above
</p>
<md-toolbar>
<button md-icon-button [md-menu-trigger-for]="posYMenu">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>
<md-menu y-position="above" #posYMenu="mdMenu">
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
{{ item.text }}
</button>
</md-menu>
</div>
</div>
+5
View File
@@ -1,8 +1,13 @@
.demo-menu {
display: flex;
flex-flow: row wrap;
.menu-section {
width: 300px;
margin: 20px;
}
.end-icon {
align-items: flex-end;
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import {MD_TOOLBAR_DIRECTIVES} from '@angular2-material/toolbar/toolbar';
MD_MENU_DIRECTIVES,
MD_ICON_DIRECTIVES,
MD_BUTTON_DIRECTIVES,
MD_TOOLBAR_DIRECTIVES
MD_TOOLBAR_DIRECTIVES,
]
})
export class MenuDemo {
+26 -4
View File
@@ -4,11 +4,33 @@
<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 #menu="mdMenu" class="custom">
<button md-menu-item (click)="selected='one'">One</button>
<button md-menu-item (click)="selected='two'">Two</button>
<button md-menu-item (click)="selected='three'" disabled>Three</button>
</md-menu>
<button [md-menu-trigger-for]="beforeMenu" id="before-t">
BEFORE
</button>
<md-menu x-position="before" class="before" #beforeMenu="mdMenu">
<button md-menu-item>Item</button>
</md-menu>
<div class="bottom-row">
<button [md-menu-trigger-for]="aboveMenu" id="above-t">ABOVE</button>
<md-menu y-position="above" class="above" #aboveMenu="mdMenu">
<button md-menu-item>Item</button>
</md-menu>
<button [md-menu-trigger-for]="combined" id="combined-t">
BOTH
</button>
<md-menu x-position="before" y-position="above" class="combined" #combined="mdMenu">
<button md-menu-item>Item</button>
</md-menu>
</div>
</div>
</div>
+12 -1
View File
@@ -5,7 +5,18 @@ import {MD_MENU_DIRECTIVES} from '@angular2-material/menu/menu-trigger';
moduleId: module.id,
selector: 'menu-e2e',
templateUrl: 'menu-e2e.html',
directives: [MD_MENU_DIRECTIVES]
directives: [MD_MENU_DIRECTIVES],
styles: [`
#before-t, #above-t, #combined-t {
width: 60px;
height: 20px;
}
.bottom-row {
position: absolute;
top: 100px;
}
`]
})
export class MenuE2E {
selected: string = '';