From ebc631f0d9afa647f46078d6cd1fff9a11dcb9e1 Mon Sep 17 00:00:00 2001 From: James Taylor Date: Wed, 5 Jul 2017 17:04:02 +0100 Subject: [PATCH] Common drawer commponent This component is a modified NgbModal to allow content to slide in from the right of the screen Contributes to #649 Signed-off-by: James Taylor --- packages/composer-playground/package.json | 1 + .../src/app/common/drawer/active-drawer.ts | 18 + .../src/app/common/drawer/content-ref.ts | 8 + .../drawer/drawer-backdrop.component.scss | 23 + .../drawer/drawer-backdrop.component.spec.ts | 55 ++ .../drawer/drawer-backdrop.component.ts | 41 ++ .../common/drawer/drawer-dismiss-reasons.ts | 4 + .../src/app/common/drawer/drawer-options.ts | 25 + .../src/app/common/drawer/drawer-ref.ts | 93 +++ .../src/app/common/drawer/drawer-stack.ts | 100 +++ .../app/common/drawer/drawer.component.html | 3 + .../app/common/drawer/drawer.component.scss | 28 + .../common/drawer/drawer.component.spec.ts | 97 +++ .../src/app/common/drawer/drawer.component.ts | 115 ++++ .../src/app/common/drawer/drawer.module.ts | 21 + .../app/common/drawer/drawer.service.spec.ts | 575 ++++++++++++++++++ .../src/app/common/drawer/drawer.service.ts | 29 + .../src/app/common/drawer/index.ts | 16 + .../src/assets/styles/base/_variables.scss | 2 + .../src/polyfills.browser.ts | 2 + 20 files changed, 1256 insertions(+) create mode 100644 packages/composer-playground/src/app/common/drawer/active-drawer.ts create mode 100644 packages/composer-playground/src/app/common/drawer/content-ref.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.scss create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.spec.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-dismiss-reasons.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-options.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-ref.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer-stack.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.component.html create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.component.scss create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.component.spec.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.component.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.module.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.service.spec.ts create mode 100644 packages/composer-playground/src/app/common/drawer/drawer.service.ts create mode 100644 packages/composer-playground/src/app/common/drawer/index.ts diff --git a/packages/composer-playground/package.json b/packages/composer-playground/package.json index 072de84733..484f7f6ef2 100644 --- a/packages/composer-playground/package.json +++ b/packages/composer-playground/package.json @@ -88,6 +88,7 @@ "opener": "^1.4.2", "socket.io": "^1.7.3", "typescript": "2.4.0", + "web-animations-js": "^2.2.5", "webpack": "^2.2.1" }, "devDependencies": { diff --git a/packages/composer-playground/src/app/common/drawer/active-drawer.ts b/packages/composer-playground/src/app/common/drawer/active-drawer.ts new file mode 100644 index 0000000000..7cd4567fdd --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/active-drawer.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; + +/** + * A reference to the active drawer. Instances of this class + * can be injected into components passed as drawer content. + */ +@Injectable() +export class ActiveDrawer { + /** + * Can be used to close the drawer, passing an optional result. + */ + close(result?: any): void {} // tslint:disable-line:no-empty + + /** + * Can be used to dismiss the drawer, passing an optional reason. + */ + dismiss(reason?: any): void {} // tslint:disable-line:no-empty +} diff --git a/packages/composer-playground/src/app/common/drawer/content-ref.ts b/packages/composer-playground/src/app/common/drawer/content-ref.ts new file mode 100644 index 0000000000..40ff4d99bf --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/content-ref.ts @@ -0,0 +1,8 @@ +import { + ViewRef, + ComponentRef +} from '@angular/core'; + +export class ContentRef { + constructor(public nodes: any[], public viewRef?: ViewRef, public componentRef?: ComponentRef) {} +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.scss b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.scss new file mode 100644 index 0000000000..2ac4541d34 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.scss @@ -0,0 +1,23 @@ +@import '../../../assets/styles/base/_colors.scss'; +@import '../../../assets/styles/base/_variables.scss'; + +drawer-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1000; + background-color: $black; + opacity: 0; + + &.open { + opacity: 0.7; + transition: opacity .3s ease-out; + } + + &.closing { + opacity: 0; + transition: opacity .3s ease-out; + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.spec.ts b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.spec.ts new file mode 100644 index 0000000000..be5e0b11b5 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.spec.ts @@ -0,0 +1,55 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; + +import { DrawerBackdropComponent } from './drawer-backdrop.component'; +import { DrawerDismissReasons } from './drawer-dismiss-reasons'; + +import * as sinon from 'sinon'; +import * as chai from 'chai'; + +let should = chai.should(); + +describe('DrawerBackdropComponent', () => { + let sandbox; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({declarations: [DrawerBackdropComponent]}); + + fixture = TestBed.createComponent(DrawerBackdropComponent); + }); + + it('should render backdrop with required CSS classes', () => { + fixture.detectChanges(); + + fixture.nativeElement.classList.contains('drawer-backdrop').should.be.true; + }); + + it('should produce a dismiss event on backdrop click', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + try { + $event.should.equal(DrawerDismissReasons.BACKDROP_CLICK); + } catch (e) { + done.fail(e); + } + done(); + }); + + fixture.nativeElement.click(); + }); + + it('should not produce a dismiss event when click is not on backdrop', (done) => { + fixture.detectChanges(); + + fixture.componentInstance.dismissEvent.subscribe(($event) => { + done.fail(new Error('Should not trigger dismiss event')); + }); + + let childEl = document.createElement('div'); + fixture.nativeElement.appendChild(childEl); + childEl.click(); + + setTimeout(done, 200); + }); +}); diff --git a/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.ts b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.ts new file mode 100644 index 0000000000..757bcfeb41 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-backdrop.component.ts @@ -0,0 +1,41 @@ +import { + Component, + ElementRef, + EventEmitter, + HostBinding, + HostListener, + Input, + Output +} from '@angular/core'; + +import { DrawerDismissReasons } from './drawer-dismiss-reasons'; + +@Component({ + selector: 'drawer-backdrop', + template: '', + styleUrls: ['./drawer-backdrop.component.scss'.toString()] +}) +export class DrawerBackdropComponent { + + @HostBinding('class.closing') + @Input() + closing: boolean | string = false; + + @HostBinding('class.open') + get isOpen() { + return !this.closing; + } + + @HostBinding('class.drawer-backdrop') true; + + @Output('dismissEvent') dismissEvent = new EventEmitter(); + + constructor(private _elRef: ElementRef) {} + + @HostListener('click', ['$event.target']) + backdropClick(target): void { + if (this._elRef.nativeElement === target) { + this.dismissEvent.emit(DrawerDismissReasons.BACKDROP_CLICK); + } + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-dismiss-reasons.ts b/packages/composer-playground/src/app/common/drawer/drawer-dismiss-reasons.ts new file mode 100644 index 0000000000..bb9e6353aa --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-dismiss-reasons.ts @@ -0,0 +1,4 @@ +export enum DrawerDismissReasons { + BACKDROP_CLICK, + ESC +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-options.ts b/packages/composer-playground/src/app/common/drawer/drawer-options.ts new file mode 100644 index 0000000000..c6a78d59c5 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-options.ts @@ -0,0 +1,25 @@ +/** + * Represent options available when opening new drawers. + */ +export interface DrawerOptions { + /** + * Whether a backdrop element should be created for a given drawer (true by default). + * Alternatively, specify 'static' for a backdrop which doesn't close the drawer on click. + */ + backdrop?: boolean | 'static'; + + /** + * An element to which to attach newly opened drawer windows. + */ + container?: string; + + /** + * Whether to close the drawer when escape key is pressed (true by default). + */ + keyboard?: boolean; + + /** + * Custom class to append to the drawer window + */ + drawerClass?: string; +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-ref.ts b/packages/composer-playground/src/app/common/drawer/drawer-ref.ts new file mode 100644 index 0000000000..e55c1d05b7 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-ref.ts @@ -0,0 +1,93 @@ +import { Injectable, ComponentRef } from '@angular/core'; + +import { DrawerBackdropComponent } from './drawer-backdrop.component'; +import { DrawerComponent } from './drawer.component'; +import { ContentRef } from './content-ref'; + +/** + * A reference to a newly opened drawer. + */ +@Injectable() +export class DrawerRef { + /** + * A promise that is resolved when a drawer is closed and rejected when a drawer is dismissed. + */ + result: Promise; + + private resolve: (result?: any) => void; + private reject: (reason?: any) => void; + + /** + * The instance of component used as drawer's content. + * Undefined when a TemplateRef is used as drawer's content. + */ + get componentInstance(): any { + if (this.contentRef.componentRef) { + return this.contentRef.componentRef.instance; + } + } + + // only needed to keep TS1.8 compatibility + set componentInstance(instance: any) {} // tslint:disable-line:no-empty + + constructor(private drawerCmptRef: ComponentRef, private contentRef: ContentRef, private backdropCmptRef?: ComponentRef) { + drawerCmptRef.instance.dismissEvent.subscribe((reason: any) => { this.dismiss(reason); }); + drawerCmptRef.instance.closedEvent.subscribe(() => { this.removeDrawerElements(); }); + if (backdropCmptRef) { + backdropCmptRef.instance.dismissEvent.subscribe((reason: any) => { this.dismiss(reason); }); + } + + this.result = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + // tslint:disable-next-line:no-empty + this.result.then(null, () => {}); + } + + /** + * Can be used to close a drawer, passing an optional result. + */ + close(result?: any): void { + if (this.drawerCmptRef) { + this.resolve(result); + this.closeDrawer(); + } + } + + /** + * Can be used to dismiss a drawer, passing an optional reason. + */ + dismiss(reason?: any): void { + if (this.drawerCmptRef) { + this.reject(reason); + this.closeDrawer(); + } + } + + private closeDrawer() { + this.drawerCmptRef.instance.closing = true; + if (this.backdropCmptRef) { + this.backdropCmptRef.instance.closing = true; + } + } + + private removeDrawerElements() { + const windowNativeEl = this.drawerCmptRef.location.nativeElement; + windowNativeEl.parentNode.removeChild(windowNativeEl); + this.drawerCmptRef.destroy(); + + if (this.backdropCmptRef) { + const backdropNativeEl = this.backdropCmptRef.location.nativeElement; + backdropNativeEl.parentNode.removeChild(backdropNativeEl); + this.backdropCmptRef.destroy(); + } + + if (this.contentRef && this.contentRef.viewRef) { + this.contentRef.viewRef.destroy(); + } + + this.drawerCmptRef = null; + this.contentRef = null; + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer-stack.ts b/packages/composer-playground/src/app/common/drawer/drawer-stack.ts new file mode 100644 index 0000000000..3c35150e99 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer-stack.ts @@ -0,0 +1,100 @@ +import { + ApplicationRef, + Injectable, + Injector, + ReflectiveInjector, + ComponentFactory, + ComponentFactoryResolver, + ComponentRef, + TemplateRef +} from '@angular/core'; + +import { ContentRef } from './content-ref'; +import { DrawerBackdropComponent } from './drawer-backdrop.component'; +import { DrawerComponent } from './drawer.component'; +import { ActiveDrawer } from './active-drawer'; +import { DrawerRef } from './drawer-ref'; + +@Injectable() +export class DrawerStack { + private _backdropFactory: ComponentFactory; + private _drawerFactory: ComponentFactory; + + constructor( + private _applicationRef: ApplicationRef, private _injector: Injector, + private _componentFactoryResolver: ComponentFactoryResolver) { + this._backdropFactory = _componentFactoryResolver.resolveComponentFactory(DrawerBackdropComponent); + this._drawerFactory = _componentFactoryResolver.resolveComponentFactory(DrawerComponent); + } + + open(moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, options): DrawerRef { + const containerSelector = options.container || 'body'; + const containerEl = document.querySelector(containerSelector); + + if (!containerEl) { + throw new Error(`The specified drawer container "${containerSelector}" was not found in the DOM.`); + } + + const activeDrawer = new ActiveDrawer(); + const contentRef = this._getContentRef(moduleCFR, contentInjector, content, activeDrawer); + + let drawerCmptRef: ComponentRef; + let backdropCmptRef: ComponentRef; + let drawerRef: DrawerRef; + + if (options.backdrop !== false) { + backdropCmptRef = this._backdropFactory.create(this._injector); + this._applicationRef.attachView(backdropCmptRef.hostView); + containerEl.appendChild(backdropCmptRef.location.nativeElement); + } + drawerCmptRef = this._drawerFactory.create(this._injector, contentRef.nodes); + this._applicationRef.attachView(drawerCmptRef.hostView); + containerEl.appendChild(drawerCmptRef.location.nativeElement); + + drawerRef = new DrawerRef(drawerCmptRef, contentRef, backdropCmptRef); + + activeDrawer.close = (result: any) => { drawerRef.close(result); }; + activeDrawer.dismiss = (reason: any) => { drawerRef.dismiss(reason); }; + + this.applyDrawerOptions(drawerCmptRef.instance, options); + + return drawerRef; + } + + private applyDrawerOptions(drawerInstance: DrawerComponent, options: Object): void { + ['keyboard', 'drawerClass'].forEach((optionName: string) => { + if (this._isDefined(options[optionName])) { + drawerInstance[optionName] = options[optionName]; + } + }); + } + + private _getContentRef( + moduleCFR: ComponentFactoryResolver, contentInjector: Injector, content: any, + context: ActiveDrawer): ContentRef { + if (!content) { + return new ContentRef([]); + } else if (content instanceof TemplateRef) { + const viewRef = content.createEmbeddedView(context); + this._applicationRef.attachView(viewRef); + return new ContentRef([viewRef.rootNodes], viewRef); + } else if (this._isString(content)) { + return new ContentRef([[document.createTextNode(`${content}`)]]); + } else { + const contentCmptFactory = moduleCFR.resolveComponentFactory(content); + const drawerContentInjector = + ReflectiveInjector.resolveAndCreate([{provide: ActiveDrawer, useValue: context}], contentInjector); + const componentRef = contentCmptFactory.create(drawerContentInjector); + this._applicationRef.attachView(componentRef.hostView); + return new ContentRef([[componentRef.location.nativeElement]], componentRef.hostView, componentRef); + } + } + + private _isString(value: any): value is string { + return typeof value === 'string'; + } + + private _isDefined(value: any): boolean { + return value !== undefined && value !== null; + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer.component.html b/packages/composer-playground/src/app/common/drawer/drawer.component.html new file mode 100644 index 0000000000..fc9dde50b6 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.component.html @@ -0,0 +1,3 @@ + diff --git a/packages/composer-playground/src/app/common/drawer/drawer.component.scss b/packages/composer-playground/src/app/common/drawer/drawer.component.scss new file mode 100644 index 0000000000..58b3632e4a --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.component.scss @@ -0,0 +1,28 @@ +@import '../../../assets/styles/base/_colors.scss'; +@import '../../../assets/styles/base/_variables.scss'; + +body.drawer-open { + overflow: hidden; +} + +drawer { + bottom: 0; + outline: none; + box-shadow: -2px 0 5px -1px $black; + overflow: hidden; + padding: 0; + position: fixed; + right: 0; + top: 0; + transform: translateX(438px); + width: 438px; + z-index: 99999; + + .drawer-container { + background: $white; + height: 100%; + position: absolute; + width: 438px; + z-index: 9999; + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer.component.spec.ts b/packages/composer-playground/src/app/common/drawer/drawer.component.spec.ts new file mode 100644 index 0000000000..4a121b794c --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.component.spec.ts @@ -0,0 +1,97 @@ +/* tslint:disable:no-unused-expression */ + +import { ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { DrawerComponent } from './drawer.component'; +import { DrawerDismissReasons } from './drawer-dismiss-reasons'; + +import * as sinon from 'sinon'; +import * as chai from 'chai'; + +let should = chai.should(); + +describe('DrawerBackdropComponent', () => { + let fixture: ComponentFixture; + let component: DrawerComponent; + let mockElementRef: ElementRef; + + beforeEach(() => { + mockElementRef = sinon.createStubInstance(ElementRef); + mockElementRef.nativeElement = { + contains: sinon.stub(), + focus: { + apply: sinon.spy() + } + }; + + TestBed.configureTestingModule({ + declarations: [DrawerComponent], + imports: [NoopAnimationsModule], + providers: [{provide: ElementRef, useValue: mockElementRef}] + }); + + fixture = TestBed.createComponent(DrawerComponent); + component = fixture.componentInstance; + }); + + it('should render drawer with required CSS classes', () => { + fixture.detectChanges(); + + fixture.nativeElement.classList.contains('drawer').should.be.true; + }); + + it('should render drawer with a specified class', () => { + component.drawerClass = 'custom-class'; + fixture.detectChanges(); + + fixture.nativeElement.classList.contains('custom-class').should.be.true; + }); + + it('should produce a dismiss event on esc press by default', (done) => { + fixture.detectChanges(); + + component.dismissEvent.subscribe(($event) => { + try { + $event.should.equal(DrawerDismissReasons.ESC); + } catch (e) { + done.fail(e); + } + done(); + }); + + fixture.debugElement.triggerEventHandler('keyup.esc', {}); + }); + + it('should optionally ignore esc press', (done) => { + component.keyboard = false; + fixture.detectChanges(); + + component.dismissEvent.subscribe(($event) => { + done.fail(new Error('Should not trigger dismiss event')); + }); + + fixture.debugElement.triggerEventHandler('keyup.esc', {}); + + setTimeout(done, 200); + }); + + it('should produce a closed event when closed', (done) => { + fixture.detectChanges(); + + component.closedEvent.subscribe(($event) => { + done(); + }); + + fixture.debugElement.triggerEventHandler('@slideOpenClosed.done', { toState: 'closed' }); + }); + + describe('#ngOnInit', () => { + it('should render drawer and add required class to document body', () => { + fixture.detectChanges(); + + document.body.classList.contains('drawer-open').should.be.true; + }); + }); +}); diff --git a/packages/composer-playground/src/app/common/drawer/drawer.component.ts b/packages/composer-playground/src/app/common/drawer/drawer.component.ts new file mode 100644 index 0000000000..353163822f --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.component.ts @@ -0,0 +1,115 @@ +import { + Component, + Output, + EventEmitter, + Input, + ElementRef, + Renderer2, + OnInit, + AfterViewInit, + OnDestroy, + HostBinding, + HostListener +} from '@angular/core'; + +import { + trigger, + state, + style, + animate, + transition +} from '@angular/animations'; + +import { Observable } from 'rxjs/Rx'; + +import { DrawerDismissReasons } from './drawer-dismiss-reasons'; + +@Component({ + selector: 'drawer', + templateUrl: './drawer.component.html', + styleUrls: ['./drawer.component.scss'.toString()], + animations: [ + trigger('slideOpenClosed', [ + state('open', style({ + transform: 'translateX(0)' + })), + state('closed', style({ + transform: 'translateX(438px)' + })), + transition('* => open', animate('.3s cubic-bezier(.5, .8, 0, 1)')), + transition('* => closed', animate('.2s cubic-bezier(.5, .8, 0, 1)')) + ]) + ] +}) +export class DrawerComponent implements OnInit, + AfterViewInit, OnDestroy { + + @Input('closing') + closing: boolean | string = false; + + @Input('keyboard') + keyboard: boolean = true; + + @Input('drawerClass') + drawerClass: string; + + @Output('dismissEvent') dismissEvent = new EventEmitter(); + + @Output('closedEvent') closedEvent = new EventEmitter(); + + @HostBinding() tabindex = '-1'; + + @HostBinding('class') + get getClasses() { + return 'drawer' + (this.drawerClass ? ' ' + this.drawerClass : ''); + } + + @HostBinding('@slideOpenClosed') + get isClosing() { + return this.closing ? 'closed' : 'open'; + } + + private _elWithFocus: Element; // element that is focused prior to modal opening + + private dismissReason; + + constructor(private _elRef: ElementRef, private _renderer: Renderer2) {} + + @HostListener('@slideOpenClosed.done', ['$event.toState']) + onSlide(state) { + if (state === 'closed') { + this.closedEvent.emit(); + } + } + + @HostListener('keyup.esc', ['$event']) + escKey(event: Event): void { + if (this.keyboard && !event.defaultPrevented) { + this.dismiss(DrawerDismissReasons.ESC); + } + } + + dismiss(reason): void { this.dismissEvent.emit(reason); } + + ngOnInit() { + this._elWithFocus = document.activeElement; + this._renderer.addClass(document.body, 'drawer-open'); + } + + ngAfterViewInit() { + if (!this._elRef.nativeElement.contains(document.activeElement)) { + this._elRef.nativeElement['focus'].apply(this._elRef.nativeElement, []); + } + } + + ngOnDestroy() { + if (this._elWithFocus && document.body.contains(this._elWithFocus)) { + this._elWithFocus['focus'].apply(this._elWithFocus, []); + } else { + document.body['focus'].apply(document.body, []); + } + + this._elWithFocus = null; + this._renderer.removeClass(document.body, 'drawer-open'); + } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer.module.ts b/packages/composer-playground/src/app/common/drawer/drawer.module.ts new file mode 100644 index 0000000000..e39b712c3b --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.module.ts @@ -0,0 +1,21 @@ +import { NgModule, ModuleWithProviders } from '@angular/core'; + +import { DrawerBackdropComponent } from './drawer-backdrop.component'; +import { DrawerComponent } from './drawer.component'; +import { DrawerStack } from './drawer-stack'; +import { DrawerService } from './drawer.service'; + +export { DrawerService } from './drawer.service'; +export { DrawerOptions } from './drawer-options'; +export { DrawerRef } from './drawer-ref'; +export { ActiveDrawer } from './active-drawer'; +export { DrawerDismissReasons } from './drawer-dismiss-reasons'; + +@NgModule({ + declarations: [DrawerBackdropComponent, DrawerComponent], + entryComponents: [DrawerBackdropComponent, DrawerComponent], + providers: [DrawerService] +}) +export class DrawerModule { + static forRoot(): ModuleWithProviders { return {ngModule: DrawerModule, providers: [DrawerService, DrawerStack]}; } +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer.service.spec.ts b/packages/composer-playground/src/app/common/drawer/drawer.service.spec.ts new file mode 100644 index 0000000000..1f05d1adde --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.service.spec.ts @@ -0,0 +1,575 @@ +/* tslint:disable:no-unused-variable */ +/* tslint:disable:no-unused-expression */ +/* tslint:disable:no-var-requires */ +/* tslint:disable:max-classes-per-file */ +import { Component, Injectable, ViewChild, OnDestroy, NgModule, getDebugNode, DebugElement } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing'; + +import { DrawerModule, DrawerService, ActiveDrawer, DrawerRef } from './drawer.module'; + +import * as sinon from 'sinon'; +import * as chai from 'chai'; + +let should = chai.should(); + +// tslint:disable-next-line:no-empty +const NOOP = () => {}; + +@Injectable() +class SpyService { + called = false; +} + +// Should this be some sort of chai matcher thing? +function isDrawerOpen(content?, selector?): boolean { + const allDrawersContent = document.querySelector(selector || 'body').querySelectorAll('.drawer-content'); + let result; + + if (!content) { + result = (allDrawersContent.length > 0); + } else { + result = allDrawersContent.length === 1 && allDrawersContent[0].textContent.trim() === content; + } + + return result; +} + +// Should this be some sort of chai matcher thing? +function isBackdropOpen(): boolean { + return document.querySelectorAll('drawer-backdrop').length === 1; +} + +describe('DrawerService', () => { + + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({imports: [DrawerTestModule]}); + fixture = TestBed.createComponent(TestComponent); + }); + + afterEach(() => { + // detect left-over drawers and close them or report errors when can't + + const remainingDrawerWindows = document.querySelectorAll('drawer'); + if (remainingDrawerWindows.length) { + fail(`${remainingDrawerWindows.length} drawers were left in the DOM.`); + } + + const remainingDrawerBackdrops = document.querySelectorAll('drawer-backdrop'); + if (remainingDrawerBackdrops.length) { + fail(`${remainingDrawerBackdrops.length} drawer backdrops were left in the DOM.`); + } + }); + + describe('basic functionality', () => { + + it('should open and close drawer with default options', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should open and close drawer from a TemplateRef content', fakeAsync(() => { + const drawerRef = fixture.componentInstance.openTpl(); + fixture.detectChanges(); + + isDrawerOpen('Hello, World!').should.be.true; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should properly destroy TemplateRef content', fakeAsync(() => { + const spyService = fixture.debugElement.injector.get(SpyService); + const drawerRef = fixture.componentInstance.openDestroyableTpl(); + fixture.detectChanges(); + tick(); + + isDrawerOpen('Some content').should.be.true; + spyService.called.should.be.false; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + spyService.called.should.be.true; + })); + + it('should open and close drawer from a component type', fakeAsync(() => { + const spyService = fixture.debugElement.injector.get(SpyService); + const drawerRef = fixture.componentInstance.openCmpt(DestroyableComponent); + fixture.detectChanges(); + + isDrawerOpen('Some content').should.be.true; + spyService.called.should.be.false; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + spyService.called.should.be.true; + })); + + it('should inject active drawer ref when component is used as content', fakeAsync(() => { + fixture.componentInstance.openCmpt(WithActiveDrawerComponent); + fixture.detectChanges(); + + isDrawerOpen('Close').should.be.true; + + ( document.querySelector('button.closeFromInside')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should expose component used as drawer content', fakeAsync(() => { + const drawerRef = fixture.componentInstance.openCmpt(WithActiveDrawerComponent); + fixture.detectChanges(); + + isDrawerOpen('Close').should.be.true; + (drawerRef.componentInstance instanceof WithActiveDrawerComponent).should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should open and close drawer from inside', fakeAsync(() => { + fixture.componentInstance.openTplClose(); + fixture.detectChanges(); + + isDrawerOpen().should.be.true; + + ( document.querySelector('button#close')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should open and dismiss drawer from inside', fakeAsync(() => { + fixture.componentInstance.openTplDismiss().result.catch(NOOP); + fixture.detectChanges(); + + isDrawerOpen().should.be.true; + + ( document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should resolve result promise on close', fakeAsync(() => { + let resolvedResult; + fixture.componentInstance.openTplClose().result.then((result) => resolvedResult = result); + fixture.detectChanges(); + + isDrawerOpen().should.be.true; + + ( document.querySelector('button#close')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + + fixture.whenStable().then(() => { resolvedResult.should.equal('myResult'); }); + })); + + it('should reject result promise on dismiss', fakeAsync(() => { + let rejectReason; + fixture.componentInstance.openTplDismiss().result.catch((reason) => rejectReason = reason); + fixture.detectChanges(); + + isDrawerOpen().should.be.true; + + ( document.querySelector('button#dismiss')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + + fixture.whenStable().then(() => { rejectReason.should.equal('myReason'); }); + })); + + it('should add / remove "drawer-open" class to body when drawer is open', fakeAsync(() => { + const modalRef = fixture.componentInstance.open('bar'); + fixture.detectChanges(); + + isDrawerOpen().should.be.true; + document.body.classList.contains('drawer-open').should.be.true; + + modalRef.close('bar result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + document.body.classList.contains('drawer-open').should.be.false; + })); + + it('should not throw when close called multiple times', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + + drawerRef.close('some result'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should not throw when dismiss called multiple times', fakeAsync(() => { + const modalRef = fixture.componentInstance.open('foo'); + modalRef.result.catch(NOOP); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + + modalRef.dismiss('some reason'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + }); + + describe('backdrop options', () => { + + it('should have backdrop by default', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + isBackdropOpen().should.be.true; + + drawerRef.close('some reason'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + isBackdropOpen().should.be.false; + })); + + it('should open and close drawer without backdrop', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo', {backdrop: false}); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + isBackdropOpen().should.be.false; + + drawerRef.close('some reason'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + isBackdropOpen().should.be.false; + })); + + it('should open and close drawer without backdrop from template content', fakeAsync(() => { + const drawerRef = fixture.componentInstance.openTpl({backdrop: false}); + fixture.detectChanges(); + + isDrawerOpen('Hello, World!').should.be.true; + isBackdropOpen().should.be.false; + + drawerRef.close('some reason'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + isBackdropOpen().should.be.false; + })); + + it('should dismiss on backdrop click', fakeAsync(() => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + isBackdropOpen().should.be.true; + + ( document.querySelector('drawer-backdrop')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + isBackdropOpen().should.be.false; + })); + + it('should not dismiss on clicks that result in detached elements', fakeAsync(() => { + const drawerRef = fixture.componentInstance.openTplIf({}); + fixture.detectChanges(); + + isDrawerOpen('Click me').should.be.true; + + ( document.querySelector('button#if')).click(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + }); + + describe('container options', () => { + + it('should attach window and backdrop elements to the specified container', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo', {container: '#testContainer'}); + fixture.detectChanges(); + + isDrawerOpen('foo', '#testContainer').should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should throw when the specified container element doesnt exist', () => { + const brokenSelector = '#notInTheDOM'; + + (() => { + fixture.componentInstance.open('foo', {container: brokenSelector}); + }).should.throw(`The specified drawer container "${brokenSelector}" was not found in the DOM.`); + }); + }); + + describe('keyboard options', () => { + + it('should dismiss modals on ESC by default', fakeAsync(() => { + fixture.componentInstance.open('foo').result.catch(NOOP); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + ( getDebugNode(document.querySelector('drawer'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should not dismiss modals on ESC when keyboard option is false', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo', {keyboard: false}); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + ( getDebugNode(document.querySelector('drawer'))).triggerEventHandler('keyup.esc', {}); + fixture.detectChanges(); + tick(); + + isDrawerOpen('foo').should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + + it('should not dismiss modals on ESC when default is prevented', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo', {keyboard: true}); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + + ( getDebugNode(document.querySelector('drawer'))).triggerEventHandler('keyup.esc', { + defaultPrevented: true + }); + fixture.detectChanges(); + tick(); + + isDrawerOpen('foo').should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + })); + }); + + describe('custom class options', () => { + + it('should render modals with the correct custom classes', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo', {drawerClass: 'bar'}); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + document.querySelector('drawer').classList.contains('bar').should.be.true; + + drawerRef.close(); + fixture.detectChanges(); + tick(); + })); + + }); + + describe('focus management', () => { + + it('should focus drawer and return focus to previously focused element', fakeAsync(() => { + fixture.detectChanges(); + const openButtonEl = fixture.nativeElement.querySelector('button#open'); + + openButtonEl.focus(); + openButtonEl.click(); + fixture.detectChanges(); + + isDrawerOpen('from button').should.be.true; + document.activeElement.should.equal(document.querySelector('drawer')); + + fixture.componentInstance.close(); + tick(); + + isDrawerOpen().should.be.false; + document.activeElement.should.equal(openButtonEl); + })); + + it('should return focus to body if no element focused prior to drawer opening', fakeAsync(() => { + const drawerRef = fixture.componentInstance.open('foo'); + fixture.detectChanges(); + + isDrawerOpen('foo').should.be.true; + document.activeElement.should.equal(document.querySelector('drawer')); + + drawerRef.close('ok!'); + fixture.detectChanges(); + tick(); + + isDrawerOpen().should.be.false; + document.activeElement.should.equal(document.body); + })); + }); + + describe('window element ordering', () => { + it('should place newer windows on top of older ones', fakeAsync(() => { + const drawerRef1 = fixture.componentInstance.open('foo', {drawerClass: 'drawer-1'}); + fixture.detectChanges(); + + const drawerRef2 = fixture.componentInstance.open('bar', {drawerClass: 'drawer-2'}); + fixture.detectChanges(); + + let drawers = document.querySelectorAll('drawer'); + drawers.length.should.equal(2); + drawers[0].classList.contains('drawer-1').should.be.true; + drawers[1].classList.contains('drawer-2').should.be.true; + + drawerRef1.close(); + drawerRef2.close(); + fixture.detectChanges(); + tick(); + })); + }); +}); + +@Component({selector: 'destroyable-cmpt', template: 'Some content'}) +export class DestroyableComponent implements OnDestroy { + constructor(private _spyService: SpyService) {} + + ngOnDestroy(): void { this._spyService.called = true; } +} + +@Component( + {selector: 'drawer-content-cmpt', template: ''}) +export class WithActiveDrawerComponent { + constructor(public activeModal: ActiveDrawer) {} + + close() { this.activeModal.close('from inside'); } +} + +@Component({ + selector: 'test-cmpt', + template: ` +
+ Hello, {{name}}! + + + + + + + + + + + + + + ` +}) +class TestComponent { + name = 'World'; + openedModal: DrawerRef; + show = true; + @ViewChild('content') tplContent; + @ViewChild('destroyableContent') tplDestroyableContent; + @ViewChild('contentWithClose') tplContentWithClose; + @ViewChild('contentWithDismiss') tplContentWithDismiss; + @ViewChild('contentWithIf') tplContentWithIf; + + constructor(private drawerService: DrawerService) {} + + open(content: string, options?: Object) { + this.openedModal = this.drawerService.open(content, options); + return this.openedModal; + } + close() { + if (this.openedModal) { + this.openedModal.close('ok'); + } + } + openTpl(options?: Object) { return this.drawerService.open(this.tplContent, options); } + openCmpt(cmptType: any, options?: Object) { return this.drawerService.open(cmptType, options); } + openDestroyableTpl(options?: Object) { return this.drawerService.open(this.tplDestroyableContent, options); } + openTplClose(options?: Object) { return this.drawerService.open(this.tplContentWithClose, options); } + openTplDismiss(options?: Object) { return this.drawerService.open(this.tplContentWithDismiss, options); } + openTplIf(options?: Object) { return this.drawerService.open(this.tplContentWithIf, options); } +} + +@NgModule({ + declarations: [TestComponent, DestroyableComponent, WithActiveDrawerComponent], + exports: [TestComponent, DestroyableComponent], + imports: [CommonModule, DrawerModule.forRoot(), NoopAnimationsModule], + entryComponents: [DestroyableComponent, WithActiveDrawerComponent], + providers: [SpyService] +}) +class DrawerTestModule { +} diff --git a/packages/composer-playground/src/app/common/drawer/drawer.service.ts b/packages/composer-playground/src/app/common/drawer/drawer.service.ts new file mode 100644 index 0000000000..257b40177c --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/drawer.service.ts @@ -0,0 +1,29 @@ +import { Injectable, Injector, ComponentFactoryResolver } from '@angular/core'; + +import { DrawerOptions } from './drawer-options'; +import { DrawerRef } from './drawer-ref'; +import { DrawerStack } from './drawer-stack'; + +/** + * A service to open drawers. + * + * Creating a drawer is the same as creating a modal: create a template and pass it as an argument to + * the "open" method! + * + * See https://ng-bootstrap.github.io/#/components/modal for details. + */ +@Injectable() +export class DrawerService { + constructor( + private _moduleCFR: ComponentFactoryResolver, private _injector: Injector, private _drawerStack: DrawerStack) {} + + /** + * Opens a new drawer with the specified content and using supplied options. Content can be provided + * as a TemplateRef or a component type. If you pass a component type as content than instances of those + * components can be injected with an instance of the ActiveDrawer class. You can use methods on the + * ActiveDrawer class to close / dismiss drawers from "inside" of a component. + */ + open(content: any, options: DrawerOptions = {}): DrawerRef { + return this._drawerStack.open(this._moduleCFR, this._injector, content, options); + } +} diff --git a/packages/composer-playground/src/app/common/drawer/index.ts b/packages/composer-playground/src/app/common/drawer/index.ts new file mode 100644 index 0000000000..05f974a952 --- /dev/null +++ b/packages/composer-playground/src/app/common/drawer/index.ts @@ -0,0 +1,16 @@ +/* + * Provides the ability to slide new content out over + * existing content from the right. See example from IBM Design Language + * + * https://www.ibm.com/design/language/resources/animation-library/web-drawer/ + * + * Drawer implementation is based on NgbModal. + * See https://ng-bootstrap.github.io/#/components/modal for details. + */ + +export * from './drawer.component'; +export * from './drawer-backdrop.component'; +export * from './drawer.service'; +export * from './drawer-ref'; +export * from './active-drawer'; +export * from './drawer-stack'; diff --git a/packages/composer-playground/src/assets/styles/base/_variables.scss b/packages/composer-playground/src/assets/styles/base/_variables.scss index 478aa9d2fa..ff698a1944 100644 --- a/packages/composer-playground/src/assets/styles/base/_variables.scss +++ b/packages/composer-playground/src/assets/styles/base/_variables.scss @@ -27,3 +27,5 @@ $space-xlarge: 64px; transition-duration: .5s; transition-timing-function: ease; } + +$cubic-bezier: .3s cubic-bezier(.5, .8, 0, 1); diff --git a/packages/composer-playground/src/polyfills.browser.ts b/packages/composer-playground/src/polyfills.browser.ts index 3066808a6b..e178311237 100644 --- a/packages/composer-playground/src/polyfills.browser.ts +++ b/packages/composer-playground/src/polyfills.browser.ts @@ -31,6 +31,8 @@ import 'core-js/es6/reflect'; import 'core-js/es7/reflect'; import 'zone.js/dist/zone'; +import 'web-animations-js'; + // Typescript emit helpers polyfill import 'ts-helpers';