Skip to content

Commit 6e0356d

Browse files
committed
feat(menu): add custom position feature
1 parent 29ad153 commit 6e0356d

File tree

12 files changed

+298
-58
lines changed

12 files changed

+298
-58
lines changed

e2e/components/menu/menu-page.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import ElementFinder = protractor.ElementFinder;
2+
3+
export class MenuPage {
4+
5+
constructor() {
6+
browser.get('/menu');
7+
}
8+
9+
menu() { return element(by.css('.md-menu')); }
10+
11+
trigger() { return element(by.id('trigger')); }
12+
13+
triggerTwo() { return element(by.id('trigger-two')); }
14+
15+
body() { return element(by.tagName('body')); }
16+
17+
firstItem() { return element(by.id('one')); }
18+
19+
secondItem() { return element(by.id('two')); }
20+
21+
thirdItem() { return element(by.id('three')); }
22+
23+
textArea() { return element(by.id('text')); }
24+
25+
beforeTrigger() { return element(by.id('before-t')); }
26+
27+
aboveTrigger() { return element(by.id('above-t')); }
28+
29+
combinedTrigger() { return element(by.id('combined-t')); }
30+
31+
beforeMenu() { return element(by.css('.md-menu.before')); }
32+
33+
aboveMenu() { return element(by.css('.md-menu.above')); }
34+
35+
combinedMenu() { return element(by.css('.md-menu.combined')); }
36+
37+
expectMenuPresent(expected: boolean) {
38+
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
39+
expect(isPresent).toBe(expected);
40+
});
41+
}
42+
43+
expectMenuLocation(el: ElementFinder, {x,y}: {x: number, y: number}) {
44+
el.getLocation().then((loc) => {
45+
expect(loc.x).toEqual(x);
46+
expect(loc.y).toEqual(y);
47+
});
48+
}
49+
50+
expectMenuAlignedWith(el: ElementFinder, id: string) {
51+
element(by.id(id)).getLocation().then((loc) => {
52+
this.expectMenuLocation(el, {x: loc.x, y: loc.y});
53+
});
54+
}
55+
56+
getResultText() {
57+
return this.textArea().getText();
58+
}
59+
}

e2e/components/menu/menu.e2e.ts

Lines changed: 80 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,113 @@
1+
import { MenuPage } from './menu-page';
2+
13
describe('menu', function () {
4+
let page: MenuPage;
5+
26
beforeEach(function() {
3-
browser.get('/menu');
7+
page = new MenuPage();
48
});
59

610
it('should open menu when the trigger is clicked', function () {
7-
expectMenuPresent(false);
8-
element(by.id('trigger')).click();
9-
10-
expectMenuPresent(true);
11-
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
12-
});
11+
page.expectMenuPresent(false);
12+
page.trigger().click();
1313

14-
it('should align menu when open', function() {
15-
element(by.id('trigger')).click();
16-
expectMenuAlignedWith('trigger');
14+
page.expectMenuPresent(true);
15+
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
1716
});
1817

1918
it('should close menu when area outside menu is clicked', function () {
20-
element(by.id('trigger')).click();
21-
element(by.tagName('body')).click();
22-
expectMenuPresent(false);
19+
page.trigger().click();
20+
page.body().click();
21+
page.expectMenuPresent(false);
2322
});
2423

2524
it('should close menu when menu item is clicked', function () {
26-
element(by.id('trigger')).click();
27-
element(by.id('one')).click();
28-
expectMenuPresent(false);
25+
page.trigger().click();
26+
page.firstItem().click();
27+
page.expectMenuPresent(false);
2928
});
3029

3130
it('should run click handlers on regular menu items', function() {
32-
element(by.id('trigger')).click();
33-
element(by.id('one')).click();
34-
expect(element(by.id('text')).getText()).toEqual('one');
31+
page.trigger().click();
32+
page.firstItem().click();
33+
expect(page.getResultText()).toEqual('one');
3534

36-
element(by.id('trigger')).click();
37-
element(by.id('two')).click();
38-
expect(element(by.id('text')).getText()).toEqual('two');
35+
page.trigger().click();
36+
page.secondItem().click();
37+
expect(page.getResultText()).toEqual('two');
3938
});
4039

4140
it('should run not run click handlers on disabled menu items', function() {
42-
element(by.id('trigger')).click();
43-
element(by.id('three')).click();
44-
expect(element(by.id('text')).getText()).toEqual('');
41+
page.trigger().click();
42+
page.thirdItem().click();
43+
expect(page.getResultText()).toEqual('');
4544
});
4645

4746
it('should support multiple triggers opening the same menu', function() {
48-
element(by.id('trigger-two')).click();
49-
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
50-
expectMenuAlignedWith('trigger-two');
47+
page.triggerTwo().click();
48+
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
49+
page.expectMenuAlignedWith(page.menu(), 'trigger-two');
5150

52-
element(by.tagName('body')).click();
53-
expectMenuPresent(false);
51+
page.body().click();
52+
page.expectMenuPresent(false);
5453

55-
element(by.id('trigger')).click();
56-
expect(element(by.css('.md-menu')).getText()).toEqual("One\nTwo\nThree");
57-
expectMenuAlignedWith('trigger');
54+
page.trigger().click();
55+
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
56+
page.expectMenuAlignedWith(page.menu(), 'trigger');
5857

59-
element(by.tagName('body')).click();
60-
expectMenuPresent(false);
58+
page.body().click();
59+
page.expectMenuPresent(false);
6160
});
6261

63-
function expectMenuPresent(bool: boolean) {
64-
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
65-
expect(isPresent).toBe(bool);
62+
it('should mirror classes on host to menu template in overlay', () => {
63+
page.trigger().click();
64+
page.menu().getAttribute('class').then((classes) => {
65+
expect(classes).toEqual('md-menu custom');
66+
});
67+
});
68+
69+
describe('position - ', () => {
70+
71+
it('should default menu alignment to "after below" when not set', function() {
72+
page.trigger().click();
73+
74+
// menu.x should equal trigger.x, menu.y should equal trigger.y
75+
page.expectMenuAlignedWith(page.menu(), 'trigger');
76+
});
77+
78+
it('should align overlay end to origin end when x-position is "before"', () => {
79+
page.beforeTrigger().click();
80+
page.beforeTrigger().getLocation().then((trigger) => {
81+
82+
// the menu's right corner must be attached to the trigger's right corner.
83+
// menu = 112px wide. trigger = 60px wide. 112 - 60 = 52px of menu to the left of trigger.
84+
// trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x (left corner)
85+
// menu.y should equal trigger.y because only x position has changed.
86+
page.expectMenuLocation(page.beforeMenu(), {x: trigger.x - 52, y: trigger.y});
87+
});
6688
});
67-
}
6889

69-
function expectMenuAlignedWith(id: string) {
70-
element(by.id(id)).getLocation().then((loc) => {
71-
expectMenuLocation({x: loc.x, y: loc.y});
90+
it('should align overlay bottom to origin bottom when y-position is "above"', () => {
91+
page.aboveTrigger().click();
92+
page.aboveTrigger().getLocation().then((trigger) => {
93+
94+
// the menu's bottom corner must be attached to the trigger's bottom corner.
95+
// menu.x should equal trigger.x because only y position has changed.
96+
// menu = 64px high. trigger = 20px high. 64 - 20 = 44px of menu extending up past trigger.
97+
// trigger.y (top corner) - 44px (menu above trigger) = expected menu.y (top corner)
98+
page.expectMenuLocation(page.aboveMenu(), {x: trigger.x, y: trigger.y - 44});
99+
});
72100
});
73-
}
74101

75-
function expectMenuLocation({x,y}: {x: number, y: number}) {
76-
element(by.css('.md-menu')).getLocation().then((loc) => {
77-
expect(loc.x).toEqual(x);
78-
expect(loc.y).toEqual(y);
102+
it('should align menu to top left of trigger when "below" and "above"', () => {
103+
page.combinedTrigger().click();
104+
page.combinedTrigger().getLocation().then((trigger) => {
105+
106+
// trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x
107+
// trigger.y (top corner) - 44px (menu above trigger) = expected menu.y
108+
page.expectMenuLocation(page.combinedMenu(), {x: trigger.x - 52, y: trigger.y - 44});
109+
});
79110
});
80-
}
111+
112+
});
81113
});

src/components/menu/menu-errors.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,27 @@ export class MdMenuMissingError extends MdError {
1313
`);
1414
}
1515
}
16+
17+
/**
18+
* Exception thrown when menu's x-position value isn't valid.
19+
* In other words, it doesn't match 'before' or 'after'.
20+
*/
21+
export class MdMenuInvalidPositionX extends MdError {
22+
constructor() {
23+
super(`x-position value must be either 'before' or after'.
24+
Example: <md-menu x-position="before" #menu="mdMenu"></md-menu>
25+
`);
26+
}
27+
}
28+
29+
/**
30+
* Exception thrown when menu's y-position value isn't valid.
31+
* In other words, it doesn't match 'above' or 'below'.
32+
*/
33+
export class MdMenuInvalidPositionY extends MdError {
34+
constructor() {
35+
super(`y-position value must be either 'above' or below'.
36+
Example: <md-menu y-position="above" #menu="mdMenu"></md-menu>
37+
`);
38+
}
39+
}

src/components/menu/menu-trigger.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import {
2222
import {
2323
ConnectedPositionStrategy
2424
} from '@angular2-material/core/overlay/position/connected-position-strategy';
25+
import {
26+
HorizontalConnectionPos,
27+
VerticalConnectionPos
28+
} from '@angular2-material/core/overlay/position/connected-position';
2529

2630
/**
2731
* This directive is intended to be used in conjunction with an md-menu tag. It is
@@ -115,10 +119,13 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
115119
* @returns ConnectedPositionStrategy
116120
*/
117121
private _getPosition(): ConnectedPositionStrategy {
122+
const positionX: HorizontalConnectionPos = this.menu.positionX === 'before' ? 'end' : 'start';
123+
const positionY: VerticalConnectionPos = this.menu.positionY === 'above' ? 'bottom' : 'top';
124+
118125
return this._overlay.position().connectedTo(
119126
this._element,
120-
{originX: 'start', originY: 'top'},
121-
{overlayX: 'start', overlayY: 'top'}
127+
{originX: positionX, originY: positionY},
128+
{overlayX: positionX, overlayY: positionY}
122129
);
123130
}
124131
}

src/components/menu/menu.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="md-menu" (click)="_emitCloseEvent()">
2+
<div class="md-menu" [ngClass]="_classList" (click)="_emitCloseEvent()">
33
<ng-content></ng-content>
44
</div>
55
</template>

src/components/menu/menu.scss

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ $md-menu-overlay-max-width: 280px !default; // 56 * 5
1414
$md-menu-item-height: 48px !default;
1515
$md-menu-font-size: 16px !default;
1616
$md-menu-side-padding: 16px !default;
17+
$md-menu-vertical-padding: 8px !default;
1718

1819
.md-menu {
1920
@include md-elevation(2);
@@ -26,6 +27,8 @@ $md-menu-side-padding: 16px !default;
2627
-webkit-overflow-scrolling: touch; // for momentum scroll on mobile
2728

2829
background: md-color($md-background, 'card');
30+
padding-top: $md-menu-vertical-padding;
31+
padding-bottom: $md-menu-vertical-padding;
2932
}
3033

3134
[md-menu-item] {
@@ -35,7 +38,6 @@ $md-menu-side-padding: 16px !default;
3538
display: flex;
3639
flex-direction: row;
3740
align-items: center;
38-
width: 100%;
3941
height: $md-menu-item-height;
4042
padding: 0 $md-menu-side-padding;
4143

@@ -55,6 +57,10 @@ $md-menu-side-padding: 16px !default;
5557
}
5658
}
5759

60+
button[md-menu-item] {
61+
width: 100%;
62+
}
63+
5864
.md-menu-click-catcher {
5965
@include md-fullscreen();
6066
}

src/components/menu/menu.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
// TODO(kara): set position of menu
44

55
import {
6+
Attribute,
67
Component,
7-
ViewEncapsulation,
8+
EventEmitter,
89
Output,
9-
ViewChild,
1010
TemplateRef,
11-
EventEmitter
11+
ViewChild,
12+
ViewEncapsulation
1213
} from '@angular/core';
14+
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
1315

1416
@Component({
1517
moduleId: module.id,
@@ -22,10 +24,21 @@ import {
2224
})
2325
export class MdMenu {
2426
private _showClickCatcher: boolean = false;
27+
private _classList: Object;
28+
positionX: 'before' | 'after' = 'after';
29+
positionY: 'above' | 'below' = 'below';
2530

2631
@Output() close = new EventEmitter;
2732
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
2833

34+
constructor(@Attribute('x-position') posX: 'before' | 'after',
35+
@Attribute('y-position') posY: 'above' | 'below',
36+
@Attribute('class') classes: string) {
37+
if (posX) { this._setPositionX(posX); }
38+
if (posY) { this._setPositionY(posY); }
39+
this._mirrorHostClasses(classes);
40+
}
41+
2942
/**
3043
* This function toggles the display of the menu's click catcher element.
3144
* This element covers the viewport when the menu is open to detect clicks outside the menu.
@@ -35,6 +48,35 @@ export class MdMenu {
3548
this._showClickCatcher = bool;
3649
}
3750

51+
/**
52+
* This method takes classes set on the host md-menu element and applies them on the
53+
* menu template that displays in the overlay container. Otherwise, it's difficult
54+
* to style the containing menu from outside the component.
55+
* @param classes: list of class names
56+
*/
57+
private _mirrorHostClasses(classes: string): void {
58+
if (!classes) { return; }
59+
60+
this._classList = classes.split(' ').reduce((obj: any, className: string) => {
61+
obj[className] = true;
62+
return obj;
63+
}, {});
64+
}
65+
66+
private _setPositionX(pos: 'before' | 'after'): void {
67+
if ( pos !== 'before' && pos !== 'after') {
68+
throw new MdMenuInvalidPositionX();
69+
}
70+
this.positionX = pos;
71+
}
72+
73+
private _setPositionY(pos: 'above' | 'below'): void {
74+
if ( pos !== 'above' && pos !== 'below') {
75+
throw new MdMenuInvalidPositionY();
76+
}
77+
this.positionY = pos;
78+
}
79+
3880
private _emitCloseEvent(): void {
3981
this.close.emit(null);
4082
}

0 commit comments

Comments
 (0)