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 @@
+
+
+
+
+
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;
+ }
+}