diff --git a/package.json b/package.json
index 23a295fcd..8de7bebf4 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,6 @@
"microedge-rxstate": "2.0.2",
"moment": "2.21.0",
"ng2-dragula": "1.5.0",
- "ng2-toastr": "4.1.2",
"web-animations-js": "2.3.1"
},
"devDependencies": {
diff --git a/skyux-spa-visual-tests/config/utils/start-visual.js b/skyux-spa-visual-tests/config/utils/start-visual.js
index a999a68cd..4a03e1910 100644
--- a/skyux-spa-visual-tests/config/utils/start-visual.js
+++ b/skyux-spa-visual-tests/config/utils/start-visual.js
@@ -191,7 +191,11 @@ function spawnServer() {
function spawnBuild(skyPagesConfig, webpack) {
return new Promise(resolve => {
logger.info('Running build');
- build([], skyPagesConfig, webpack).then(stats => {
+ build(
+ { logFormat: 'compact' },
+ skyPagesConfig,
+ webpack
+ ).then(stats => {
logger.info('Completed build');
resolve(stats.toJson().chunks);
});
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..0d8cb162a
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/index.html
@@ -0,0 +1,2 @@
+
+
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..1f95fb1b3
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.html
@@ -0,0 +1,5 @@
+
+ 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..d965e4fbf
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/toast-demo.component.ts
@@ -0,0 +1,19 @@
+import {
+ ChangeDetectionStrategy,
+ Component
+} from '@angular/core';
+
+import {
+ SkyToastInstance
+} from '@blackbaud/skyux/dist/core';
+
+@Component({
+ selector: 'sky-test-cmp-toast',
+ templateUrl: './toast-demo.component.html',
+ 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..be0c6dec3
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.html
@@ -0,0 +1,18 @@
+
+
+
+
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..534d35c4b
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/toast-visual.component.ts
@@ -0,0 +1,43 @@
+import {
+ Component,
+ ChangeDetectionStrategy,
+ OnDestroy
+} from '@angular/core';
+
+import {
+ SkyToastService,
+ SkyToastType
+} 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 implements OnDestroy {
+ constructor(
+ private toastService: SkyToastService
+ ) { }
+
+ public ngOnDestroy(): void {
+ this.toastService.closeAll();
+ }
+
+ public openToasts(): void {
+ this.toastService.openMessage('Toast message', { type: SkyToastType.Info });
+ this.toastService.openMessage('Toast message', { type: SkyToastType.Success });
+ this.toastService.openMessage('Toast message', { type: SkyToastType.Warning });
+ this.toastService.openMessage('Toast message', { type: SkyToastType.Danger });
+ }
+
+ public openComponents(): void {
+ this.toastService.openComponent(ToastDemoComponent, { type: SkyToastType.Info });
+ this.toastService.openComponent(ToastDemoComponent, { type: SkyToastType.Success });
+ this.toastService.openComponent(ToastDemoComponent, { type: SkyToastType.Warning });
+ this.toastService.openComponent(ToastDemoComponent, { type: SkyToastType.Danger });
+ }
+}
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..dcf338a24
--- /dev/null
+++ b/skyux-spa-visual-tests/src/app/toast/toast.visual-spec.ts
@@ -0,0 +1,58 @@
+import {
+ SkyVisualTest
+} from '../../../config/utils/visual-test-commands';
+
+import {
+ by,
+ element
+} 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: '.sky-toaster'
+ });
+ });
+ });
+
+ 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-component',
+ selector: '.sky-toaster'
+ });
+ });
+ });
+
+ 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-xs',
+ selector: '.sky-toaster'
+ });
+ });
+ });
+
+ 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-component-xs',
+ selector: '.sky-toaster'
+ });
+ });
+ });
+});
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 c70de8218..c46975bdf 100644
--- a/src/demos/demo.service.ts
+++ b/src/demos/demo.service.ts
@@ -1,4 +1,7 @@
-import { Injectable } from '@angular/core';
+// #region imports
+import {
+ Injectable
+} from '@angular/core';
import {
SkyActionButtonDemoComponent,
@@ -52,6 +55,7 @@ import {
SkyTextHighlightDemoComponent,
SkyTileDemoComponent,
SkyTimepickerDemoComponent,
+ SkyToastDemoComponent,
SkyTokensDemoComponent,
SkyToolbarDemoComponent,
SkyUrlValidationDemoComponent,
@@ -59,6 +63,7 @@ import {
SkyWaitDemoComponent,
SkyWizardDemoComponent
} from './index';
+// #endregion
/**
* This service provides consumers with the raw file contents for each component demo.
@@ -1025,6 +1030,36 @@ 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: 'toast-custom-demo-context.ts',
+ fileContents: require('!!raw-loader!./toast/toast-custom-demo-context.ts')
+ }
+ ]
+ },
{
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..f10cf3cf3
--- /dev/null
+++ b/src/demos/toast/index.ts
@@ -0,0 +1,2 @@
+export * from './toast-custom-demo.component';
+export * from './toast-demo.component';
diff --git a/src/demos/toast/toast-custom-demo-context.ts b/src/demos/toast/toast-custom-demo-context.ts
new file mode 100644
index 000000000..7cd2d2e9c
--- /dev/null
+++ b/src/demos/toast/toast-custom-demo-context.ts
@@ -0,0 +1,5 @@
+export class SkyToastCustomDemoContext {
+ constructor(
+ public message: string
+ ) { }
+}
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..b00248df2
--- /dev/null
+++ b/src/demos/toast/toast-custom-demo.component.html
@@ -0,0 +1,13 @@
+
+ {{ context.message }}
+
+
+ Some link: 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..cd34514d4
--- /dev/null
+++ b/src/demos/toast/toast-custom-demo.component.ts
@@ -0,0 +1,28 @@
+import {
+ ChangeDetectionStrategy,
+ Component
+} from '@angular/core';
+
+import {
+ SkyToastInstance
+} from '../../core';
+
+import {
+ SkyToastCustomDemoContext
+} from './toast-custom-demo-context';
+
+@Component({
+ selector: 'sky-toast-custom-demo',
+ templateUrl: './toast-custom-demo.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SkyToastCustomDemoComponent {
+ constructor(
+ public context: SkyToastCustomDemoContext,
+ private instance: SkyToastInstance
+ ) { }
+
+ public close(): void {
+ this.instance.close();
+ }
+}
diff --git a/src/demos/toast/toast-demo.component.html b/src/demos/toast/toast-demo.component.html
new file mode 100644
index 000000000..d631ee3b4
--- /dev/null
+++ b/src/demos/toast/toast-demo.component.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/demos/toast/toast-demo.component.ts b/src/demos/toast/toast-demo.component.ts
new file mode 100644
index 000000000..9dea650cb
--- /dev/null
+++ b/src/demos/toast/toast-demo.component.ts
@@ -0,0 +1,84 @@
+import {
+ Component
+} from '@angular/core';
+
+import {
+ SkyToastService,
+ SkyToastType
+} from '../../core';
+
+import {
+ SkyToastCustomDemoContext
+} from './toast-custom-demo-context';
+
+import {
+ SkyToastCustomDemoComponent
+} from './toast-custom-demo.component';
+
+@Component({
+ selector: 'sky-toast-demo',
+ templateUrl: './toast-demo.component.html'
+})
+export class SkyToastDemoComponent {
+ public selectedType: SkyToastType = SkyToastType.Info;
+ public types: any[] = [
+ {
+ value: SkyToastType.Info,
+ label: 'Info'
+ },
+ {
+ value: SkyToastType.Success,
+ label: 'Success'
+ },
+ {
+ value: SkyToastType.Warning,
+ label: 'Warning'
+ },
+ {
+ value: SkyToastType.Danger,
+ label: 'Danger'
+ }
+ ];
+
+ constructor(
+ private toastService: SkyToastService
+ ) { }
+
+ public openMessage(): void {
+ const instance = this.toastService.openMessage(
+ `This is a sample toast message.`,
+ {
+ type: this.selectedType
+ }
+ );
+
+ instance.closed.subscribe(() => {
+ console.log('Message toast closed!');
+ });
+ }
+
+ public openComponent(): void {
+ const context = new SkyToastCustomDemoContext(
+ 'This toast has embedded a custom component for its content.'
+ );
+
+ const instance = this.toastService.openComponent(
+ SkyToastCustomDemoComponent,
+ {
+ type: this.selectedType
+ },
+ [{
+ provide: SkyToastCustomDemoContext,
+ useValue: context
+ }]
+ );
+
+ instance.closed.subscribe(() => {
+ console.log('Custom component toast closed!');
+ });
+ }
+
+ public closeAll(): void {
+ this.toastService.closeAll();
+ }
+}
diff --git a/src/locales/resources_en_US.json b/src/locales/resources_en_US.json
index d8e67f2e2..1b86df578 100644
--- a/src/locales/resources_en_US.json
+++ b/src/locales/resources_en_US.json
@@ -843,6 +843,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/animation/emerge.spec.ts b/src/modules/animation/emerge.spec.ts
new file mode 100644
index 000000000..f0148073b
--- /dev/null
+++ b/src/modules/animation/emerge.spec.ts
@@ -0,0 +1,18 @@
+// #region imports
+import {
+ skyAnimationEmerge
+} from './emerge';
+// #endregion
+
+describe('Animation emerge', () => {
+ it('should define an animation trigger', () => {
+ const definitions: any = skyAnimationEmerge.definitions;
+ expect(skyAnimationEmerge.name).toEqual('skyAnimationEmerge');
+ expect(definitions[0].name).toEqual('open');
+ expect(definitions[1].name).toEqual('closed');
+ expect(definitions[2].expr).toEqual('void => *');
+ expect(definitions[2].animation[1].timings).toEqual('300ms ease-in-out');
+ expect(definitions[3].expr).toEqual('* <=> *');
+ expect(definitions[3].animation.timings).toEqual('300ms ease-in-out');
+ });
+});
diff --git a/src/modules/animation/emerge.ts b/src/modules/animation/emerge.ts
new file mode 100644
index 000000000..eb121f5c8
--- /dev/null
+++ b/src/modules/animation/emerge.ts
@@ -0,0 +1,32 @@
+// #region imports
+import {
+ AnimationEntryMetadata
+} from '@angular/core';
+
+import {
+ animate,
+ state,
+ style,
+ transition,
+ trigger
+} from '@angular/animations';
+// #endregion
+
+export const skyAnimationEmerge = trigger('skyAnimationEmerge', [
+ state('open', style({
+ opacity: 1,
+ transform: 'scale(1)'
+ })),
+ state('closed', style({
+ opacity: 0,
+ transform: 'scale(0.0)'
+ })),
+ transition('void => *', [
+ style({
+ opacity: 0,
+ transform: 'scale(0.0)'
+ }),
+ animate('300ms ease-in-out')
+ ]),
+ transition(`* <=> *`, animate('300ms ease-in-out'))
+]) as AnimationEntryMetadata;
diff --git a/src/modules/animation/index.ts b/src/modules/animation/index.ts
new file mode 100644
index 000000000..aa0000d43
--- /dev/null
+++ b/src/modules/animation/index.ts
@@ -0,0 +1,2 @@
+export * from './emerge';
+export * from './slide';
diff --git a/src/modules/animation/slide.ts b/src/modules/animation/slide.ts
index 9e8bb81c0..0a033def6 100644
--- a/src/modules/animation/slide.ts
+++ b/src/modules/animation/slide.ts
@@ -1,11 +1,16 @@
+// #region imports
+import {
+ AnimationEntryMetadata
+} from '@angular/core';
+
import {
animate,
- AnimationEntryMetadata,
state,
style,
transition,
trigger
-} from '@angular/core';
+} from '@angular/animations';
+// #endregion
export const skyAnimationSlide = trigger('skyAnimationSlide', [
state('down', style({
diff --git a/src/modules/toast/fixtures/index.ts b/src/modules/toast/fixtures/index.ts
new file mode 100644
index 000000000..020851ae3
--- /dev/null
+++ b/src/modules/toast/fixtures/index.ts
@@ -0,0 +1,5 @@
+export * from './toast-body-context';
+export * from './toast-body.component.fixture';
+export * from './toast-fixtures.module';
+export * from './toast.component.fixture';
+export * from './toaster.component.fixture';
diff --git a/src/modules/toast/fixtures/toast-body-context.ts b/src/modules/toast/fixtures/toast-body-context.ts
new file mode 100644
index 000000000..13f40d186
--- /dev/null
+++ b/src/modules/toast/fixtures/toast-body-context.ts
@@ -0,0 +1,5 @@
+export class SkyToastBodyTestContext {
+ constructor(
+ public message: string
+ ) { }
+}
diff --git a/src/modules/toast/fixtures/toast-body.component.fixture.html b/src/modules/toast/fixtures/toast-body.component.fixture.html
new file mode 100644
index 000000000..8b69eb26b
--- /dev/null
+++ b/src/modules/toast/fixtures/toast-body.component.fixture.html
@@ -0,0 +1,10 @@
+
+ {{ context.message }}
+
+
diff --git a/src/modules/toast/fixtures/toast-body.component.fixture.ts b/src/modules/toast/fixtures/toast-body.component.fixture.ts
new file mode 100644
index 000000000..29ec0d7cb
--- /dev/null
+++ b/src/modules/toast/fixtures/toast-body.component.fixture.ts
@@ -0,0 +1,28 @@
+// #region imports
+import {
+ Component
+} from '@angular/core';
+
+import {
+ SkyToastInstance
+} from '../toast-instance';
+
+import {
+ SkyToastBodyTestContext
+} from './toast-body-context';
+// #endregion
+
+@Component({
+ selector: 'sky-toast-body-test',
+ templateUrl: './toast-body.component.fixture.html'
+})
+export class SkyToastBodyTestComponent {
+ constructor(
+ public context: SkyToastBodyTestContext,
+ private instance: SkyToastInstance
+ ) { }
+
+ public close(): void {
+ this.instance.close();
+ }
+}
diff --git a/src/modules/toast/fixtures/toast-fixtures.module.ts b/src/modules/toast/fixtures/toast-fixtures.module.ts
new file mode 100644
index 000000000..9ef1222c7
--- /dev/null
+++ b/src/modules/toast/fixtures/toast-fixtures.module.ts
@@ -0,0 +1,50 @@
+// #region imports
+import {
+ NgModule
+} from '@angular/core';
+
+import {
+ CommonModule
+} from '@angular/common';
+
+import {
+ NoopAnimationsModule
+} from '@angular/platform-browser/animations';
+
+import {
+ SkyToastModule
+} from '../toast.module';
+
+import {
+ SkyToastBodyTestComponent
+} from './toast-body.component.fixture';
+
+import {
+ SkyToastTestComponent
+} from './toast.component.fixture';
+
+import {
+ SkyToasterTestComponent
+} from './toaster.component.fixture';
+// #endregion
+
+@NgModule({
+ declarations: [
+ SkyToastTestComponent,
+ SkyToastBodyTestComponent,
+ SkyToasterTestComponent
+ ],
+ imports: [
+ CommonModule,
+ NoopAnimationsModule,
+ SkyToastModule
+ ],
+ exports: [
+ SkyToastTestComponent,
+ SkyToasterTestComponent
+ ],
+ entryComponents: [
+ SkyToastBodyTestComponent
+ ]
+})
+export class SkyToastFixturesModule { }
diff --git a/src/modules/toast/fixtures/toast.component.fixture.html b/src/modules/toast/fixtures/toast.component.fixture.html
new file mode 100644
index 000000000..c0ea0ae60
--- /dev/null
+++ b/src/modules/toast/fixtures/toast.component.fixture.html
@@ -0,0 +1,6 @@
+
+ Inner content here...
+
diff --git a/src/modules/toast/fixtures/toast.component.fixture.ts b/src/modules/toast/fixtures/toast.component.fixture.ts
new file mode 100644
index 000000000..0b1f0fdb6
--- /dev/null
+++ b/src/modules/toast/fixtures/toast.component.fixture.ts
@@ -0,0 +1,27 @@
+// #region imports
+import {
+ Component,
+ ViewChild
+} from '@angular/core';
+
+import {
+ SkyToastType
+} from '../types/toast-type';
+
+import {
+ SkyToastComponent
+} from '../toast.component';
+// #endregion
+
+@Component({
+ selector: 'sky-test-cmp',
+ templateUrl: './toast.component.fixture.html'
+})
+export class SkyToastTestComponent {
+ public toastType: SkyToastType;
+
+ @ViewChild(SkyToastComponent)
+ public toastComponent: SkyToastComponent;
+
+ public onClosed(): void { }
+}
diff --git a/src/modules/toast/fixtures/toaster.component.fixture.ts b/src/modules/toast/fixtures/toaster.component.fixture.ts
new file mode 100644
index 000000000..f504f6a5d
--- /dev/null
+++ b/src/modules/toast/fixtures/toaster.component.fixture.ts
@@ -0,0 +1,11 @@
+// #region imports
+import {
+ Component
+} from '@angular/core';
+// #endregion
+
+@Component({
+ selector: 'sky-test-cmp',
+ template: 'noop'
+})
+export class SkyToasterTestComponent { }
diff --git a/src/modules/toast/index.ts b/src/modules/toast/index.ts
new file mode 100644
index 000000000..5148a8060
--- /dev/null
+++ b/src/modules/toast/index.ts
@@ -0,0 +1,4 @@
+export * from './types';
+export * from './toast-instance';
+export * from './toast.module';
+export * from './toast.service';
diff --git a/src/modules/toast/toast-adapter.service.spec.ts b/src/modules/toast/toast-adapter.service.spec.ts
new file mode 100644
index 000000000..51991ddef
--- /dev/null
+++ b/src/modules/toast/toast-adapter.service.spec.ts
@@ -0,0 +1,56 @@
+// #region imports
+import {
+ RendererFactory2
+} from '@angular/core';
+
+import {
+ TestBed
+} from '@angular/core/testing';
+
+import {
+ SkyWindowRefService
+} from '../window';
+
+import {
+ SkyToastAdapterService
+} from './toast-adapter.service';
+// #endregion
+
+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/toast-adapter.service.ts b/src/modules/toast/toast-adapter.service.ts
new file mode 100644
index 000000000..99ec53934
--- /dev/null
+++ b/src/modules/toast/toast-adapter.service.ts
@@ -0,0 +1,43 @@
+// #region imports
+import {
+ ElementRef,
+ Injectable,
+ Renderer2,
+ RendererFactory2
+} from '@angular/core';
+
+import {
+ SkyWindowRefService
+} from '../window';
+// #endregion
+
+@Injectable()
+export class SkyToastAdapterService {
+ private hostElement: any;
+ 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.hostElement = element;
+ this.renderer.appendChild(body, element);
+ }
+
+ public removeHostElement(): void {
+ const document = this.windowRef.getWindow().document;
+ this.renderer.removeChild(document.body, this.hostElement);
+ }
+
+ public scrollBottom(elementRef: ElementRef): void {
+ const element = elementRef.nativeElement;
+ this.windowRef.getWindow().setTimeout(() => {
+ element.scrollTop = element.scrollHeight;
+ });
+ }
+}
diff --git a/src/modules/toast/toast-body-context.ts b/src/modules/toast/toast-body-context.ts
new file mode 100644
index 000000000..2f3873ef8
--- /dev/null
+++ b/src/modules/toast/toast-body-context.ts
@@ -0,0 +1,10 @@
+// #region imports
+import {
+ Injectable
+} from '@angular/core';
+// #endregion
+
+@Injectable()
+export class SkyToastBodyContext {
+ public message: string;
+}
diff --git a/src/modules/toast/toast-body.component.html b/src/modules/toast/toast-body.component.html
new file mode 100644
index 000000000..813de5501
--- /dev/null
+++ b/src/modules/toast/toast-body.component.html
@@ -0,0 +1,3 @@
+
+ {{ context.message }}
+
diff --git a/src/modules/toast/toast-body.component.ts b/src/modules/toast/toast-body.component.ts
new file mode 100644
index 000000000..d9d025765
--- /dev/null
+++ b/src/modules/toast/toast-body.component.ts
@@ -0,0 +1,19 @@
+// #region imports
+import {
+ Component
+} from '@angular/core';
+
+import {
+ SkyToastBodyContext
+} from './toast-body-context';
+// #endregion
+
+@Component({
+ selector: 'sky-toast-body',
+ templateUrl: './toast-body.component.html'
+})
+export class SkyToastBodyComponent {
+ constructor(
+ public context: SkyToastBodyContext
+ ) { }
+}
diff --git a/src/modules/toast/toast-instance.ts b/src/modules/toast/toast-instance.ts
new file mode 100644
index 000000000..8fb3879ea
--- /dev/null
+++ b/src/modules/toast/toast-instance.ts
@@ -0,0 +1,25 @@
+// #region imports
+import {
+ EventEmitter
+} from '@angular/core';
+
+import {
+ Observable
+} from 'rxjs/Observable';
+// #endregion
+
+export class SkyToastInstance {
+ public get closed(): Observable {
+ return this._closed;
+ }
+
+ private _closed = new EventEmitter();
+
+ /**
+ * Closes the toast component.
+ */
+ public close(): void {
+ this._closed.emit();
+ this._closed.complete();
+ }
+}
diff --git a/src/modules/toast/toast.component.html b/src/modules/toast/toast.component.html
new file mode 100644
index 000000000..48e668921
--- /dev/null
+++ b/src/modules/toast/toast.component.html
@@ -0,0 +1,24 @@
+
diff --git a/src/modules/toast/toast.component.scss b/src/modules/toast/toast.component.scss
new file mode 100644
index 000000000..13947f3f6
--- /dev/null
+++ b/src/modules/toast/toast.component.scss
@@ -0,0 +1,100 @@
+@import '../../scss/mixins';
+
+.sky-toast {
+ @include sky-shadow();
+ 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;
+
+ button {
+ margin-left: auto;
+ width: 32px;
+ height: 32px;
+ }
+}
+
+.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;
+ }
+ }
+}
+
+@mixin sky-toast-variant(
+ $background-color,
+ $border-color,
+ $icon
+) {
+ background-color: $background-color;
+ border-color: $border-color;
+
+ &:before {
+ content: "\f06a";
+ font-family: FontAwesome;
+ margin-left: -31px;
+ margin-right: 20px;
+ color: $sky-color-white;
+ }
+}
+
+.sky-toast-info {
+ @include sky-toast-variant(
+ $sky-background-color-info,
+ $sky-highlight-color-info,
+ "\f06a"
+ );
+}
+
+.sky-toast-success {
+ @include sky-toast-variant(
+ $sky-background-color-success,
+ $sky-highlight-color-success,
+ "\f00c"
+ );
+}
+
+.sky-toast-warning {
+ @include sky-toast-variant(
+ $sky-background-color-warning,
+ $sky-highlight-color-warning,
+ "\f071"
+ );
+}
+
+.sky-toast-danger {
+ @include sky-toast-variant(
+ $sky-background-color-danger,
+ $sky-highlight-color-danger,
+ "\f071"
+ );
+}
+
+.sky-toast-btn-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;
+ }
+}
diff --git a/src/modules/toast/toast.component.spec.ts b/src/modules/toast/toast.component.spec.ts
new file mode 100644
index 000000000..9e1442100
--- /dev/null
+++ b/src/modules/toast/toast.component.spec.ts
@@ -0,0 +1,90 @@
+// #region imports
+import {
+ ComponentFixture,
+ TestBed
+} from '@angular/core/testing';
+
+import {
+ expect
+} from '@blackbaud/skyux-builder/runtime/testing/browser';
+
+import {
+ SkyToastFixturesModule,
+ SkyToastTestComponent
+} from './fixtures';
+
+import {
+ SkyToastType
+} from './types';
+
+import {
+ SkyToastComponent
+} from './toast.component';
+// #endregion
+
+describe('Toast component', () => {
+ let fixture: ComponentFixture;
+ let component: SkyToastTestComponent;
+ let toastComponent: SkyToastComponent;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ SkyToastFixturesModule
+ ]
+ });
+
+ fixture = TestBed.createComponent(SkyToastTestComponent);
+ component = fixture.componentInstance;
+ toastComponent = component.toastComponent;
+ });
+
+ afterEach(() => {
+ fixture.destroy();
+ });
+
+ function verifyType(type?: SkyToastType) {
+ component.toastType = type;
+ fixture.detectChanges();
+
+ let className: string;
+ if (SkyToastType[type]) {
+ className = `sky-toast-${SkyToastType[type].toLowerCase()}`;
+ } else {
+ className = `sky-toast-info`;
+ }
+
+ expect(className).toEqual(toastComponent.classNames);
+ }
+
+ it('should set defaults', () => {
+ expect(toastComponent.toastType).toEqual(SkyToastType.Info);
+ });
+
+ it('should allow setting the toast type', () => {
+ verifyType(); // default
+ verifyType(SkyToastType.Info);
+ verifyType(SkyToastType.Success);
+ verifyType(SkyToastType.Warning);
+ verifyType(SkyToastType.Danger);
+ });
+
+ it('should close the toast when clicking close button', () => {
+ fixture.detectChanges();
+ expect(toastComponent['isOpen']).toEqual(true);
+ expect(toastComponent.animationState).toEqual('open');
+ fixture.nativeElement.querySelector('.sky-toast-btn-close').click();
+ fixture.detectChanges();
+ expect(toastComponent['isOpen']).toEqual(false);
+ expect(toastComponent.animationState).toEqual('closed');
+ });
+
+ it('should set aria attributes', () => {
+ expect(toastComponent.ariaLive).toEqual('polite');
+ expect(toastComponent.ariaRole).toEqual(undefined);
+ fixture.componentInstance.toastType = SkyToastType.Danger;
+ fixture.detectChanges();
+ expect(toastComponent.ariaLive).toEqual('assertive');
+ expect(toastComponent.ariaRole).toEqual('alert');
+ });
+});
diff --git a/src/modules/toast/toast.component.ts b/src/modules/toast/toast.component.ts
new file mode 100644
index 000000000..2fe3e7022
--- /dev/null
+++ b/src/modules/toast/toast.component.ts
@@ -0,0 +1,112 @@
+// #region imports
+import {
+ ChangeDetectionStrategy,
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ Input,
+ OnInit,
+ Output
+} from '@angular/core';
+
+import {
+ AnimationEvent
+} from '@angular/animations';
+
+import {
+ skyAnimationEmerge
+} from '../animation';
+
+import {
+ SkyToastType
+} from './types';
+// #endregion
+
+@Component({
+ selector: 'sky-toast',
+ templateUrl: './toast.component.html',
+ styleUrls: ['./toast.component.scss'],
+ animations: [
+ skyAnimationEmerge
+ ],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SkyToastComponent implements OnInit {
+ @Input()
+ public set toastType(value: SkyToastType) {
+ this._toastType = value;
+ }
+
+ public get toastType(): SkyToastType {
+ return (this._toastType === undefined) ? SkyToastType.Info : this._toastType;
+ }
+
+ @Output()
+ public closed = new EventEmitter();
+
+ public get animationState(): string {
+ return (this.isOpen) ? 'open' : 'closed';
+ }
+
+ public get ariaLive(): string {
+ return (this.toastType === SkyToastType.Danger) ? 'assertive' : 'polite';
+ }
+
+ public get ariaRole(): string {
+ return (this.toastType === SkyToastType.Danger) ? 'alert' : undefined;
+ }
+
+ public get classNames(): string {
+ const classNames: string[] = [];
+
+ let typeLabel: string;
+ switch (this.toastType) {
+ case SkyToastType.Danger:
+ typeLabel = 'danger';
+ break;
+
+ case SkyToastType.Info:
+ default:
+ typeLabel = 'info';
+ break;
+
+ case SkyToastType.Success:
+ typeLabel = 'success';
+ break;
+
+ case SkyToastType.Warning:
+ typeLabel = 'warning';
+ break;
+ }
+
+ classNames.push(
+ `sky-toast-${typeLabel}`
+ );
+
+ return classNames.join(' ');
+ }
+
+ private isOpen = false;
+
+ private _toastType: SkyToastType;
+
+ constructor(
+ private changeDetector: ChangeDetectorRef
+ ) { }
+
+ public ngOnInit(): void {
+ this.isOpen = true;
+ }
+
+ public onAnimationDone(event: AnimationEvent) {
+ if (event.toState === 'closed') {
+ this.closed.emit();
+ this.closed.complete();
+ }
+ }
+
+ public close() {
+ this.isOpen = false;
+ this.changeDetector.markForCheck();
+ }
+}
diff --git a/src/modules/toast/toast.module.ts b/src/modules/toast/toast.module.ts
new file mode 100644
index 000000000..17ee64cc9
--- /dev/null
+++ b/src/modules/toast/toast.module.ts
@@ -0,0 +1,63 @@
+// #region imports
+import {
+ NgModule
+} from '@angular/core';
+
+import {
+ CommonModule
+} from '@angular/common';
+
+import {
+ SkyResourcesModule
+} from '../resources';
+
+import {
+ SkyWindowRefService
+} from '../window';
+
+import {
+ SkyToastAdapterService
+} from './toast-adapter.service';
+
+import {
+ SkyToastBodyComponent
+} from './toast-body.component';
+
+import {
+ SkyToastComponent
+} from './toast.component';
+
+import {
+ SkyToasterComponent
+} from './toaster.component';
+
+import {
+ SkyToastService
+} from './toast.service';
+// #endregion
+
+@NgModule({
+ declarations: [
+ SkyToastBodyComponent,
+ SkyToastComponent,
+ SkyToasterComponent
+ ],
+ imports: [
+ CommonModule,
+ SkyResourcesModule
+ ],
+ exports: [
+ SkyToastComponent
+ ],
+ providers: [
+ SkyToastService,
+ SkyToastAdapterService,
+ SkyWindowRefService
+ ],
+ entryComponents: [
+ SkyToastBodyComponent,
+ SkyToastComponent,
+ SkyToasterComponent
+ ]
+})
+export class SkyToastModule { }
diff --git a/src/modules/toast/toast.service.spec.ts b/src/modules/toast/toast.service.spec.ts
new file mode 100644
index 000000000..89581bafd
--- /dev/null
+++ b/src/modules/toast/toast.service.spec.ts
@@ -0,0 +1,186 @@
+// #region imports
+import {
+ ApplicationRef
+} from '@angular/core';
+
+import {
+ TestBed
+} from '@angular/core/testing';
+
+import 'rxjs/add/operator/take';
+
+import {
+ expect
+} from '@blackbaud/skyux-builder/runtime/testing/browser';
+
+import {
+ SkyToastFixturesModule
+} from './fixtures';
+
+import {
+ SkyToastType
+} from './types';
+
+import {
+ SkyToast
+} from './toast';
+
+import {
+ SkyToastAdapterService
+} from './toast-adapter.service';
+
+import {
+ SkyToastService
+} from './toast.service';
+// #endregion
+
+describe('Toast service', () => {
+ let toastService: SkyToastService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ SkyToastFixturesModule
+ ],
+ providers: [
+ {
+ provide: SkyToastAdapterService,
+ useValue: {
+ appendToBody() { },
+ removeHostElement() { }
+ }
+ },
+ {
+ provide: ApplicationRef,
+ useValue: {
+ attachView() {},
+ detachView() {}
+ }
+ }
+ ]
+ });
+
+ 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 only remove the host element if it exists', () => {
+ toastService.openMessage('message');
+ const spy = spyOn(toastService['host'], 'destroy').and.callThrough();
+ toastService['removeHostComponent']();
+ toastService['removeHostComponent']();
+ expect(spy.calls.count()).toEqual(1);
+ });
+
+ it('should expose a method to remove the toast from the DOM', () => {
+ toastService.openMessage('message');
+ const spy = spyOn(toastService['host'].instance, 'closeAll').and.callFake(() => {});
+ toastService.ngOnDestroy();
+ expect(spy).toHaveBeenCalledWith();
+ });
+
+ describe('openMessage() method', () => {
+ it('should open a toast with the given message and configuration', function() {
+ const instance = toastService.openMessage('Real message', {
+ type: SkyToastType.Danger
+ });
+
+ expect(instance).toBeTruthy();
+ expect(instance.close).toBeTruthy();
+
+ let isClosedCalled = false;
+ instance.closed.subscribe(() => isClosedCalled = true);
+
+ expect(isClosedCalled).toEqual(false);
+ instance.close();
+ expect(isClosedCalled).toEqual(true);
+ });
+
+ it('should remove message from queue when the message is closed', () => {
+ const instance = toastService.openMessage('My message');
+
+ let isClosedCalled = false;
+ instance.closed.subscribe(() => isClosedCalled = true);
+
+ instance.close();
+
+ toastService.toastStream.take(1).subscribe((value) => {
+ expect(value.length).toEqual(0);
+ expect(isClosedCalled).toBeTruthy();
+ });
+ });
+
+ it('should complete the instance closed emitter', () => {
+ const instance = toastService.openMessage('My message');
+ let numTimesCalled = 0;
+ instance.closed.subscribe(() => {
+ numTimesCalled++;
+ });
+ instance.close();
+ instance.close();
+ instance.close();
+ expect(numTimesCalled).toEqual(1);
+ });
+ });
+
+ describe('openComponent() method', () => {
+ class TestContext {
+ public message: string;
+ }
+
+ class TestComponent { }
+
+ it('should open a custom toast with the given component type and configuration', () => {
+ const context = new TestContext();
+ context.message = 'Hello!';
+
+ const providers = {
+ provide: TestContext,
+ useValue: context
+ };
+
+ const instance = toastService.openComponent(
+ TestComponent,
+ {
+ type: SkyToastType.Danger
+ },
+ [providers]
+ );
+
+ toastService.toastStream.take(1).subscribe((toasts: SkyToast[]) => {
+ expect(toasts[0].bodyComponentProviders[0]).toEqual(providers);
+ });
+
+ expect(instance).toBeTruthy();
+ expect(instance.close).toBeTruthy();
+
+ let isClosedCalled = false;
+ instance.closed.subscribe(() => isClosedCalled = true);
+
+ expect(isClosedCalled).toEqual(false);
+ instance.close();
+ expect(isClosedCalled).toEqual(true);
+ });
+
+ it('should handle empty providers', () => {
+ toastService.openComponent(TestComponent, {
+ type: SkyToastType.Danger
+ });
+
+ toastService.toastStream.take(1).subscribe((toasts: SkyToast[]) => {
+ expect(toasts[0].bodyComponentProviders.length).toEqual(1);
+ });
+ });
+ });
+});
diff --git a/src/modules/toast/toast.service.ts b/src/modules/toast/toast.service.ts
new file mode 100644
index 000000000..e081e197b
--- /dev/null
+++ b/src/modules/toast/toast.service.ts
@@ -0,0 +1,165 @@
+// #region imports
+import {
+ ApplicationRef,
+ ComponentRef,
+ ComponentFactoryResolver,
+ EmbeddedViewRef,
+ Injectable,
+ Injector,
+ OnDestroy,
+ Provider
+} from '@angular/core';
+
+import {
+ BehaviorSubject
+} from 'rxjs/BehaviorSubject';
+
+import {
+ Observable
+} from 'rxjs/Observable';
+
+import {
+ SkyToastConfig
+} from './types';
+
+import {
+ SkyToast
+} from './toast';
+
+import {
+ SkyToastAdapterService
+} from './toast-adapter.service';
+
+import {
+ SkyToastBodyComponent
+} from './toast-body.component';
+
+import {
+ SkyToastInstance
+} from './toast-instance';
+
+import {
+ SkyToastBodyContext
+} from './toast-body-context';
+
+import {
+ SkyToasterComponent
+} from './toaster.component';
+// #endregion
+
+@Injectable()
+export class SkyToastService implements OnDestroy {
+ public get toastStream(): Observable {
+ return this._toastStream;
+ }
+
+ private host: ComponentRef;
+ private toasts: SkyToast[] = [];
+
+ private _toastStream = new BehaviorSubject([]);
+
+ constructor(
+ private appRef: ApplicationRef,
+ private resolver: ComponentFactoryResolver,
+ private injector: Injector,
+ private adapter: SkyToastAdapterService
+ ) { }
+
+ public ngOnDestroy() {
+ this.closeAll();
+ this.removeHostComponent();
+ this._toastStream.complete();
+ }
+
+ /**
+ * Opens a new toast with a text message.
+ * @param message Text to display inside the toast
+ * @param config Optional configuration
+ */
+ public openMessage(
+ message: string,
+ config?: SkyToastConfig
+ ): SkyToastInstance {
+ const context = new SkyToastBodyContext();
+ context.message = message;
+
+ const providers = [{
+ provide: SkyToastBodyContext,
+ useValue: context
+ }];
+
+ return this.openComponent(SkyToastBodyComponent, config, providers);
+ }
+
+ /**
+ * Opens a new toast using a custom component.
+ * @param component Angular component to inject into the toast body
+ * @param config Optional configuration
+ * @param providers Optional providers for the custom component
+ */
+ public openComponent(
+ component: any,
+ config?: SkyToastConfig,
+ providers: Provider[] = []
+ ): SkyToastInstance {
+ const instance = new SkyToastInstance();
+
+ providers.push({
+ provide: SkyToastInstance,
+ useValue: instance
+ });
+
+ const toast = new SkyToast(component, providers, config);
+ toast.instance = instance;
+ this.addToast(toast);
+
+ return instance;
+ }
+
+ /**
+ * Closes all active toast components.
+ */
+ public closeAll(): void {
+ this.host.instance.closeAll();
+ }
+
+ private addToast(toast: SkyToast): void {
+ if (!this.host) {
+ this.host = this.createHostComponent();
+ }
+
+ this.toasts.push(toast);
+ this._toastStream.next(this.toasts);
+ toast.instance.closed.subscribe(() => {
+ this.removeToast(toast);
+ });
+ }
+
+ private removeToast(toast: SkyToast): void {
+ this.toasts = this.toasts.filter(t => t !== toast);
+ this._toastStream.next(this.toasts);
+ }
+
+ 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;
+ }
+
+ private removeHostComponent() {
+ if (this.host) {
+ this.appRef.detachView(this.host.hostView);
+ this.host.destroy();
+ this.host = undefined;
+ }
+
+ this.adapter.removeHostElement();
+ }
+}
diff --git a/src/modules/toast/toast.spec.ts b/src/modules/toast/toast.spec.ts
new file mode 100644
index 000000000..d7d9db229
--- /dev/null
+++ b/src/modules/toast/toast.spec.ts
@@ -0,0 +1,48 @@
+// #region imports
+import {
+ expect
+} from '@blackbaud/skyux-builder/runtime/testing/browser';
+
+import {
+ Provider
+} from '@angular/core';
+
+import {
+ SkyToastConfig
+} from './types';
+
+import {
+ SkyToast
+} from './toast';
+
+import {
+ SkyToastInstance
+} from './toast-instance';
+// #endregion
+
+describe('Toast class', () => {
+ it('should set defaults', () => {
+ const component = function () {};
+ const providers: Provider[] = [];
+ const config: SkyToastConfig = {};
+ const toast = new SkyToast(component, providers, config);
+ expect(toast.bodyComponent).toEqual(component);
+ expect(toast.bodyComponentProviders).toEqual(providers);
+ expect(toast.config).toEqual(config);
+ expect(toast.instance).toBeUndefined();
+ });
+
+ it('should only allow setting the instance once', () => {
+ const component = function () {};
+ const providers: Provider[] = [];
+ const config: SkyToastConfig = {};
+ const toast = new SkyToast(component, providers, config);
+ const firstInstance = new SkyToastInstance();
+ (firstInstance as any).fooName = 'first';
+ const secondInstance = new SkyToastInstance();
+ (secondInstance as any).fooName = 'second';
+ toast.instance = firstInstance;
+ toast.instance = secondInstance;
+ expect((toast.instance as any).fooName).toEqual('first');
+ });
+});
diff --git a/src/modules/toast/toast.ts b/src/modules/toast/toast.ts
new file mode 100644
index 000000000..9eb016674
--- /dev/null
+++ b/src/modules/toast/toast.ts
@@ -0,0 +1,45 @@
+// #region imports
+import {
+ Provider
+} from '@angular/core';
+
+import {
+ SkyToastConfig
+} from './types';
+
+import {
+ SkyToastInstance
+} from './toast-instance';
+// #endregion
+
+export class SkyToast {
+ public get bodyComponent(): any {
+ return this._bodyComponent;
+ }
+
+ public get bodyComponentProviders(): Provider[] {
+ return this._bodyComponentProviders;
+ }
+
+ public get config(): SkyToastConfig {
+ return this._config;
+ }
+
+ public get instance(): SkyToastInstance {
+ return this._instance;
+ }
+
+ public set instance(value: SkyToastInstance) {
+ if (!this._instance) {
+ this._instance = value;
+ }
+ }
+
+ private _instance: SkyToastInstance;
+
+ constructor(
+ private _bodyComponent: any,
+ private _bodyComponentProviders: Provider[],
+ private _config: SkyToastConfig
+ ) { }
+}
diff --git a/src/modules/toast/toaster.component.html b/src/modules/toast/toaster.component.html
new file mode 100644
index 000000000..16ba51e6a
--- /dev/null
+++ b/src/modules/toast/toaster.component.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/src/modules/toast/toaster.component.scss b/src/modules/toast/toaster.component.scss
new file mode 100644
index 000000000..3399a3641
--- /dev/null
+++ b/src/modules/toast/toaster.component.scss
@@ -0,0 +1,14 @@
+@import '../../scss/mixins';
+
+.sky-toaster {
+ bottom: 0;
+ right: 0;
+ display: block;
+ max-width: 300px;
+ max-height: 100%;
+ overflow-y: auto;
+ position: fixed;
+ padding-bottom: $sky-margin-double;
+ padding-right: $sky-margin-double;
+ padding-top: 50px;
+}
diff --git a/src/modules/toast/toaster.component.spec.ts b/src/modules/toast/toaster.component.spec.ts
new file mode 100644
index 000000000..ed4f9b1cb
--- /dev/null
+++ b/src/modules/toast/toaster.component.spec.ts
@@ -0,0 +1,165 @@
+// #region imports
+import {
+ ApplicationRef
+} from '@angular/core';
+
+import {
+ ComponentFixture,
+ fakeAsync,
+ inject,
+ TestBed,
+ tick
+} from '@angular/core/testing';
+
+import {
+ expect
+} from '@blackbaud/skyux-builder/runtime/testing/browser';
+
+import {
+ SkyToastFixturesModule,
+ SkyToasterTestComponent,
+ SkyToastBodyTestComponent,
+ SkyToastBodyTestContext
+} from './fixtures';
+
+import {
+ SkyToastInstance
+} from './toast-instance';
+
+import {
+ SkyToastService
+} from './toast.service';
+// #endregion
+
+describe('Toast component', () => {
+ let fixture: ComponentFixture;
+ let toastService: SkyToastService;
+ let applicationRef: ApplicationRef;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [
+ SkyToastFixturesModule
+ ]
+ });
+
+ fixture = TestBed.createComponent(SkyToasterTestComponent);
+ });
+
+ beforeEach(inject(
+ [ApplicationRef, SkyToastService],
+ (
+ _applicationRef: ApplicationRef,
+ _toastService: SkyToastService
+ ) => {
+ applicationRef = _applicationRef;
+ toastService = _toastService;
+ }
+ ));
+
+ afterEach(fakeAsync(() => {
+ toastService.closeAll();
+ applicationRef.tick();
+ tick();
+ fixture.detectChanges();
+ fixture.destroy();
+ }));
+
+ function getToastElements(): NodeListOf {
+ return document.querySelectorAll('sky-toast');
+ }
+
+ function openMessage(message = ''): SkyToastInstance {
+ const instance = toastService.openMessage(message);
+ fixture.detectChanges();
+ tick();
+ return instance;
+ }
+
+ function openComponent(message = ''): SkyToastInstance {
+ const providers = [{
+ provide: SkyToastBodyTestContext,
+ useValue: new SkyToastBodyTestContext(message)
+ }];
+ const instance = toastService.openComponent(
+ SkyToastBodyTestComponent,
+ {},
+ providers
+ );
+ fixture.detectChanges();
+ tick();
+ return instance;
+ }
+
+ it('should not create a toaster element if one exists', fakeAsync(() => {
+ openMessage();
+
+ let toasters = document.querySelectorAll('sky-toaster');
+ expect(toasters.length).toEqual(1);
+
+ openMessage();
+ toasters = document.querySelectorAll('sky-toaster');
+ expect(toasters.length).toEqual(1);
+
+ const toasts = getToastElements();
+ expect(toasts.length).toEqual(2);
+ }));
+
+ it('should display a toast component with defaults', fakeAsync(() => {
+ const message = 'Hello, World!';
+ openMessage(message);
+
+ const toasts = getToastElements();
+ expect(toasts.length).toEqual(1);
+ expect(toasts.item(0).querySelector('.sky-toast-content')).toHaveText(message, true);
+ expect(toasts.item(0).querySelector('.sky-toast-info')).toExist();
+ }));
+
+ it('should handle closing a toast', fakeAsync(() => {
+ openMessage();
+ openMessage();
+ openMessage();
+
+ let toasts = getToastElements();
+ expect(toasts.length).toEqual(3);
+
+ toasts.item(0).querySelector('.sky-toast-btn-close').click();
+ fixture.detectChanges();
+ tick();
+
+ toasts = getToastElements();
+ expect(toasts.length).toEqual(2);
+ }));
+
+ it('should handle closing a toast instance from inside a custom component', fakeAsync(() => {
+ const message = 'Hello, component!';
+ openComponent(message);
+
+ let toasts = getToastElements();
+ expect(toasts.length).toEqual(1);
+ expect(toasts.item(0).querySelector('.sky-toast-body-test-content')).toHaveText(message, true);
+
+ toasts.item(0).querySelector('.sky-toast-body-test-btn-close').click();
+ fixture.detectChanges();
+ tick();
+
+ toasts = getToastElements();
+ expect(toasts.length).toEqual(0);
+ }));
+
+ it('should close all toasts', fakeAsync(() => {
+ openMessage();
+ openMessage();
+ openMessage();
+
+ let toasts = getToastElements();
+ expect(toasts.length).toEqual(3);
+
+ toastService.closeAll();
+ fixture.detectChanges();
+ tick();
+
+ toasts = getToastElements();
+ expect(toasts.length).toEqual(0);
+ }));
+});
diff --git a/src/modules/toast/toaster.component.ts b/src/modules/toast/toaster.component.ts
new file mode 100644
index 000000000..9a76d3795
--- /dev/null
+++ b/src/modules/toast/toaster.component.ts
@@ -0,0 +1,106 @@
+// #region imports
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ Component,
+ ComponentFactoryResolver,
+ ElementRef,
+ Injector,
+ ReflectiveInjector,
+ QueryList,
+ ViewChild,
+ ViewChildren,
+ ViewContainerRef
+} from '@angular/core';
+
+import {
+ Observable
+} from 'rxjs/Observable';
+
+import 'rxjs/add/operator/take';
+
+import {
+ SkyToast
+} from './toast';
+
+import {
+ SkyToastComponent
+} from './toast.component';
+
+import {
+ SkyToastAdapterService
+} from './toast-adapter.service';
+
+import {
+ SkyToastService
+} from './toast.service';
+// #endregion
+
+@Component({
+ selector: 'sky-toaster',
+ templateUrl: './toaster.component.html',
+ styleUrls: ['./toaster.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class SkyToasterComponent implements AfterViewInit {
+ public get toastStream(): Observable {
+ return this.toastService.toastStream;
+ }
+
+ @ViewChild('toaster')
+ private toaster: ElementRef;
+
+ @ViewChildren('toastContent', { read: ViewContainerRef })
+ private toastContent: QueryList;
+
+ @ViewChildren(SkyToastComponent)
+ private toastComponents: QueryList;
+
+ constructor(
+ private domAdapter: SkyToastAdapterService,
+ private toastService: SkyToastService,
+ private resolver: ComponentFactoryResolver,
+ private injector: Injector
+ ) { }
+
+ public ngAfterViewInit(): void {
+ this.injectToastContent();
+ this.toastContent.changes.subscribe(() => {
+ this.injectToastContent();
+ });
+
+ // Scroll to the bottom of the toaster element when a new toast is added.
+ this.toastStream.subscribe((toasts: SkyToast[]) => {
+ this.domAdapter.scrollBottom(this.toaster);
+ });
+ }
+
+ public onToastClosed(toast: SkyToast): void {
+ toast.instance.close();
+ }
+
+ public closeAll(): void {
+ this.toastComponents.forEach((toastComponent) => {
+ toastComponent.close();
+ });
+ }
+
+ private injectToastContent(): void {
+ // Dynamically inject each toast's body content when the number of toasts changes.
+ this.toastService.toastStream.take(1).subscribe((toasts) => {
+ this.toastContent.toArray().forEach((target: ViewContainerRef, i: number) => {
+ target.clear();
+
+ const toast = toasts[i];
+ const componentFactory = this.resolver.resolveComponentFactory(toast.bodyComponent);
+ const injector = ReflectiveInjector.fromResolvedProviders(
+ ReflectiveInjector.resolve(toast.bodyComponentProviders),
+ this.injector
+ );
+
+ const componentRef = target.createComponent(componentFactory, undefined, injector);
+ componentRef.changeDetectorRef.detectChanges();
+ });
+ });
+ }
+}
diff --git a/src/modules/toast/types/index.ts b/src/modules/toast/types/index.ts
new file mode 100644
index 000000000..8a3475bf7
--- /dev/null
+++ b/src/modules/toast/types/index.ts
@@ -0,0 +1,2 @@
+export * from './toast-config';
+export * from './toast-type';
diff --git a/src/modules/toast/types/toast-config.ts b/src/modules/toast/types/toast-config.ts
new file mode 100644
index 000000000..179e1d11c
--- /dev/null
+++ b/src/modules/toast/types/toast-config.ts
@@ -0,0 +1,9 @@
+// #region imports
+import {
+ SkyToastType
+} from './toast-type';
+// #endregion
+
+export interface SkyToastConfig {
+ type?: SkyToastType;
+}
diff --git a/src/modules/toast/types/toast-type.ts b/src/modules/toast/types/toast-type.ts
new file mode 100644
index 000000000..e5d37167d
--- /dev/null
+++ b/src/modules/toast/types/toast-type.ts
@@ -0,0 +1,6 @@
+export enum SkyToastType {
+ Danger,
+ Info,
+ Success,
+ Warning
+}