From a882546debb92bd9cf52937bf73eee8548d61bf0 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 31 Jul 2017 21:05:47 +0300 Subject: [PATCH] refactor(sidenav): switch to the angular animations API (#4959) * refactor(sidenav): switch to the angular animations API Switches the sidenav to use the `@angular/animations` API. This saves us a lot of code that was involved in orchestrating the various animations, in addition to making it easier to follow. It also makes it possible for the consumer to disable the animations by using the `NoopAnimationsModule`. * chore: remove redundant annotations * fix: focus restoration issue in IE * chore: linting error * chore: put back the toggle animation promises * fix: avoid initial animation * fix: sidenav not showing when animation starts * chore: update e2e tests --- e2e/components/sidenav-e2e.spec.ts | 17 +- src/lib/sidenav/sidenav-transitions.scss | 4 - src/lib/sidenav/sidenav.scss | 31 +-- src/lib/sidenav/sidenav.spec.ts | 310 ++++++----------------- src/lib/sidenav/sidenav.ts | 226 +++++++---------- 5 files changed, 180 insertions(+), 408 deletions(-) diff --git a/e2e/components/sidenav-e2e.spec.ts b/e2e/components/sidenav-e2e.spec.ts index 998d732e5d88..f14688f3803b 100644 --- a/e2e/components/sidenav-e2e.spec.ts +++ b/e2e/components/sidenav-e2e.spec.ts @@ -1,26 +1,27 @@ -import {browser, by, element, ExpectedConditions} from 'protractor'; +import {browser, by, element, ElementFinder} from 'protractor'; describe('sidenav', () => { describe('opening and closing', () => { - beforeEach(() => browser.get('/sidenav')); - - let input = element(by.tagName('md-sidenav')); + let sidenav: ElementFinder; + beforeEach(() => { + browser.get('/sidenav'); + sidenav = element(by.tagName('md-sidenav')); + }); it('should be closed', () => { - expect(input.isDisplayed()).toBeFalsy(); + expect(sidenav.isDisplayed()).toBeFalsy(); }); it('should open', () => { element(by.buttonText('Open sidenav')).click(); - expect(input.isDisplayed()).toBeTruthy(); + expect(sidenav.isDisplayed()).toBeTruthy(); }); it('should close again', () => { element(by.buttonText('Open sidenav')).click(); element(by.buttonText('Open sidenav')).click(); - browser.wait(ExpectedConditions.presenceOf(element(by.className('mat-sidenav-closed'))), 999); - expect(input.isDisplayed()).toBeFalsy(); + expect(sidenav.isDisplayed()).toBeFalsy(); }); }); }); diff --git a/src/lib/sidenav/sidenav-transitions.scss b/src/lib/sidenav/sidenav-transitions.scss index d9222e5da47a..cbbbd9e0c472 100644 --- a/src/lib/sidenav/sidenav-transitions.scss +++ b/src/lib/sidenav/sidenav-transitions.scss @@ -2,10 +2,6 @@ @import '../core/style/variables'; .mat-sidenav-transition { - .mat-sidenav { - transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; - } - .mat-sidenav-content { transition: { duration: $swift-ease-out-duration; diff --git a/src/lib/sidenav/sidenav.scss b/src/lib/sidenav/sidenav.scss index 4f70dff0c874..4a3083d678df 100644 --- a/src/lib/sidenav/sidenav.scss +++ b/src/lib/sidenav/sidenav.scss @@ -3,26 +3,6 @@ @import '../core/style/layout-common'; @import '../core/a11y/a11y'; - -// Mixin to help with defining LTR/RTL 'transform: translate3d()' values. -// @param $open The translation value when the sidenav is opened. -// @param $close The translation value when the sidenav is closed. -@mixin mat-sidenav-transition($open, $close) { - transform: translate3d($close, 0, 0); - - &.mat-sidenav-closed { - // We use 'visibility: hidden | visible' because 'display: none' will not animate any - // transitions, while visibility will interpolate transitions properly. - // see https://developer.mozilla.org/en-US/docs/Web/CSS/visibility, the Interpolation - // section. - visibility: hidden; - } - - &.mat-sidenav-opening, &.mat-sidenav-opened { - transform: translate3d($open, 0, 0); - } -} - // Mixin that creates a new stacking context. // see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context @mixin mat-sidenav-stacking-context() { @@ -103,8 +83,7 @@ box-sizing: border-box; height: 100%; overflow-y: auto; // TODO(kara): revisit scrolling behavior for sidenavs - - @include mat-sidenav-transition(0, -100%); + transform: translate3d(-100%, 0, 0); &.mat-sidenav-side { z-index: 1; @@ -112,18 +91,16 @@ &.mat-sidenav-end { right: 0; - - @include mat-sidenav-transition(0, 100%); + transform: translate3d(100%, 0, 0); } [dir='rtl'] & { - @include mat-sidenav-transition(0, 100%); + transform: translate3d(100%, 0, 0); &.mat-sidenav-end { left: 0; right: auto; - - @include mat-sidenav-transition(0, -100%); + transform: translate3d(-100%, 0, 0); } } diff --git a/src/lib/sidenav/sidenav.spec.ts b/src/lib/sidenav/sidenav.spec.ts index 659be9585193..55e8f31dd6ca 100644 --- a/src/lib/sidenav/sidenav.spec.ts +++ b/src/lib/sidenav/sidenav.spec.ts @@ -1,25 +1,18 @@ import {fakeAsync, async, tick, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component, ElementRef, ViewChild} from '@angular/core'; import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MdSidenav, MdSidenavModule, MdSidenavContainer} from './index'; import {A11yModule} from '../core/a11y/index'; import {PlatformModule} from '../core/platform/index'; import {ESCAPE} from '../core/keyboard/keycodes'; - -function endSidenavTransition(fixture: ComponentFixture) { - let sidenav: any = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance; - sidenav._onTransitionEnd( { - target: (sidenav)._elementRef.nativeElement, - propertyName: 'transform' - }); - fixture.detectChanges(); -} +import {dispatchKeyboardEvent} from '@angular/cdk/testing'; describe('MdSidenav', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdSidenavModule, A11yModule, PlatformModule], + imports: [MdSidenavModule, A11yModule, PlatformModule, NoopAnimationsModule], declarations: [ BasicTestApp, SidenavContainerNoSidenavTestApp, @@ -37,138 +30,39 @@ describe('MdSidenav', () => { it('should be able to open and close', fakeAsync(() => { let fixture = TestBed.createComponent(BasicTestApp); - let testComponent: BasicTestApp = fixture.debugElement.componentInstance; - let openButtonElement = fixture.debugElement.query(By.css('.open')); - openButtonElement.nativeElement.click(); fixture.detectChanges(); tick(); - expect(testComponent.openStartCount).toBe(1); + let testComponent: BasicTestApp = fixture.debugElement.componentInstance; + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)); + let sidenavBackdropElement = fixture.debugElement.query(By.css('.mat-sidenav-backdrop')); + + fixture.debugElement.query(By.css('.open')).nativeElement.click(); + fixture.detectChanges(); + expect(testComponent.openCount).toBe(0); + expect(testComponent.closeCount).toBe(0); - endSidenavTransition(fixture); tick(); - expect(testComponent.openStartCount).toBe(1); + expect(sidenav.componentInstance._isAnimating).toBe(false); expect(testComponent.openCount).toBe(1); - expect(testComponent.closeStartCount).toBe(0); expect(testComponent.closeCount).toBe(0); + expect(getComputedStyle(sidenav.nativeElement).visibility).toBe('visible'); + expect(getComputedStyle(sidenavBackdropElement.nativeElement).visibility).toBe('visible'); - let sidenavElement = fixture.debugElement.query(By.css('md-sidenav')); - let sidenavBackdropElement = fixture.debugElement.query(By.css('.mat-sidenav-backdrop')); - expect(getComputedStyle(sidenavElement.nativeElement).visibility).toEqual('visible'); - expect(getComputedStyle(sidenavBackdropElement.nativeElement).visibility) - .toEqual('visible'); - - // Close it. - let closeButtonElement = fixture.debugElement.query(By.css('.close')); - closeButtonElement.nativeElement.click(); + fixture.debugElement.query(By.css('.close')).nativeElement.click(); fixture.detectChanges(); - tick(); - expect(testComponent.openStartCount).toBe(1); expect(testComponent.openCount).toBe(1); - expect(testComponent.closeStartCount).toBe(1); expect(testComponent.closeCount).toBe(0); - endSidenavTransition(fixture); tick(); - expect(testComponent.openStartCount).toBe(1); expect(testComponent.openCount).toBe(1); - expect(testComponent.closeStartCount).toBe(1); expect(testComponent.closeCount).toBe(1); - - expect(getComputedStyle(sidenavElement.nativeElement).visibility).toEqual('hidden'); - expect(getComputedStyle(sidenavBackdropElement.nativeElement).visibility).toEqual('hidden'); - })); - - it('open/close() return a promise that resolves after animation end', fakeAsync(() => { - let fixture = TestBed.createComponent(BasicTestApp); - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; - let called = false; - - sidenav.open().then(() => { - called = true; - }); - - expect(called).toBe(false); - endSidenavTransition(fixture); - tick(); - expect(called).toBe(true); - - called = false; - sidenav.close().then(() => { - called = true; - }); - - expect(called).toBe(false); - endSidenavTransition(fixture); - tick(); - expect(called).toBe(true); - - })); - - it('open/close() twice returns the same promise', fakeAsync(() => { - let fixture = TestBed.createComponent(BasicTestApp); - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; - - let promise = sidenav.open(); - expect(sidenav.open()).toBe(promise); - fixture.detectChanges(); - tick(); - - promise = sidenav.close(); - expect(sidenav.close()).toBe(promise); - tick(); - })); - - it('open() then close() cancel animations when called too fast', fakeAsync(() => { - let fixture = TestBed.createComponent(BasicTestApp); - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; - - sidenav.open().then(openResult => { - expect(openResult.type).toBe('open'); - expect(openResult.animationFinished).toBe(false); - }); - - // We do not call transition end, close directly. - sidenav.close().then(closeResult => { - expect(closeResult.type).toBe('close'); - expect(closeResult.animationFinished).toBe(true); - }); - - endSidenavTransition(fixture); - tick(); - })); - - it('close() then open() cancel animations when called too fast', fakeAsync(() => { - let fixture = TestBed.createComponent(BasicTestApp); - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; - - // First, open the sidenav completely. - sidenav.open(); - endSidenavTransition(fixture); - tick(); - - // Then close and check behavior. - sidenav.close().then(closeResult => { - expect(closeResult.type).toBe('close'); - expect(closeResult.animationFinished).toBe(false); - }); - - // We do not call transition end, open directly. - sidenav.open().then(openResult => { - expect(openResult.type).toBe('open'); - expect(openResult.animationFinished).toBe(true); - }); - - endSidenavTransition(fixture); - tick(); + expect(getComputedStyle(sidenav.nativeElement).visibility).toBe('hidden'); + expect(getComputedStyle(sidenavBackdropElement.nativeElement).visibility).toBe('hidden'); })); it('does not throw when created without a sidenav', fakeAsync(() => { @@ -181,93 +75,67 @@ describe('MdSidenav', () => { it('should emit the backdropClick event when the backdrop is clicked', fakeAsync(() => { let fixture = TestBed.createComponent(BasicTestApp); - let testComponent: BasicTestApp = fixture.debugElement.componentInstance; - let openButtonElement = fixture.debugElement.query(By.css('.open')); - openButtonElement.nativeElement.click(); - fixture.detectChanges(); - tick(); + let openButtonElement = fixture.debugElement.query(By.css('.open')).nativeElement; - endSidenavTransition(fixture); + openButtonElement.click(); + fixture.detectChanges(); tick(); expect(testComponent.backdropClickedCount).toBe(0); - let sidenavBackdropElement = fixture.debugElement.query(By.css('.mat-sidenav-backdrop')); - sidenavBackdropElement.nativeElement.click(); + fixture.debugElement.query(By.css('.mat-sidenav-backdrop')).nativeElement.click(); fixture.detectChanges(); tick(); expect(testComponent.backdropClickedCount).toBe(1); - endSidenavTransition(fixture); - tick(); - - openButtonElement.nativeElement.click(); + openButtonElement.click(); fixture.detectChanges(); tick(); - endSidenavTransition(fixture); - tick(); - - let closeButtonElement = fixture.debugElement.query(By.css('.close')); - closeButtonElement.nativeElement.click(); + fixture.debugElement.query(By.css('.close')).nativeElement.click(); fixture.detectChanges(); tick(); - endSidenavTransition(fixture); - tick(); - expect(testComponent.backdropClickedCount).toBe(1); })); it('should close when pressing escape', fakeAsync(() => { let fixture = TestBed.createComponent(BasicTestApp); - let testComponent: BasicTestApp = fixture.debugElement.componentInstance; - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; - - sidenav.open(); fixture.detectChanges(); - endSidenavTransition(fixture); tick(); - expect(testComponent.openCount).toBe(1); - expect(testComponent.closeCount).toBe(0); + let testComponent: BasicTestApp = fixture.debugElement.componentInstance; + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)); + + sidenav.componentInstance.open(); + fixture.detectChanges(); + tick(); - // Simulate pressing the escape key. - sidenav.handleKeydown({ - keyCode: ESCAPE, - stopPropagation: () => {} - } as KeyboardEvent); + expect(testComponent.openCount).toBe(1, 'Expected one open event.'); + expect(testComponent.closeCount).toBe(0, 'Expected no close events.'); + dispatchKeyboardEvent(sidenav.nativeElement, 'keydown', ESCAPE); fixture.detectChanges(); - endSidenavTransition(fixture); tick(); - expect(testComponent.closeCount).toBe(1); + expect(testComponent.closeCount).toBe(1, 'Expected one close event.'); })); it('should not close by pressing escape when disableClose is set', fakeAsync(() => { let fixture = TestBed.createComponent(BasicTestApp); let testComponent = fixture.debugElement.componentInstance; - let sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance; - - sidenav.disableClose = true; - sidenav.open(); + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)); + sidenav.componentInstance.disableClose = true; + sidenav.componentInstance.open(); fixture.detectChanges(); - endSidenavTransition(fixture); tick(); - sidenav.handleKeydown({ - keyCode: ESCAPE, - stopPropagation: () => {} - }); - + dispatchKeyboardEvent(sidenav.nativeElement, 'keydown', ESCAPE); fixture.detectChanges(); - endSidenavTransition(fixture); tick(); expect(testComponent.closeCount).toBe(0); @@ -280,42 +148,34 @@ describe('MdSidenav', () => { sidenav.disableClose = true; sidenav.open(); - fixture.detectChanges(); - endSidenavTransition(fixture); tick(); - let backdropEl = fixture.debugElement.query(By.css('.mat-sidenav-backdrop')).nativeElement; - backdropEl.click(); + fixture.debugElement.query(By.css('.mat-sidenav-backdrop')).nativeElement.click(); fixture.detectChanges(); tick(); - fixture.detectChanges(); - endSidenavTransition(fixture); - tick(); - expect(testComponent.closeCount).toBe(0); })); it('should restore focus on close if focus is inside sidenav', fakeAsync(() => { let fixture = TestBed.createComponent(BasicTestApp); - let sidenav: MdSidenav = fixture.debugElement - .query(By.directive(MdSidenav)).componentInstance; + + fixture.detectChanges(); + tick(); + + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance; let openButton = fixture.componentInstance.openButton.nativeElement; let sidenavButton = fixture.componentInstance.sidenavButton.nativeElement; openButton.focus(); sidenav.open(); - fixture.detectChanges(); - endSidenavTransition(fixture); tick(); sidenavButton.focus(); sidenav.close(); - fixture.detectChanges(); - endSidenavTransition(fixture); tick(); expect(document.activeElement) @@ -333,14 +193,11 @@ describe('MdSidenav', () => { sidenav.open(); fixture.detectChanges(); - endSidenavTransition(fixture); tick(); closeButton.focus(); sidenav.close(); - fixture.detectChanges(); - endSidenavTransition(fixture); tick(); expect(document.activeElement) @@ -351,26 +208,22 @@ describe('MdSidenav', () => { describe('attributes', () => { it('should correctly parse opened="false"', () => { let fixture = TestBed.createComponent(SidenavSetToOpenedFalse); + fixture.detectChanges(); - let sidenavEl = fixture.debugElement.query(By.css('md-sidenav')).nativeElement; + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance; - expect(sidenavEl.classList).toContain('mat-sidenav-closed'); - expect(sidenavEl.classList).not.toContain('mat-sidenav-opened'); + expect((sidenav as MdSidenav).opened).toBe(false); }); it('should correctly parse opened="true"', () => { let fixture = TestBed.createComponent(SidenavSetToOpenedTrue); - fixture.detectChanges(); - endSidenavTransition(fixture); - let sidenavEl = fixture.debugElement.query(By.css('md-sidenav')).nativeElement; - let testComponent = fixture.debugElement.query(By.css('md-sidenav')).componentInstance; + fixture.detectChanges(); - expect(sidenavEl.classList).not.toContain('mat-sidenav-closed'); - expect(sidenavEl.classList).toContain('mat-sidenav-opened'); + let sidenav = fixture.debugElement.query(By.directive(MdSidenav)).componentInstance; - expect((testComponent as any)._toggleAnimationPromise).toBeNull(); + expect((sidenav as MdSidenav).opened).toBe(true); }); it('should remove align attr from DOM', () => { @@ -382,15 +235,16 @@ describe('MdSidenav', () => { .toBe(false, 'Expected sidenav not to have a native align attribute.'); }); - it('should throw when multiple sidenavs have the same align', () => { + it('should throw when multiple sidenavs have the same align', fakeAsync(() => { const fixture = TestBed.createComponent(SidenavDynamicAlign); fixture.detectChanges(); + tick(); const testComponent: SidenavDynamicAlign = fixture.debugElement.componentInstance; testComponent.sidenav1Align = 'end'; expect(() => fixture.detectChanges()).toThrow(); - }); + })); it('should not throw when sidenavs swap sides', () => { const fixture = TestBed.createComponent(SidenavDynamicAlign); @@ -425,7 +279,7 @@ describe('MdSidenav', () => { lastFocusableElement.focus(); sidenav.open(); - endSidenavTransition(fixture); + fixture.detectChanges(); tick(); expect(document.activeElement).toBe(firstFocusableElement); @@ -436,7 +290,7 @@ describe('MdSidenav', () => { lastFocusableElement.focus(); sidenav.open(); - endSidenavTransition(fixture); + fixture.detectChanges(); tick(); expect(document.activeElement).toBe(firstFocusableElement); @@ -447,7 +301,7 @@ describe('MdSidenav', () => { lastFocusableElement.focus(); sidenav.open(); - endSidenavTransition(fixture); + fixture.detectChanges(); tick(); expect(document.activeElement).toBe(lastFocusableElement); @@ -458,38 +312,36 @@ describe('MdSidenav', () => { describe('MdSidenavContainer', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdSidenavModule, A11yModule, PlatformModule], - declarations: [ - SidenavContainerTwoSidenavTestApp - ], + imports: [MdSidenavModule, A11yModule, PlatformModule, NoopAnimationsModule], + declarations: [SidenavContainerTwoSidenavTestApp], }); TestBed.compileComponents(); })); - describe('methods', () => { - it('should be able to open and close', async(() => { - const fixture = TestBed.createComponent(SidenavContainerTwoSidenavTestApp); + it('should be able to open and close all sidenavs', fakeAsync(() => { + const fixture = TestBed.createComponent(SidenavContainerTwoSidenavTestApp); - fixture.detectChanges(); + fixture.detectChanges(); - const testComponent: SidenavContainerTwoSidenavTestApp = - fixture.debugElement.componentInstance; - const sidenavs = fixture.debugElement.queryAll(By.directive(MdSidenav)); + const testComponent: SidenavContainerTwoSidenavTestApp = + fixture.debugElement.componentInstance; + const sidenavs = fixture.debugElement.queryAll(By.directive(MdSidenav)); - expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBeFalsy(); + expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBe(false); - return testComponent.sidenavContainer.open() - .then(() => { - expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBeTruthy(); + testComponent.sidenavContainer.open(); + fixture.detectChanges(); + tick(); - return testComponent.sidenavContainer.close(); - }) - .then(() => { - expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBeTruthy(); - }); - })); - }); + expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBe(true); + + testComponent.sidenavContainer.close(); + fixture.detectChanges(); + tick(); + + expect(sidenavs.every(sidenav => sidenav.componentInstance.opened)).toBe(false); + })); }); @@ -515,9 +367,7 @@ class SidenavContainerTwoSidenavTestApp { template: ` @@ -526,9 +376,7 @@ class SidenavContainerTwoSidenavTestApp { `, }) class BasicTestApp { - openStartCount: number = 0; openCount: number = 0; - closeStartCount: number = 0; closeCount: number = 0; backdropClickedCount: number = 0; @@ -536,18 +384,10 @@ class BasicTestApp { @ViewChild('openButton') openButton: ElementRef; @ViewChild('closeButton') closeButton: ElementRef; - openStart() { - this.openStartCount++; - } - open() { this.openCount++; } - closeStart() { - this.closeStartCount++; - } - close() { this.closeCount++; } diff --git a/src/lib/sidenav/sidenav.ts b/src/lib/sidenav/sidenav.ts index 329a5b066776..884985a3d536 100644 --- a/src/lib/sidenav/sidenav.ts +++ b/src/lib/sidenav/sidenav.ts @@ -24,12 +24,12 @@ import { Inject, ChangeDetectorRef, } from '@angular/core'; +import {animate, state, style, transition, trigger, AnimationEvent} from '@angular/animations'; import {Directionality, coerceBooleanProperty} from '../core'; import {FocusTrapFactory, FocusTrap} from '../core/a11y/focus-trap'; import {ESCAPE} from '../core/keyboard/keycodes'; import {first} from '../core/rxjs/index'; import {DOCUMENT} from '@angular/platform-browser'; -import {merge} from 'rxjs/observable/merge'; /** Throws an exception when two MdSidenav are matching the same side. */ @@ -38,12 +38,14 @@ export function throwMdDuplicatedSidenavError(align: string) { } -/** Sidenav toggle promise result. */ +/** + * Sidenav toggle promise result. + * @deprecated + */ export class MdSidenavToggleResult { constructor(public type: 'open' | 'close', public animationFinished: boolean) {} } - /** * component. * @@ -55,27 +57,42 @@ export class MdSidenavToggleResult { moduleId: module.id, selector: 'md-sidenav, mat-sidenav', templateUrl: 'sidenav.html', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('transform', [ + state('open, open-instant', style({ + transform: 'translate3d(0, 0, 0)', + visibility: 'visible', + })), + state('void', style({ + visibility: 'hidden', + })), + transition('void => open-instant', animate('0ms')), + transition('void <=> open', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')) + ]) + ], host: { 'class': 'mat-sidenav', - '(transitionend)': '_onTransitionEnd($event)', + '[@transform]': '_getAnimationState()', + '(@transform.start)': '_onAnimationStart()', + '(@transform.done)': '_onAnimationEnd($event)', '(keydown)': 'handleKeydown($event)', // must prevent the browser from aligning text based on value '[attr.align]': 'null', - '[class.mat-sidenav-closed]': '_isClosed', - '[class.mat-sidenav-closing]': '_isClosing', - '[class.mat-sidenav-end]': '_isEnd', - '[class.mat-sidenav-opened]': '_isOpened', - '[class.mat-sidenav-opening]': '_isOpening', - '[class.mat-sidenav-over]': '_modeOver', - '[class.mat-sidenav-push]': '_modePush', - '[class.mat-sidenav-side]': '_modeSide', + '[class.mat-sidenav-end]': 'align === "end"', + '[class.mat-sidenav-over]': 'mode === "over"', + '[class.mat-sidenav-push]': 'mode === "push"', + '[class.mat-sidenav-side]': 'mode === "side"', 'tabIndex': '-1' }, - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, }) export class MdSidenav implements AfterContentInit, OnDestroy { private _focusTrap: FocusTrap; + private _elementFocusedBeforeSidenavWasOpened: HTMLElement | null = null; + + /** Whether the sidenav is initialized. Used for disabling the initial animation. */ + private _enableAnimations = false; /** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */ private _align: 'start' | 'end' = 'start'; @@ -85,7 +102,7 @@ export class MdSidenav implements AfterContentInit, OnDestroy { get align() { return this._align; } set align(value) { // Make sure we have a valid value. - value = (value == 'end') ? 'end' : 'start'; + value = value === 'end' ? 'end' : 'start'; if (value != this._align) { this._align = value; this.onAlignChanged.emit(); @@ -102,41 +119,35 @@ export class MdSidenav implements AfterContentInit, OnDestroy { private _disableClose: boolean = false; /** Whether the sidenav is opened. */ - _opened: boolean = false; + private _opened: boolean = false; - /** Event emitted when the sidenav is being opened. Use this to synchronize animations. */ - @Output('open-start') onOpenStart = new EventEmitter(); + /** Emits whenever the sidenav has started animating. */ + _animationStarted = new EventEmitter(); - /** Event emitted when the sidenav is fully opened. */ - @Output('open') onOpen = new EventEmitter(); + /** Whether the sidenav is animating. Used to prevent overlapping animations. */ + _isAnimating = false; + + /** + * Promise that resolves when the open/close animation completes. It is here for backwards + * compatibility and should be removed next time we do sidenav breaking changes. + * @deprecated + */ + private _currentTogglePromise: Promise | null; - /** Event emitted when the sidenav is being closed. Use this to synchronize animations. */ - @Output('close-start') onCloseStart = new EventEmitter(); + /** Event emitted when the sidenav is fully opened. */ + @Output('open') onOpen = new EventEmitter(); /** Event emitted when the sidenav is fully closed. */ - @Output('close') onClose = new EventEmitter(); + @Output('close') onClose = new EventEmitter(); /** Event emitted when the sidenav alignment changes. */ @Output('align-changed') onAlignChanged = new EventEmitter(); - /** The current toggle animation promise. `null` if no animation is in progress. */ - private _toggleAnimationPromise: Promise | null = null; - - /** - * The current toggle animation promise resolution function. - * `null` if no animation is in progress. - */ - private _resolveToggleAnimationPromise: ((animationFinished: boolean) => void) | null = null; - get isFocusTrapEnabled() { // The focus trap is only enabled when the sidenav is open in any mode other than side. return this.opened && this.mode !== 'side'; } - /** - * @param _elementRef The DOM element reference. Used for transition and width calculation. - * If not available we do not hook on transitions. - */ constructor(private _elementRef: ElementRef, private _focusTrapFactory: FocusTrapFactory, @Optional() @Inject(DOCUMENT) private _doc: any) { @@ -173,13 +184,7 @@ export class MdSidenav implements AfterContentInit, OnDestroy { ngAfterContentInit() { this._focusTrap = this._focusTrapFactory.create(this._elementRef.nativeElement); this._focusTrap.enabled = this.isFocusTrapEnabled; - - // This can happen when the sidenav is set to opened in - // the template and the transition hasn't ended. - if (this._toggleAnimationPromise && this._resolveToggleAnimationPromise) { - this._resolveToggleAnimationPromise(true); - this._toggleAnimationPromise = this._resolveToggleAnimationPromise = null; - } + Promise.resolve().then(() => this._enableAnimations = true); } ngOnDestroy() { @@ -199,53 +204,35 @@ export class MdSidenav implements AfterContentInit, OnDestroy { } - /** Open this sidenav, and return a Promise that will resolve when it's fully opened (or get - * rejected if it didn't). */ + /** Open the sidenav. */ open(): Promise { return this.toggle(true); } - /** - * Close this sidenav, and return a Promise that will resolve when it's fully closed (or get - * rejected if it didn't). - */ + /** Close the sidenav. */ close(): Promise { return this.toggle(false); } /** - * Toggle this sidenav. This is equivalent to calling open() when it's already opened, or - * close() when it's closed. + * Toggle this sidenav. * @param isOpen Whether the sidenav should be open. - * @returns Resolves with the result of whether the sidenav was opened or closed. */ toggle(isOpen: boolean = !this.opened): Promise { - // Shortcut it if we're already opened. - if (isOpen === this.opened) { - return this._toggleAnimationPromise || - Promise.resolve(new MdSidenavToggleResult(isOpen ? 'open' : 'close', true)); - } - - this._opened = isOpen; - - if (this._focusTrap) { - this._focusTrap.enabled = this.isFocusTrapEnabled; - } - - if (isOpen) { - this.onOpenStart.emit(); - } else { - this.onCloseStart.emit(); + if (!this._isAnimating) { + this._opened = isOpen; + this._currentTogglePromise = new Promise(resolve => { + first.call(isOpen ? this.onOpen : this.onClose).subscribe(resolve); + }); + + if (this._focusTrap) { + this._focusTrap.enabled = this.isFocusTrapEnabled; + } } - if (this._toggleAnimationPromise && this._resolveToggleAnimationPromise) { - this._resolveToggleAnimationPromise(false); - } - this._toggleAnimationPromise = new Promise(resolve => { - this._resolveToggleAnimationPromise = animationFinished => - resolve(new MdSidenavToggleResult(isOpen ? 'open' : 'close', animationFinished)); - }); - return this._toggleAnimationPromise; + // TODO(crisbeto): This promise is here backwards-compatibility. + // It should be removed next time we do breaking changes in the sidenav. + return this._currentTogglePromise!; } /** @@ -260,60 +247,37 @@ export class MdSidenav implements AfterContentInit, OnDestroy { } /** - * When transition has finished, set the internal state for classes and emit the proper event. - * The event passed is actually of type TransitionEvent, but that type is not available in - * Android so we use any. + * Figures out the state of the sidenav animation. */ - _onTransitionEnd(transitionEvent: TransitionEvent) { - if (transitionEvent.target == this._elementRef.nativeElement - // Simpler version to check for prefixes. - && transitionEvent.propertyName.endsWith('transform')) { - if (this._opened) { - this.onOpen.emit(); - } else { - this.onClose.emit(); - } - - if (this._toggleAnimationPromise && this._resolveToggleAnimationPromise) { - this._resolveToggleAnimationPromise(true); - this._toggleAnimationPromise = this._resolveToggleAnimationPromise = null; - } + _getAnimationState(): 'open-instant' | 'open' | 'void' { + if (this.opened) { + return this._enableAnimations ? 'open' : 'open-instant'; } - } - get _isClosing() { - return !this._opened && !!this._toggleAnimationPromise; - } - get _isOpening() { - return this._opened && !!this._toggleAnimationPromise; - } - get _isClosed() { - return !this._opened && !this._toggleAnimationPromise; + return 'void'; } - get _isOpened() { - return this._opened && !this._toggleAnimationPromise; - } - get _isEnd() { - return this.align == 'end'; - } - get _modeSide() { - return this.mode == 'side'; - } - get _modeOver() { - return this.mode == 'over'; - } - get _modePush() { - return this.mode == 'push'; + + _onAnimationStart() { + this._isAnimating = true; + this._animationStarted.emit(); } - get _width() { - if (this._elementRef.nativeElement) { - return this._elementRef.nativeElement.offsetWidth; + _onAnimationEnd(event: AnimationEvent) { + const {fromState, toState} = event; + + if (toState === 'open' && fromState === 'void') { + this.onOpen.emit(new MdSidenavToggleResult('open', true)); + } else if (toState === 'void' && fromState === 'open') { + this.onClose.emit(new MdSidenavToggleResult('close', true)); } - return 0; + + this._isAnimating = false; + this._currentTogglePromise = null; } - private _elementFocusedBeforeSidenavWasOpened: HTMLElement | null = null; + get _width() { + return this._elementRef.nativeElement ? (this._elementRef.nativeElement.offsetWidth || 0) : 0; + } } /** @@ -382,17 +346,13 @@ export class MdSidenavContainer implements AfterContentInit { } /** Calls `open` of both start and end sidenavs */ - public open() { - return Promise.all([this._start, this._end] - .filter(sidenav => sidenav) - .map(sidenav => sidenav!.open())); + open(): void { + this._sidenavs.forEach(sidenav => sidenav.open()); } /** Calls `close` of both start and end sidenavs */ - public close() { - return Promise.all([this._start, this._end] - .filter(sidenav => sidenav) - .map(sidenav => sidenav!.close())); + close(): void { + this._sidenavs.forEach(sidenav => sidenav.close()); } /** @@ -401,7 +361,7 @@ export class MdSidenavContainer implements AfterContentInit { * is properly hidden. */ private _watchSidenavToggle(sidenav: MdSidenav): void { - merge(sidenav.onOpenStart, sidenav.onCloseStart).subscribe(() => { + sidenav._animationStarted.subscribe(() => { // Set the transition class on the container so that the animations occur. This should not // be set initially because animations should only be triggered via a change in state. this._renderer.addClass(this._element.nativeElement, 'mat-sidenav-transition'); @@ -442,9 +402,7 @@ export class MdSidenavContainer implements AfterContentInit { this._start = this._end = null; // Ensure that we have at most one start and one end sidenav. - // NOTE: We must call toArray on _sidenavs even though it's iterable - // (see https://github.com/Microsoft/TypeScript/issues/3164). - for (let sidenav of this._sidenavs.toArray()) { + this._sidenavs.forEach(sidenav => { if (sidenav.align == 'end') { if (this._end != null) { throwMdDuplicatedSidenavError('end'); @@ -456,7 +414,7 @@ export class MdSidenavContainer implements AfterContentInit { } this._start = sidenav; } - } + }); this._right = this._left = null;