From 16eb6be2f7fd86a95cac00de62d5e2175c7d4f56 Mon Sep 17 00:00:00 2001 From: Kara Date: Fri, 22 Jul 2016 13:27:17 -0700 Subject: [PATCH] feat(menu): add custom position support to menu (#893) --- e2e/components/menu/menu-page.ts | 57 ++++++++++ e2e/components/menu/menu.e2e.ts | 152 ++++++++++++++++---------- src/components/menu/menu-errors.ts | 24 ++++ src/components/menu/menu-positions.ts | 4 + src/components/menu/menu-trigger.ts | 11 +- src/components/menu/menu.html | 2 +- src/components/menu/menu.scss | 10 +- src/components/menu/menu.ts | 53 ++++++++- src/core/style/_sidenav-mixins.scss | 2 +- src/demo-app/menu/menu-demo.html | 32 ++++++ src/demo-app/menu/menu-demo.scss | 5 + src/demo-app/menu/menu-demo.ts | 2 +- src/e2e-app/menu/menu-e2e.html | 30 ++++- src/e2e-app/menu/menu-e2e.ts | 13 ++- 14 files changed, 321 insertions(+), 76 deletions(-) create mode 100644 e2e/components/menu/menu-page.ts create mode 100644 src/components/menu/menu-positions.ts diff --git a/e2e/components/menu/menu-page.ts b/e2e/components/menu/menu-page.ts new file mode 100644 index 00000000000..25c4bf01d4e --- /dev/null +++ b/e2e/components/menu/menu-page.ts @@ -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(); + } +} diff --git a/e2e/components/menu/menu.e2e.ts b/e2e/components/menu/menu.e2e.ts index ddc47b9740f..fda43f6f834 100644 --- a/e2e/components/menu/menu.e2e.ts +++ b/e2e/components/menu/menu.e2e.ts @@ -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}); + }); + }); + + }); }); diff --git a/src/components/menu/menu-errors.ts b/src/components/menu/menu-errors.ts index 0dff1b769f4..6cef8180512 100644 --- a/src/components/menu/menu-errors.ts +++ b/src/components/menu/menu-errors.ts @@ -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: + `); + } +} + +/** + * 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: + `); + } +} diff --git a/src/components/menu/menu-positions.ts b/src/components/menu/menu-positions.ts new file mode 100644 index 00000000000..ed599e3d7c9 --- /dev/null +++ b/src/components/menu/menu-positions.ts @@ -0,0 +1,4 @@ + +export type MenuPositionX = 'before' | 'after'; + +export type MenuPositionY = 'above' | 'below'; diff --git a/src/components/menu/menu-trigger.ts b/src/components/menu/menu-trigger.ts index e4db939cb31..072686e0294 100644 --- a/src/components/menu/menu-trigger.ts +++ b/src/components/menu/menu-trigger.ts @@ -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} ); } } diff --git a/src/components/menu/menu.html b/src/components/menu/menu.html index 016c533be26..33fe25d6b6b 100644 --- a/src/components/menu/menu.html +++ b/src/components/menu/menu.html @@ -1,5 +1,5 @@ diff --git a/src/components/menu/menu.scss b/src/components/menu/menu.scss index bece3d37d4d..b17b8dfa160 100644 --- a/src/components/menu/menu.scss +++ b/src/components/menu/menu.scss @@ -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(); } \ No newline at end of file diff --git a/src/components/menu/menu.ts b/src/components/menu/menu.ts index 57641dc6ead..146a6c1ee3f 100644 --- a/src/components/menu/menu.ts +++ b/src/components/menu/menu.ts @@ -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; + 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); } diff --git a/src/core/style/_sidenav-mixins.scss b/src/core/style/_sidenav-mixins.scss index bc61616b022..3c72c20580a 100644 --- a/src/core/style/_sidenav-mixins.scss +++ b/src/core/style/_sidenav-mixins.scss @@ -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; diff --git a/src/demo-app/menu/menu-demo.html b/src/demo-app/menu/menu-demo.html index 3550aa04037..a28d9fd12f5 100644 --- a/src/demo-app/menu/menu-demo.html +++ b/src/demo-app/menu/menu-demo.html @@ -28,4 +28,36 @@ + + diff --git a/src/demo-app/menu/menu-demo.scss b/src/demo-app/menu/menu-demo.scss index cf1116ee1ba..795545945d0 100644 --- a/src/demo-app/menu/menu-demo.scss +++ b/src/demo-app/menu/menu-demo.scss @@ -1,8 +1,13 @@ .demo-menu { display: flex; + flex-flow: row wrap; .menu-section { width: 300px; margin: 20px; } + + .end-icon { + align-items: flex-end; + } } \ No newline at end of file diff --git a/src/demo-app/menu/menu-demo.ts b/src/demo-app/menu/menu-demo.ts index 7fde8189ebd..c51381e642d 100644 --- a/src/demo-app/menu/menu-demo.ts +++ b/src/demo-app/menu/menu-demo.ts @@ -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 { diff --git a/src/e2e-app/menu/menu-e2e.html b/src/e2e-app/menu/menu-e2e.html index 6bd271ecd02..b77eee83e80 100644 --- a/src/e2e-app/menu/menu-e2e.html +++ b/src/e2e-app/menu/menu-e2e.html @@ -4,11 +4,33 @@ - - - - + + + + + + + + + + +
+ + + + + + + + + + +
diff --git a/src/e2e-app/menu/menu-e2e.ts b/src/e2e-app/menu/menu-e2e.ts index c392674ec86..25b9d5df732 100644 --- a/src/e2e-app/menu/menu-e2e.ts +++ b/src/e2e-app/menu/menu-e2e.ts @@ -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 = '';