diff --git a/skyux-spa-visual-tests/src/app/app-extras.module.ts b/skyux-spa-visual-tests/src/app/app-extras.module.ts index fdd0de404..f8ce0b3a7 100644 --- a/skyux-spa-visual-tests/src/app/app-extras.module.ts +++ b/skyux-spa-visual-tests/src/app/app-extras.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { FlyoutDemoComponent } from './flyout/flyout-demo.component'; +import { ToastDemoComponent } from './toast/toast-demo.component'; import { ModalDemoComponent } from './modal/modal-demo.component'; import { ModalLargeDemoComponent } from './modal/modal-large-demo.component'; @@ -16,6 +17,7 @@ import { Tile2Component } from './tiles/tile2.component'; providers: [], entryComponents: [ FlyoutDemoComponent, + ToastDemoComponent, ModalDemoComponent, ModalLargeDemoComponent, ModalFullPageDemoComponent, diff --git a/skyux-spa-visual-tests/src/app/toast/index.html b/skyux-spa-visual-tests/src/app/toast/index.html new file mode 100644 index 000000000..b1db80f7e --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/index.html @@ -0,0 +1 @@ + diff --git a/skyux-spa-visual-tests/src/app/toast/toast-demo.component.html b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.html new file mode 100644 index 000000000..cd3a16d25 --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.html @@ -0,0 +1 @@ +Toast component diff --git a/skyux-spa-visual-tests/src/app/toast/toast-demo.component.ts b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.ts new file mode 100644 index 000000000..1bc1611f9 --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.ts @@ -0,0 +1,21 @@ +import { + Component, + ChangeDetectionStrategy +} from '@angular/core'; + +import { + SkyToastService, + SkyToastInstance +} from '@blackbaud/skyux/dist/core'; + +@Component({ + selector: 'sky-test-cmp-toast', + templateUrl: './toast-demo.component.html', + providers: [SkyToastService], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ToastDemoComponent { + constructor( + public message: SkyToastInstance + ) {} +} diff --git a/skyux-spa-visual-tests/src/app/toast/toast-visual.component.html b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.html new file mode 100644 index 000000000..a6c5337d0 --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.html @@ -0,0 +1,16 @@ +
+ + +
diff --git a/skyux-spa-visual-tests/src/app/toast/toast-visual.component.ts b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.ts new file mode 100644 index 000000000..4b11c40ee --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.ts @@ -0,0 +1,31 @@ +import { + Component, + ChangeDetectionStrategy +} from '@angular/core'; + +import { + SkyToastService +} from '@blackbaud/skyux/dist/core'; + +import { + ToastDemoComponent +} from './toast-demo.component'; + +@Component({ + selector: 'toast-visual', + templateUrl: './toast-visual.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ToastVisualComponent { + constructor( + private toastService: SkyToastService + ) { } + + public openToast() { + this.toastService.openMessage('Toast message'); + } + + public openTemplatedToast() { + this.toastService.openTemplatedMessage(ToastDemoComponent, {}); + } +} diff --git a/skyux-spa-visual-tests/src/app/toast/toast.visual-spec.ts b/skyux-spa-visual-tests/src/app/toast/toast.visual-spec.ts new file mode 100644 index 000000000..df221c0d2 --- /dev/null +++ b/skyux-spa-visual-tests/src/app/toast/toast.visual-spec.ts @@ -0,0 +1,70 @@ +import { + SkyVisualTest +} from '../../../config/utils/visual-test-commands'; + +import { + element, + by +} from 'protractor'; + +describe('Toast', () => { + it('should match previous toast screenshot', () => { + return SkyVisualTest.setupTest('toast') + .then(() => { + element(by.css('.sky-btn-primary')).click(); + SkyVisualTest.moveCursorOffScreen(); + return SkyVisualTest.compareScreenshot({ + screenshotName: 'toast', + selector: 'body' + }).then(() => { + expect(by.css('.sky-toast')).toBeTruthy(); + element(by.css('.sky-toast')).click(); + }); + }); + }); + + it('should match previous templated toast screenshot', () => { + return SkyVisualTest.setupTest('toast') + .then(() => { + element(by.css('.sky-btn-secondary')).click(); + SkyVisualTest.moveCursorOffScreen(); + return SkyVisualTest.compareScreenshot({ + screenshotName: 'toast', + selector: 'body' + }).then(() => { + expect(by.css('.sky-custom-toast')).toBeTruthy(); + element(by.css('.sky-toast')).click(); + }); + }); + }); + + it('should match previous toast screenshot on tiny screens', () => { + return SkyVisualTest.setupTest('toast', 480) + .then(() => { + element(by.css('.sky-btn-primary')).click(); + SkyVisualTest.moveCursorOffScreen(); + return SkyVisualTest.compareScreenshot({ + screenshotName: 'toast', + selector: 'body' + }).then(() => { + expect(by.css('.sky-toast')).toBeTruthy(); + element(by.css('.sky-toast')).click(); + }); + }); + }); + + it('should match previous templated toast screenshot on tiny screens', () => { + return SkyVisualTest.setupTest('toast', 480) + .then(() => { + element(by.css('.sky-btn-secondary')).click(); + SkyVisualTest.moveCursorOffScreen(); + return SkyVisualTest.compareScreenshot({ + screenshotName: 'toast', + selector: 'body' + }).then(() => { + expect(by.css('.sky-custom-toast')).toBeTruthy(); + element(by.css('.sky-toast')).click(); + }); + }); + }); +}); diff --git a/src/core.ts b/src/core.ts index 714d186c0..3e5b2e079 100644 --- a/src/core.ts +++ b/src/core.ts @@ -63,6 +63,7 @@ import { SkyTabsModule } from './modules/tabs'; import { SkyTextExpandModule } from './modules/text-expand'; import { SkyTextExpandRepeaterModule } from './modules/text-expand-repeater'; import { SkyTextHighlightModule } from './modules/text-highlight'; +import { SkyToastModule } from './modules/toast'; import { SkyTokensModule } from './modules/tokens'; import { SkyToolbarModule } from './modules/toolbar'; import { SkyTilesModule } from './modules/tiles'; @@ -125,6 +126,7 @@ import { SkyWaitModule } from './modules/wait'; SkyTextHighlightModule, SkyTilesModule, SkyTimepickerModule, + SkyToastModule, SkyTokensModule, SkyToolbarModule, SkyUrlValidationModule, @@ -190,6 +192,7 @@ export * from './modules/text-expand-repeater'; export * from './modules/text-highlight'; export * from './modules/tiles'; export * from './modules/timepicker'; +export * from './modules/toast'; export * from './modules/tokens'; export * from './modules/toolbar'; export * from './modules/url-validation'; diff --git a/src/demo.ts b/src/demo.ts index 010f04a82..27658a93f 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -70,6 +70,8 @@ import { SkyTextHighlightDemoComponent, SkyTileDemoComponent, SkyTimepickerDemoComponent, + SkyToastDemoComponent, + SkyToastCustomDemoComponent, SkyTokensDemoComponent, SkyToolbarDemoComponent, SkyUrlValidationDemoComponent, @@ -148,6 +150,8 @@ const components = [ SkyTextHighlightDemoComponent, SkyTileDemoComponent, SkyTimepickerDemoComponent, + SkyToastDemoComponent, + SkyToastCustomDemoComponent, SkyTokensDemoComponent, SkyToolbarDemoComponent, SkyUrlValidationDemoComponent, diff --git a/src/demos/demo.service.ts b/src/demos/demo.service.ts index d92845cac..f7e9f6bb5 100644 --- a/src/demos/demo.service.ts +++ b/src/demos/demo.service.ts @@ -52,6 +52,7 @@ import { SkyTextHighlightDemoComponent, SkyTileDemoComponent, SkyTimepickerDemoComponent, + SkyToastDemoComponent, SkyTokensDemoComponent, SkyToolbarDemoComponent, SkyUrlValidationDemoComponent, @@ -1017,6 +1018,32 @@ export class SkyDemoService { } ] }, + { + name: 'Toast', + component: SkyToastDemoComponent, + files: [ + { + name: 'toast-demo.component.html', + fileContents: require('!!raw-loader!./toast/toast-demo.component.html') + }, + { + name: 'toast-demo.component.ts', + fileContents: require('!!raw-loader!./toast/toast-demo.component.ts'), + componentName: 'SkyToastDemoComponent', + bootstrapSelector: 'sky-toast-demo' + }, + { + name: 'toast-custom-demo.component.html', + fileContents: require('!!raw-loader!./toast/toast-custom-demo.component.html') + }, + { + name: 'toast-custom-demo.component.ts', + fileContents: require('!!raw-loader!./toast/toast-custom-demo.component.ts'), + componentName: 'SkyToastCustomDemoComponent', + bootstrapSelector: 'sky-toast-custom-demo' + } + ] + }, { name: 'Tokens', component: SkyTokensDemoComponent, diff --git a/src/demos/index.ts b/src/demos/index.ts index a8d9016b2..12b02a41a 100644 --- a/src/demos/index.ts +++ b/src/demos/index.ts @@ -45,6 +45,7 @@ export * from './text-expand'; export * from './text-highlight'; export * from './tile'; export * from './timepicker'; +export * from './toast'; export * from './tokens'; export * from './toolbar'; export * from './url-validation'; diff --git a/src/demos/toast/index.ts b/src/demos/toast/index.ts new file mode 100644 index 000000000..44701c6c8 --- /dev/null +++ b/src/demos/toast/index.ts @@ -0,0 +1,2 @@ +export * from './toast-demo.component'; +export * from './toast-custom-demo.component'; diff --git a/src/demos/toast/toast-custom-demo.component.html b/src/demos/toast/toast-custom-demo.component.html new file mode 100644 index 000000000..510dd3620 --- /dev/null +++ b/src/demos/toast/toast-custom-demo.component.html @@ -0,0 +1,8 @@ +

+ {{text}} + + example.com + +

diff --git a/src/demos/toast/toast-custom-demo.component.ts b/src/demos/toast/toast-custom-demo.component.ts new file mode 100644 index 000000000..ab719c04b --- /dev/null +++ b/src/demos/toast/toast-custom-demo.component.ts @@ -0,0 +1,21 @@ +import { + Component, + ChangeDetectionStrategy +} from '@angular/core'; + +import { + SkyToastInstance +} from '../../core'; + +@Component({ + selector: 'sky-toast-custom-demo', + templateUrl: './toast-custom-demo.component.html', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SkyToastCustomDemoComponent { + public text = 'This is a templated message. It can even link you to '; + + constructor( + public instance: SkyToastInstance + ) {} +} diff --git a/src/demos/toast/toast-demo.component.html b/src/demos/toast/toast-demo.component.html new file mode 100644 index 000000000..fdb6b7c62 --- /dev/null +++ b/src/demos/toast/toast-demo.component.html @@ -0,0 +1,25 @@ +
+
+ Toast type + +
+ + +
diff --git a/src/demos/toast/toast-demo.component.ts b/src/demos/toast/toast-demo.component.ts new file mode 100644 index 000000000..d94e0f285 --- /dev/null +++ b/src/demos/toast/toast-demo.component.ts @@ -0,0 +1,32 @@ +import { + Component +} from '@angular/core'; + +import { + SkyToastService +} from '../../core'; + +import { + SkyToastCustomDemoComponent +} from './toast-custom-demo.component'; + +@Component({ + selector: 'sky-toast-demo', + templateUrl: './toast-demo.component.html' +}) +export class SkyToastDemoComponent { + public selectedType: 'info' | 'success' | 'warning' | 'danger' = 'info'; + public types = ['info', 'success', 'warning', 'danger']; + + constructor( + private toastSvc: SkyToastService + ) {} + + public openMessage() { + this.toastSvc.openMessage('This is a ' + this.selectedType + ' toast.', {toastType: this.selectedType}); + } + + public openTemplatedMessage() { + this.toastSvc.openTemplatedMessage(SkyToastCustomDemoComponent, {toastType: this.selectedType}); + } +} diff --git a/src/locales/resources_en_US.json b/src/locales/resources_en_US.json index 7445ea4d4..f2e6e1455 100644 --- a/src/locales/resources_en_US.json +++ b/src/locales/resources_en_US.json @@ -839,6 +839,10 @@ "_description": "The close button for the timepicker modal", "message": "Done" }, + "toast_close_button": { + "_description": "Screen reader text for the close button on toasts", + "message": "Close the toast" + }, "token_dismiss_button_title": { "_description": "The default text for the token dismiss button title.", "message": "Remove item" diff --git a/src/modules/toast/index.ts b/src/modules/toast/index.ts new file mode 100644 index 000000000..c5d09997b --- /dev/null +++ b/src/modules/toast/index.ts @@ -0,0 +1,10 @@ +export { + SkyToasterComponent +} from './toaster.component'; +export { + SkyToastService +} from './services/toast.service'; +export { + SkyToastModule +} from './toast.module'; +export * from './types'; diff --git a/src/modules/toast/services/toast-adapter.service.spec.ts b/src/modules/toast/services/toast-adapter.service.spec.ts new file mode 100644 index 000000000..cc20ca31b --- /dev/null +++ b/src/modules/toast/services/toast-adapter.service.spec.ts @@ -0,0 +1,53 @@ +import { + RendererFactory2 +} from '@angular/core'; +import { + TestBed +} from '@angular/core/testing'; + +import { + SkyWindowRefService +} from '../../window'; + +import { + SkyToastAdapterService +} from './toast-adapter.service'; + +describe('Toast adapter service', () => { + + let adapter: SkyToastAdapterService; + let rendererCallCounts = { + appendCalledCount: 0, + removeCalledCount: 0 + }; + + beforeEach(() => { + let rendererMock = { + appendChild: () => { rendererCallCounts.appendCalledCount++; }, + removeChild: () => { rendererCallCounts.removeCalledCount++; } + }; + TestBed.configureTestingModule({ + providers: [ + SkyToastAdapterService, + SkyWindowRefService, + { + provide: RendererFactory2, + useValue: { + createRenderer() { return rendererMock; } + } + } + ] + }); + adapter = TestBed.get(SkyToastAdapterService); + }); + + it('should append element to body', () => { + adapter.appendToBody(undefined); + expect(rendererCallCounts.appendCalledCount).toBe(1); + }); + + it('should remove element from body', () => { + adapter.removeHostElement(); + expect(rendererCallCounts.removeCalledCount).toBe(1); + }); +}); diff --git a/src/modules/toast/services/toast-adapter.service.ts b/src/modules/toast/services/toast-adapter.service.ts new file mode 100644 index 000000000..b3f2e08b5 --- /dev/null +++ b/src/modules/toast/services/toast-adapter.service.ts @@ -0,0 +1,32 @@ +import { + Injectable, + Renderer2, + RendererFactory2 +} from '@angular/core'; + +import { + SkyWindowRefService +} from '../../window'; + +@Injectable() +export class SkyToastAdapterService { + private renderer: Renderer2; + + constructor( + private rendererFactory: RendererFactory2, + private windowRef: SkyWindowRefService + ) { + this.renderer = this.rendererFactory.createRenderer(undefined, undefined); + } + + public appendToBody(element: any): void { + const body = this.windowRef.getWindow().document.body; + this.renderer.appendChild(body, element); + } + + public removeHostElement(): void { + const document = this.windowRef.getWindow().document; + const hostElement = document.querySelector('sky-toast'); + this.renderer.removeChild(document.body, hostElement); + } +} diff --git a/src/modules/toast/services/toast.service.spec.ts b/src/modules/toast/services/toast.service.spec.ts new file mode 100644 index 000000000..a3614428d --- /dev/null +++ b/src/modules/toast/services/toast.service.spec.ts @@ -0,0 +1,216 @@ +import { + ApplicationRef, + ComponentFactoryResolver, + Injector +} from '@angular/core'; +import { + TestBed +} from '@angular/core/testing'; + +import { + SkyWindowRefService +} from '../../window'; + +import { + SkyToastService +} from './toast.service'; +import { + SkyToastAdapterService +} from './toast-adapter.service'; +import { + SkyToastInstance +} from '../types'; + +describe('Toast service', () => { + class TestComponent { constructor(public message: SkyToastInstance) { } } + let toastService: SkyToastService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyToastService, + { + provide: SkyToastAdapterService, + useValue: { + appendToBody() { }, + removeHostElement() { } + } + }, + { + provide: ApplicationRef, + useValue: { + attachView() {}, + detachView() {} + } + }, + Injector, + { + provide: ComponentFactoryResolver, + useValue: { + resolveComponentFactory() { + return { + create() { + return { + destroy() {}, + hostView: { + rootNodes: [ + {} + ] + }, + instance: { + messageStream: { + take() { + return { + subscribe() { } + }; + }, + next() {} + }, + attach() { + return { + close() { }, + closed: { + take() { + return { + subscribe() { } + }; + } + } + }; + } + } + }; + } + }; + } + } + }, + SkyWindowRefService + ] + }); + toastService = TestBed.get(SkyToastService); + }); + + it('should only create a single host component', () => { + const spy = spyOn(toastService as any, 'createHostComponent').and.callThrough(); + toastService.openMessage('message'); + toastService.openMessage('message'); + expect(spy.calls.count()).toEqual(1); + }); + + it('should return an instance with a close method', () => { + const toast = toastService.openMessage('message'); + expect(typeof toast.close).toEqual('function'); + }); + + it('should expose a method to remove the toast from the DOM', () => { + let message: SkyToastInstance = toastService.openMessage('message'); + const spy = spyOn(message, 'close').and.callThrough(); + toastService.ngOnDestroy(); + expect(spy).toHaveBeenCalledWith(); + }); + + describe('openMessage() method', () => { + it('should open a toast with the given message and configuration', function() { + let internalMessage: SkyToastInstance = toastService.openMessage('Real message', {toastType: 'danger'}); + + expect(internalMessage).toBeTruthy(); + expect(internalMessage.message).toBe('Real message'); + expect(internalMessage.toastType).toBe('danger'); + + expect(internalMessage.close).toBeTruthy(); + + let isClosedCalled = false; + internalMessage.isClosed.subscribe(() => isClosedCalled = true); + + expect(internalMessage.isOpen).toBeTruthy(); + expect(isClosedCalled).toBeFalsy(); + }); + + it('should remove message from queue when the message is closed', function(done: Function) { + let internalMessage: SkyToastInstance = toastService.openMessage('My message', {toastType: 'danger'}); + + let isClosedCalled = false; + internalMessage.isClosed.subscribe(() => isClosedCalled = true); + + internalMessage.close(); + internalMessage.isClosed.next(); + setTimeout(() => { + toastService.toastInstances.subscribe((value) => { + if (!internalMessage.isOpen) { + expect(value.length).toBe(0); + } + }); + expect(internalMessage.isOpen).toBeFalsy(); + expect(isClosedCalled).toBeTruthy(); + done(); + }, 600); + }); + + it('should not error when closing an already closed message', function(done: Function) { + let internalMessage: SkyToastInstance = toastService.openMessage('My message', {toastType: 'danger'}); + internalMessage.close(); + setTimeout(() => { + try { + internalMessage.close(); + } catch (error) { + fail(); + } + done(); + }, 600); + }); + + it('should open specific toast types', function () { + let infoMessage = toastService.openMessage('info message', {toastType: 'info'}); + let warningMessage = toastService.openMessage('warning message', {toastType: 'warning'}); + let dangerMessage = toastService.openMessage('danger message', {toastType: 'danger'}); + let successMessage = toastService.openMessage('success message', {toastType: 'success'}); + + expect(infoMessage.toastType).toBe('info'); + expect(warningMessage.toastType).toBe('warning'); + expect(dangerMessage.toastType).toBe('danger'); + expect(successMessage.toastType).toBe('success'); + }); + + it('should open info toast type when no or an unknown type is supplied', function () { + let emptyType: SkyToastInstance = toastService.openMessage('info message'); + expect(emptyType.toastType).toBe('info'); + }); + }); + + describe('openTemplatedMessage() method', () => { + it('should open a custom toast with the given component type and configuration', function() { + let internalMessage: SkyToastInstance = toastService.openTemplatedMessage(TestComponent, {toastType: 'danger'}); + + expect(internalMessage).toBeTruthy(); + expect(internalMessage.message).toBeFalsy(); + expect(internalMessage.customComponentType).toBeTruthy(); + expect(internalMessage.toastType).toBe('danger'); + + expect(internalMessage.close).toBeTruthy(); + + let isClosedCalled = false; + internalMessage.isClosed.subscribe(() => isClosedCalled = true); + + expect(internalMessage.isOpen).toBeTruthy(); + expect(isClosedCalled).toBeFalsy(); + }); + + it('should open a custom toast with the given component type and configuration', function() { + let internalMessage: SkyToastInstance = toastService.openTemplatedMessage(TestComponent, {toastType: 'danger'}); + + expect(internalMessage).toBeTruthy(); + expect(internalMessage.message).toBeFalsy(); + expect(internalMessage.customComponentType).toBeTruthy(); + expect(internalMessage.toastType).toBe('danger'); + + expect(internalMessage.close).toBeTruthy(); + + let isClosedCalled = false; + internalMessage.isClosed.subscribe(() => isClosedCalled = true); + + expect(internalMessage.isOpen).toBeTruthy(); + expect(isClosedCalled).toBeFalsy(); + }); + }); +}); diff --git a/src/modules/toast/services/toast.service.ts b/src/modules/toast/services/toast.service.ts new file mode 100644 index 000000000..43085af79 --- /dev/null +++ b/src/modules/toast/services/toast.service.ts @@ -0,0 +1,103 @@ +import { + Injectable, + ComponentRef, + ComponentFactoryResolver, + Injector, + ApplicationRef, + EmbeddedViewRef, + OnDestroy, + Type, + Provider +} from '@angular/core'; + +import { + BehaviorSubject +} from 'rxjs'; + +import { + SkyToasterComponent +} from '../toaster.component'; +import { + SkyToastAdapterService +} from './toast-adapter.service'; +import { + SkyToastInstance, + SkyToastConfig +} from '../types'; + +@Injectable() +export class SkyToastService implements OnDestroy { + private host: ComponentRef; + + private _toastInstances: SkyToastInstance[] = []; + public toastInstances: BehaviorSubject = new BehaviorSubject([]); + + constructor( + private appRef: ApplicationRef, + private injector: Injector, + private resolver: ComponentFactoryResolver, + private adapter: SkyToastAdapterService + ) {} + + public openMessage(message: string, config: SkyToastConfig = {}): SkyToastInstance { + return this.open(config, message); + } + + public openTemplatedMessage(customComponentType: Type, config: SkyToastConfig = {}, providers?: Provider[]): SkyToastInstance { + return this.open(config, undefined, customComponentType, providers); + } + + public ngOnDestroy() { + this.host = undefined; + this._toastInstances.forEach(instance => { + instance.close(); + }); + this.toastInstances.next([]); + this.adapter.removeHostElement(); + } + + private open(config: SkyToastConfig, message?: string, customComponentType?: Type, providers?: Provider[]): SkyToastInstance { + if (!this.host) { + this.host = this.createHostComponent(); + } + + let instance: SkyToastInstance = this.createToastInstance(config, message, customComponentType, providers); + this._toastInstances.push(instance); + this.toastInstances.next(this._toastInstances); + + return instance; + } + + private removeFromQueue: Function = (instance: SkyToastInstance) => { + this._toastInstances = this._toastInstances.filter(inst => inst !== instance); + this.toastInstances.next(this._toastInstances); + } + + private createToastInstance( + config: SkyToastConfig, + message?: string, + customComponentType?: Type, + providers?: Provider[] + ): SkyToastInstance { + let newToast = new SkyToastInstance( + message, + customComponentType, + config.toastType ? config.toastType : 'info', + providers); + newToast.isClosed.subscribe(() => { this.removeFromQueue(newToast); }); + return newToast; + } + + private createHostComponent(): ComponentRef { + const componentRef = this.resolver + .resolveComponentFactory(SkyToasterComponent) + .create(this.injector); + + const domElem = (componentRef.hostView as EmbeddedViewRef).rootNodes[0]; + + this.appRef.attachView(componentRef.hostView); + this.adapter.appendToBody(domElem); + + return componentRef; + } +} diff --git a/src/modules/toast/toast-messages/index.ts b/src/modules/toast/toast-messages/index.ts new file mode 100644 index 000000000..6502de796 --- /dev/null +++ b/src/modules/toast/toast-messages/index.ts @@ -0,0 +1 @@ +export * from './toast.component'; diff --git a/src/modules/toast/toast-messages/toast.component.html b/src/modules/toast/toast-messages/toast.component.html new file mode 100644 index 000000000..27bed1a62 --- /dev/null +++ b/src/modules/toast/toast-messages/toast.component.html @@ -0,0 +1,28 @@ + diff --git a/src/modules/toast/toast-messages/toast.component.scss b/src/modules/toast/toast-messages/toast.component.scss new file mode 100644 index 000000000..df2d48442 --- /dev/null +++ b/src/modules/toast/toast-messages/toast.component.scss @@ -0,0 +1,112 @@ +@import '../../../scss/variables'; +@import "../../../scss/mixins"; + +.sky-toast { + padding: 0 $sky-padding; + margin-bottom: $sky-margin-double; + border-left: solid 30px; + border-radius: $sky-border-radius; + color: $sky-text-color-default; + display: flex; + flex-direction: row; + align-items: center; + opacity: 1; + + &:hover { + @include sky-shadow; + } + + .sky-toast-content { + padding-top: $sky-padding; + padding-bottom: $sky-padding; + width: 100%; + + ::ng-deep a { + color: change-color($sky-text-color-default, $alpha: 0.8); + text-decoration: underline; + + &:hover { + color: $sky-text-color-default; + } + } + + .sky-toast-content-message { + margin: 0; + } + } + + button { + margin-left: auto; + width: 32px; + height: 32px; + } +} + +.sky-toast-info { + background-color: $sky-background-color-info; + border-color: $sky-highlight-color-info; + + &:before { + content: "\f06a"; + font-family: FontAwesome; + margin-left: -31px; + margin-right: 20px; + color: $sky-color-white; + } +} + +.sky-toast-success { + background-color: $sky-background-color-success; + border-color: $sky-highlight-color-success; + + &:before { + content: "\f00c"; + font-family: FontAwesome; + margin-left: -32px; + margin-right: 19px; + color: $sky-color-white; + } +} + +.sky-toast-warning { + background-color: $sky-background-color-warning; + border-color: $sky-highlight-color-warning; + + &:before { + content: "\f071"; + font-family: FontAwesome; + margin-left: -32px; + margin-right: 19px; + color: $sky-color-white; + } +} + +.sky-toast-danger { + background-color: $sky-background-color-danger; + border-color: $sky-highlight-color-danger; + + &:before { + content: "\f071"; + font-family: FontAwesome; + margin-left: -32px; + margin-right: 19px; + color: $sky-color-white; + } +} + +.sky-toast-close { + cursor: pointer; + font-weight: bold; + line-height: 1; + margin: 0; + padding: 0; + color: $sky-text-color-default; + opacity: 0.8; + border: none; + background-color: transparent; + display: block; + + &:hover { + opacity: 1.0; + } +} diff --git a/src/modules/toast/toast-messages/toast.component.spec.ts b/src/modules/toast/toast-messages/toast.component.spec.ts new file mode 100644 index 000000000..f2b96fea5 --- /dev/null +++ b/src/modules/toast/toast-messages/toast.component.spec.ts @@ -0,0 +1,134 @@ +import { + ComponentFactoryResolver, + Injector +} from '@angular/core'; +import { + TestBed +} from '@angular/core/testing'; + +import { + SkyToastInstance +} from '../types'; +import { + SkyToastComponent +} from '.'; + +describe('Toast component', () => { + class TestComponent { constructor(public instance: SkyToastInstance) {} } + let instance: SkyToastInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + Injector, + { + provide: ComponentFactoryResolver, + useValue: { + resolveComponentFactory() { + return { + create() { + return { + destroy() {}, + hostView: { + rootNodes: [ + {} + ] + }, + instance: { + messageStream: { + take() { + return { + subscribe() { } + }; + }, + next() {} + }, + attach() { + return { + close() { }, + closed: { + take() { + return { + subscribe() { } + }; + } + } + }; + } + } + }; + } + }; + } + } + } + ] + }); + }); + + it('should instantiate a toast without a custom component', + () => { + instance = new SkyToastInstance('My message', undefined, 'danger', []); + let toast: SkyToastComponent; + try { + toast = new SkyToastComponent(undefined, undefined); + toast.instance = instance; + + toast.ngOnInit(); + expect((toast as any).customComponent).toBeFalsy(); + + toast.ngOnDestroy(); + expect((toast as any).customComponent).toBeFalsy(); + } catch (error) { + fail(); + } + expect(toast).toBeTruthy(); + }); + + it('should show proper closed or open states', () => { + instance = new SkyToastInstance('My message', undefined, 'danger', []); + let toast: SkyToastComponent = new SkyToastComponent(undefined, undefined); + toast.instance = instance; + + toast.ngOnInit(); + + expect(toast.getAnimationState()).toBe('toastOpen'); + instance.close(); + toast.animationDone(undefined); + expect(toast.getAnimationState()).toBe('toastClosed'); + expect(toast).toBeTruthy(); + }); + + it('should instantiate a toast with a custom component and tear it down', + () => { + let clearCalled: boolean = false; + let createComponentCalled: boolean = false; + let destroyCalled: boolean = false; + + instance = new SkyToastInstance(undefined, TestComponent, 'danger', []); + + let toast: SkyToastComponent; + toast = new SkyToastComponent(TestBed.get(ComponentFactoryResolver), TestBed.get(Injector)); + toast.instance = instance; + + (toast as any).toastHost = { + clear: () => { clearCalled = true; }, + createComponent: () => { + createComponentCalled = true; + return { + instance: {}, + destroy: () => { destroyCalled = true; } + }; + } + }; + + toast.ngOnInit(); + expect((toast as any).customComponent).toBeTruthy(); + expect(clearCalled).toBeTruthy(); + expect(createComponentCalled).toBeTruthy(); + + toast.ngOnDestroy(); + expect(destroyCalled).toBeTruthy(); + expect(toast).toBeTruthy(); + }); +}); diff --git a/src/modules/toast/toast-messages/toast.component.ts b/src/modules/toast/toast-messages/toast.component.ts new file mode 100644 index 000000000..dddf1a317 --- /dev/null +++ b/src/modules/toast/toast-messages/toast.component.ts @@ -0,0 +1,91 @@ +import { + Component, + Input, + ComponentFactoryResolver, + OnInit, + ViewChild, + OnDestroy, + ViewContainerRef, + ReflectiveInjector, + ComponentRef, + Injector, + trigger, + state, + style, + animate, + transition, + ChangeDetectionStrategy +} from '@angular/core'; + +import { + SkyToastInstance +} from '../types'; + +const TOAST_OPEN_STATE = 'toastOpen'; +const TOAST_CLOSED_STATE = 'toastClosed'; + +@Component({ + selector: 'sky-toast', + templateUrl: './toast.component.html', + styleUrls: ['./toast.component.scss'], + animations: [ + trigger('toastState', [ + state(TOAST_OPEN_STATE, style({ opacity: 1 })), + state(TOAST_CLOSED_STATE, style({ opacity: 0 })), + transition(`toastOpen => toastClosed`, animate('500ms linear')) + ]) + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SkyToastComponent implements OnInit, OnDestroy { + @Input('instance') + public instance: SkyToastInstance; + + @ViewChild('skytoastcustomtemplate', { read: ViewContainerRef }) + private toastHost: ViewContainerRef; + + private customComponent: ComponentRef; + + constructor( + private resolver: ComponentFactoryResolver, + private injector: Injector + ) {} + + public getAnimationState(): string { + return this.instance.isOpen ? TOAST_OPEN_STATE : TOAST_CLOSED_STATE; + } + + public ngOnInit() { + if (this.instance.customComponentType) { + this.loadComponent(); + } + } + + public ngOnDestroy() { + if (this.customComponent) { + this.customComponent.destroy(); + } + } + + public animationDone(event: AnimationEvent) { + if (!this.instance.isOpen) { + this.instance.isClosed.emit(); + this.instance.isClosed.complete(); + } + } + + private loadComponent() { + this.toastHost.clear(); + this.instance.providers.push({ + provide: SkyToastInstance, + useValue: this.instance + }); + + const componentFactory = this.resolver.resolveComponentFactory(this.instance.customComponentType); + const providers = ReflectiveInjector.resolve(this.instance.providers || []); + + const injector = ReflectiveInjector.fromResolvedProviders(providers, this.injector); + + this.customComponent = this.toastHost.createComponent(componentFactory, undefined, injector); + } +} diff --git a/src/modules/toast/toast.module.ts b/src/modules/toast/toast.module.ts new file mode 100644 index 000000000..224e5f2ce --- /dev/null +++ b/src/modules/toast/toast.module.ts @@ -0,0 +1,53 @@ +import { + NgModule +} from '@angular/core'; +import { + CommonModule +} from '@angular/common'; + +import { + SkyResourcesModule +} from '../resources'; + +import { + SkyToastService +} from './services/toast.service'; +import { + SkyToasterComponent +} from './toaster.component'; +import { + SkyToastAdapterService +} from './services/toast-adapter.service'; +import { + SkyToastComponent +} from './toast-messages'; + +export { + SkyToastInstance +} from './types'; +export { + SkyToastService +}; + +@NgModule({ + declarations: [ + SkyToasterComponent, + SkyToastComponent + ], + imports: [ + CommonModule, SkyResourcesModule + ], + exports: [ + SkyToasterComponent, + SkyToastComponent + ], + providers: [ + SkyToastService, + SkyToastAdapterService + ], + entryComponents: [ + SkyToasterComponent, + SkyToastComponent + ] +}) +export class SkyToastModule {} diff --git a/src/modules/toast/toaster.component.html b/src/modules/toast/toaster.component.html new file mode 100644 index 000000000..86f784c50 --- /dev/null +++ b/src/modules/toast/toaster.component.html @@ -0,0 +1,7 @@ +
+ + +
diff --git a/src/modules/toast/toaster.component.scss b/src/modules/toast/toaster.component.scss new file mode 100644 index 000000000..ca972f51b --- /dev/null +++ b/src/modules/toast/toaster.component.scss @@ -0,0 +1,17 @@ +@import '../../scss/variables'; + +.sky-toaster { + bottom: 0; + right: 0; + display: block; + position: fixed; + padding-bottom: $sky-margin-double; + padding-right: $sky-margin-double; + width: 300px; + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Chrom and Opera */ +} diff --git a/src/modules/toast/toaster.component.spec.ts b/src/modules/toast/toaster.component.spec.ts new file mode 100644 index 000000000..9a0810f36 --- /dev/null +++ b/src/modules/toast/toaster.component.spec.ts @@ -0,0 +1,49 @@ +import { + TestBed +} from '@angular/core/testing'; + +import { + BehaviorSubject +} from 'rxjs'; + +import { + SkyToastService, + SkyToasterComponent +} from '.'; +import { + SkyToastInstance +} from './types'; + +describe('Toaster component', () => { + let toastService: SkyToastService; + let toastInstances: BehaviorSubject = new BehaviorSubject([]); + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: SkyToastService, + useValue: { + toastInstances: toastInstances + } + } + ]}); + toastService = TestBed.get(SkyToastService); + }); + + it('should instantiate a toaster with its own subscription to the toastInstance list', + (done: Function) => { + let instance: SkyToastInstance = new SkyToastInstance('My message', undefined, 'danger', []); + instance.isClosed.subscribe(() => { + toastInstances.next([]); + }); + toastInstances.next([instance]); + + let container: SkyToasterComponent = new SkyToasterComponent(toastService); + container.ngOnInit(); + container.toastInstances.subscribe((value) => { + expect(value[0]).toBe(instance); + done(); + }); + }); +}); diff --git a/src/modules/toast/toaster.component.ts b/src/modules/toast/toaster.component.ts new file mode 100644 index 000000000..91b3756d2 --- /dev/null +++ b/src/modules/toast/toaster.component.ts @@ -0,0 +1,32 @@ +import { + Component, + OnInit, + ChangeDetectionStrategy +} from '@angular/core'; + +import { + Observable +} from 'rxjs'; + +import { + SkyToastService +} from './services/toast.service'; + +@Component({ + selector: 'sky-toaster', + templateUrl: './toaster.component.html', + styleUrls: ['./toaster.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class SkyToasterComponent implements OnInit { + + public toastInstances: Observable; + + constructor( + private toast: SkyToastService + ) {} + + public ngOnInit() { + this.toastInstances = this.toast.toastInstances; + } +} diff --git a/src/modules/toast/types/index.ts b/src/modules/toast/types/index.ts new file mode 100644 index 000000000..e61e81ac2 --- /dev/null +++ b/src/modules/toast/types/index.ts @@ -0,0 +1,2 @@ +export * from './toast-config'; +export * from './toast-instance'; diff --git a/src/modules/toast/types/toast-config.ts b/src/modules/toast/types/toast-config.ts new file mode 100644 index 000000000..fa4e8922a --- /dev/null +++ b/src/modules/toast/types/toast-config.ts @@ -0,0 +1,9 @@ +import { + Type +} from '@angular/core'; + +export interface SkyToastConfig { + message?: string; + customComponentType?: Type; + toastType?: 'info' | 'success' | 'warning' | 'danger'; +} diff --git a/src/modules/toast/types/toast-instance.ts b/src/modules/toast/types/toast-instance.ts new file mode 100644 index 000000000..8682b9233 --- /dev/null +++ b/src/modules/toast/types/toast-instance.ts @@ -0,0 +1,21 @@ +import { + Type, + Provider, + EventEmitter +} from '@angular/core'; + +export class SkyToastInstance { + public isClosed = new EventEmitter(); + public isOpen = true; + + constructor( + public message: string, + public customComponentType: Type, + public toastType: 'info' | 'success' | 'warning' | 'danger', + public providers: Provider[] = [] + ) {} + + public close = () => { + this.isOpen = false; + } +}