mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(menu): add custom position support to menu (#893)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
export type MenuPositionX = 'before' | 'after';
|
||||
|
||||
export type MenuPositionY = 'above' | 'below';
|
||||
@@ -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,5 +1,5 @@
|
||||
<template>
|
||||
<div class="md-menu" (click)="_emitCloseEvent()">
|
||||
<div class="md-menu" [ngClass]="_classList" (click)="_emitCloseEvent()">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
.demo-menu {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
|
||||
.menu-section {
|
||||
width: 300px;
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.end-icon {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user