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