From db64a66cd6db0b083cce5aa86de28279414a1d6a Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Wed, 2 Nov 2016 12:07:27 -0700 Subject: [PATCH] feat(menu): add animations Closes #1671 --- e2e/components/menu/menu-page.ts | 2 +- e2e/components/menu/menu.e2e.ts | 11 ++-- src/e2e-app/e2e-app-module.ts | 5 +- src/e2e-app/system-config.ts | 2 + src/lib/core/style/_menu-common.scss | 26 ++++++-- src/lib/menu/_menu-theme.scss | 2 +- src/lib/menu/menu-animations.ts | 51 +++++++++++++++ src/lib/menu/menu-directive.ts | 26 +++++++- src/lib/menu/menu-item.html | 4 ++ src/lib/menu/menu-item.ts | 16 +++-- src/lib/menu/menu-trigger.ts | 5 +- src/lib/menu/menu.html | 8 ++- src/lib/menu/menu.scss | 17 +++++ src/lib/menu/menu.spec.ts | 93 ++++++++++++++++++++++++++-- src/lib/menu/menu.ts | 3 +- 15 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 src/lib/menu/menu-animations.ts create mode 100644 src/lib/menu/menu-item.html diff --git a/e2e/components/menu/menu-page.ts b/e2e/components/menu/menu-page.ts index 97fe77271352..3938ad7e8a0a 100644 --- a/e2e/components/menu/menu-page.ts +++ b/e2e/components/menu/menu-page.ts @@ -14,7 +14,7 @@ export class MenuPage { triggerTwo() { return element(by.id('trigger-two')); } - body() { return element(by.tagName('body')); } + backdrop() { return element(by.css('.md-overlay-backdrop')); } items(index: number) { return element.all(by.css('[md-menu-item]')).get(index); diff --git a/e2e/components/menu/menu.e2e.ts b/e2e/components/menu/menu.e2e.ts index bdda6616b2ac..564db6175bad 100644 --- a/e2e/components/menu/menu.e2e.ts +++ b/e2e/components/menu/menu.e2e.ts @@ -42,21 +42,23 @@ describe('menu', () => { expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour"); page.expectMenuAlignedWith(page.menu(), 'trigger-two'); - page.body().click(); + page.backdrop().click(); page.expectMenuPresent(false); + // TODO(kara): temporary, remove when #1607 is fixed + browser.sleep(250); page.trigger().click(); expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour"); page.expectMenuAlignedWith(page.menu(), 'trigger'); - page.body().click(); + page.backdrop().click(); page.expectMenuPresent(false); }); 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-panel custom'); + expect(classes).toContain('md-menu-panel custom'); }); }); @@ -110,9 +112,10 @@ describe('menu', () => { page.pressKey(protractor.Key.TAB); page.expectMenuPresent(false); - page.start().click(); page.pressKey(protractor.Key.TAB); page.pressKey(protractor.Key.ENTER); + page.expectMenuPresent(true); + page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB)); page.expectMenuPresent(false); }); diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index 09be530b4614..59b560d71bb2 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -1,5 +1,5 @@ import {NgModule} from '@angular/core'; -import {BrowserModule} from '@angular/platform-browser'; +import {BrowserModule, AnimationDriver} from '@angular/platform-browser'; import {RouterModule} from '@angular/router'; import {SimpleCheckboxes} from './checkbox/checkbox-e2e'; import {E2EApp, Home} from './e2e-app/e2e-app'; @@ -29,5 +29,8 @@ import {E2E_APP_ROUTES} from './e2e-app/routes'; Home, ], bootstrap: [E2EApp], + providers: [ + {provide: AnimationDriver, useValue: AnimationDriver.NOOP} + ] }) export class E2eAppModule { } diff --git a/src/e2e-app/system-config.ts b/src/e2e-app/system-config.ts index a067613be96e..26606ec4265f 100644 --- a/src/e2e-app/system-config.ts +++ b/src/e2e-app/system-config.ts @@ -15,6 +15,8 @@ System.config({ '@angular/forms': 'vendor/@angular/forms/bundles/forms.umd.js', '@angular/router': 'vendor/@angular/router/bundles/router.umd.js', '@angular/platform-browser': 'vendor/@angular/platform-browser/bundles/platform-browser.umd.js', + '@angular/platform-browser/testing': + 'vendor/@angular/platform-browser/bundles/platform-browser-testing.umd.js', '@angular/platform-browser-dynamic': 'vendor/@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', }, diff --git a/src/lib/core/style/_menu-common.scss b/src/lib/core/style/_menu-common.scss index c010a34e0a65..6784dcab0918 100644 --- a/src/lib/core/style/_menu-common.scss +++ b/src/lib/core/style/_menu-common.scss @@ -11,7 +11,6 @@ $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; @mixin md-menu-base() { @include md-elevation(2); @@ -20,9 +19,6 @@ $md-menu-vertical-padding: 8px !default; overflow: auto; -webkit-overflow-scrolling: touch; // for momentum scroll on mobile - - padding-top: $md-menu-vertical-padding; - padding-bottom: $md-menu-vertical-padding; } @mixin md-menu-item-base() { @@ -43,3 +39,25 @@ $md-menu-vertical-padding: 8px !default; cursor: default; } } + +/** + * This mixin adds the correct panel transform styles based + * on the direction that the menu panel opens. + */ +@mixin md-menu-positions() { + &.md-menu-after.md-menu-below { + transform-origin: left top; + } + + &.md-menu-after.md-menu-above { + transform-origin: left bottom; + } + + &.md-menu-before.md-menu-below { + transform-origin: right top; + } + + &.md-menu-before.md-menu-above { + transform-origin: right bottom; + } +} \ No newline at end of file diff --git a/src/lib/menu/_menu-theme.scss b/src/lib/menu/_menu-theme.scss index 28930e22e900..64895dbe8abc 100644 --- a/src/lib/menu/_menu-theme.scss +++ b/src/lib/menu/_menu-theme.scss @@ -6,7 +6,7 @@ $background: map-get($theme, background); $foreground: map-get($theme, foreground); - .md-menu-panel { + .md-menu-content { background: md-color($background, 'card'); } diff --git a/src/lib/menu/menu-animations.ts b/src/lib/menu/menu-animations.ts new file mode 100644 index 000000000000..60ee2fa5007f --- /dev/null +++ b/src/lib/menu/menu-animations.ts @@ -0,0 +1,51 @@ +import{ + AnimationEntryMetadata, + trigger, + state, + style, + animate, + transition +} from '@angular/core'; + +/** + * Below are all the animations for the md-menu component. + * Animation duration and timing values are based on Material 1. + */ + + +/** + * This animation controls the menu panel's entry and exit from the page. + * + * When the menu panel is added to the DOM, it scales in and fades in its border. + * + * When the menu panel is removed from the DOM, it simply fades out after a brief + * delay to display the ripple. + */ +export const transformMenu: AnimationEntryMetadata = trigger('transformMenu', [ + state('showing', style({ + opacity: 1, + transform: `scale(1)` + })), + transition(':enter', [ + style({ + opacity: 0, + transform: `scale(0)` + }), + animate(`200ms cubic-bezier(0.25, 0.8, 0.25, 1)`) + ]), + transition(':leave', [ + animate('50ms 100ms linear', style({opacity: 0})) + ]) +]); + +/** + * This animation fades in the background color and content of the menu panel + * after its containing element is scaled in. + */ +export const fadeInItems: AnimationEntryMetadata = trigger('fadeInItems', [ + state('showing', style({opacity: 1})), + transition(':enter', [ + style({opacity: 0}), + animate(`200ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)`) + ]) +]); diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index b91d92728b06..d532f3a7b981 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -12,7 +12,7 @@ import { QueryList, TemplateRef, ViewChild, - ViewEncapsulation + ViewEncapsulation, } from '@angular/core'; import {MenuPositionX, MenuPositionY} from './menu-positions'; import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors'; @@ -20,6 +20,7 @@ import {MdMenuItem} from './menu-item'; import {ListKeyManager} from '../core/a11y/list-key-manager'; import {MdMenuPanel} from './menu-panel'; import {Subscription} from 'rxjs/Subscription'; +import {transformMenu, fadeInItems} from './menu-animations'; @Component({ moduleId: module.id, @@ -28,6 +29,10 @@ import {Subscription} from 'rxjs/Subscription'; templateUrl: 'menu.html', styleUrls: ['menu.css'], encapsulation: ViewEncapsulation.None, + animations: [ + transformMenu, + fadeInItems + ], exportAs: 'mdMenu' }) export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { @@ -37,7 +42,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { private _tabSubscription: Subscription; /** Config object to be passed into the menu's ngClass */ - _classList: Object; + _classList: any = {}; positionX: MenuPositionX = 'after'; positionY: MenuPositionY = 'below'; @@ -49,6 +54,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { @Attribute('y-position') posY: MenuPositionY) { if (posX) { this._setPositionX(posX); } if (posY) { this._setPositionY(posY); } + this._setPositionClasses(); } // TODO: internal @@ -77,6 +83,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { obj[className] = true; return obj; }, {}); + this._setPositionClasses(); } @Output() close = new EventEmitter(); @@ -91,11 +98,12 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { this.items.first.focus(); this._keyManager.focusedItemIndex = 0; } + /** * This emits a close event to which the trigger is subscribed. When emitted, the * trigger will close the menu. */ - private _emitCloseEvent(): void { + _emitCloseEvent(): void { this.close.emit(); } @@ -112,4 +120,16 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { } this.positionY = pos; } + + /** + * It's necessary to set position-based classes to ensure the menu panel animation + * folds out from the correct direction. + */ + private _setPositionClasses() { + this._classList['md-menu-before'] = this.positionX == 'before'; + this._classList['md-menu-after'] = this.positionX == 'after'; + this._classList['md-menu-above'] = this.positionY == 'above'; + this._classList['md-menu-below'] = this.positionY == 'below'; + } + } diff --git a/src/lib/menu/menu-item.html b/src/lib/menu/menu-item.html new file mode 100644 index 000000000000..864f582d74c1 --- /dev/null +++ b/src/lib/menu/menu-item.html @@ -0,0 +1,4 @@ + +
+
diff --git a/src/lib/menu/menu-item.ts b/src/lib/menu/menu-item.ts index 06df0d60a9c1..ca910f516cd5 100644 --- a/src/lib/menu/menu-item.ts +++ b/src/lib/menu/menu-item.ts @@ -1,17 +1,19 @@ -import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core'; +import {Component, ElementRef, Input, HostBinding, Renderer} from '@angular/core'; import {MdFocusable} from '../core/a11y/list-key-manager'; /** * This directive is intended to be used inside an md-menu tag. * It exists mostly to set the role attribute. */ -@Directive({ +@Component({ + moduleId: module.id, selector: '[md-menu-item]', host: { 'role': 'menuitem', '(click)': '_checkDisabled($event)', 'tabindex': '-1' }, + templateUrl: 'menu-item.html', exportAs: 'mdMenuItem' }) export class MdMenuItem implements MdFocusable { @@ -36,12 +38,14 @@ export class MdMenuItem implements MdFocusable { @HostBinding('attr.aria-disabled') get isAriaDisabled(): string { - return String(this.disabled); + return String(!!this.disabled); + } + + + _getHostElement(): HTMLElement { + return this._elementRef.nativeElement; } - /** - * TODO: internal - */ _checkDisabled(event: Event) { if (this.disabled) { event.preventDefault(); diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index 7239c74dcb8a..e6048e2fef38 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -4,7 +4,6 @@ import { Input, Output, EventEmitter, - HostListener, ViewContainerRef, AfterViewInit, OnDestroy, @@ -33,7 +32,8 @@ import { Subscription } from 'rxjs/Subscription'; selector: '[md-menu-trigger-for]', host: { 'aria-haspopup': 'true', - '(keydown)': '_handleKeydown($event)' + '(keydown)': '_handleKeydown($event)', + '(click)': 'toggleMenu()' }, exportAs: 'mdMenuTrigger' }) @@ -63,7 +63,6 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy { get menuOpen(): boolean { return this._menuOpen; } - @HostListener('click') toggleMenu(): void { return this._menuOpen ? this.closeMenu() : this.openMenu(); } diff --git a/src/lib/menu/menu.html b/src/lib/menu/menu.html index f23266c05da9..c862d7fdc3d0 100644 --- a/src/lib/menu/menu.html +++ b/src/lib/menu/menu.html @@ -1,7 +1,9 @@ diff --git a/src/lib/menu/menu.scss b/src/lib/menu/menu.scss index 061927531435..a9e4ba09670d 100644 --- a/src/lib/menu/menu.scss +++ b/src/lib/menu/menu.scss @@ -5,18 +5,35 @@ @import '../core/style/sidenav-common'; @import '../core/style/menu-common'; +$md-menu-vertical-padding: 8px !default; + .md-menu-panel { @include md-menu-base(); + @include md-menu-positions(); // max height must be 100% of the viewport height + one row height max-height: calc(100vh + 48px); } +.md-menu-content { + padding-top: $md-menu-vertical-padding; + padding-bottom: $md-menu-vertical-padding; +} + [md-menu-item] { @include md-button-reset(); @include md-menu-item-base(); + position: relative; } button[md-menu-item] { width: 100%; } + +.md-menu-ripple { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} diff --git a/src/lib/menu/menu.spec.ts b/src/lib/menu/menu.spec.ts index 737531ae0063..70ce391b6cb8 100644 --- a/src/lib/menu/menu.spec.ts +++ b/src/lib/menu/menu.spec.ts @@ -1,4 +1,5 @@ import {TestBed, async} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; import { Component, EventEmitter, @@ -15,14 +16,13 @@ import { } from './menu'; import {OverlayContainer} from '../core/overlay/overlay-container'; - describe('MdMenu', () => { let overlayContainerElement: HTMLElement; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [MdMenuModule.forRoot()], - declarations: [CustomMenuPanel, CustomMenu, SimpleMenu], + declarations: [SimpleMenu, PositionedMenu, CustomMenuPanel, CustomMenu], providers: [ {provide: OverlayContainer, useFactory: () => { overlayContainerElement = document.createElement('div'); @@ -42,11 +42,12 @@ describe('MdMenu', () => { fixture.componentInstance.trigger.openMenu(); fixture.componentInstance.trigger.openMenu(); - expect(overlayContainerElement.textContent.trim()).toBe('Simple Content'); + expect(overlayContainerElement.textContent).toContain('Simple Content'); + expect(overlayContainerElement.textContent).toContain('Disabled Content'); }).not.toThrowError(); }); - it('should close the menu when a click occurs outside the menu', () => { + it('should close the menu when a click occurs outside the menu', async(() => { const fixture = TestBed.createComponent(SimpleMenu); fixture.detectChanges(); fixture.componentInstance.trigger.openMenu(); @@ -55,8 +56,10 @@ describe('MdMenu', () => { backdrop.click(); fixture.detectChanges(); - expect(overlayContainerElement.textContent).toBe(''); - }); + fixture.whenStable().then(() => { + expect(overlayContainerElement.textContent).toBe(''); + }); + })); it('should open a custom menu', () => { const fixture = TestBed.createComponent(CustomMenu); @@ -71,6 +74,70 @@ describe('MdMenu', () => { }).not.toThrowError(); }); + describe('positions', () => { + it('should append md-menu-after and md-menu-below classes by default', () => { + const fixture = TestBed.createComponent(SimpleMenu); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + const panel = overlayContainerElement.querySelector('.md-menu-panel'); + expect(panel.classList).toContain('md-menu-after'); + expect(panel.classList).toContain('md-menu-below'); + expect(panel.classList).not.toContain('md-menu-before'); + expect(panel.classList).not.toContain('md-menu-above'); + }); + + it('should append md-menu-before if x position is changed', () => { + const fixture = TestBed.createComponent(PositionedMenu); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + const panel = overlayContainerElement.querySelector('.md-menu-panel'); + expect(panel.classList).toContain('md-menu-before'); + expect(panel.classList).not.toContain('md-menu-after'); + }); + + it('should append md-menu-above if y position is changed', () => { + const fixture = TestBed.createComponent(PositionedMenu); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + fixture.detectChanges(); + const panel = overlayContainerElement.querySelector('.md-menu-panel'); + expect(panel.classList).toContain('md-menu-above'); + expect(panel.classList).not.toContain('md-menu-below'); + }); + + }); + + describe('animations', () => { + it('should include the ripple on items by default', () => { + const fixture = TestBed.createComponent(SimpleMenu); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + const item = fixture.debugElement.query(By.css('[md-menu-item]')); + const ripple = item.query(By.css('[md-ripple]')); + + expect(ripple).not.toBeNull(); + }); + + it('should remove the ripple on disabled items', () => { + const fixture = TestBed.createComponent(SimpleMenu); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openMenu(); + const items = fixture.debugElement.queryAll(By.css('[md-menu-item]')); + + // items[1] is disabled, so the ripple should not be present + const ripple = items[1].query(By.css('[md-ripple]')); + expect(ripple).toBeNull(); + }); + + }); + }); @Component({ @@ -78,6 +145,7 @@ describe('MdMenu', () => { + ` }) @@ -85,6 +153,19 @@ class SimpleMenu { @ViewChild(MdMenuTrigger) trigger: MdMenuTrigger; } +@Component({ + template: ` + + + + + ` +}) +class PositionedMenu { + @ViewChild(MdMenuTrigger) trigger: MdMenuTrigger; +} + + @Component({ selector: 'custom-menu', template: ` diff --git a/src/lib/menu/menu.ts b/src/lib/menu/menu.ts index 2ebd65dc9774..e1c32b782817 100644 --- a/src/lib/menu/menu.ts +++ b/src/lib/menu/menu.ts @@ -4,6 +4,7 @@ import {OverlayModule, OVERLAY_PROVIDERS} from '../core'; import {MdMenu} from './menu-directive'; import {MdMenuItem} from './menu-item'; import {MdMenuTrigger} from './menu-trigger'; +import {MdRippleModule} from '../core/ripple/ripple'; export {MdMenu} from './menu-directive'; export {MdMenuItem} from './menu-item'; export {MdMenuTrigger} from './menu-trigger'; @@ -12,7 +13,7 @@ export {MenuPositionX, MenuPositionY} from './menu-positions'; @NgModule({ - imports: [OverlayModule, CommonModule], + imports: [OverlayModule, CommonModule, MdRippleModule], exports: [MdMenu, MdMenuItem, MdMenuTrigger], declarations: [MdMenu, MdMenuItem, MdMenuTrigger], })