diff --git a/src/demo-app/snack-bar/snack-bar-demo.html b/src/demo-app/snack-bar/snack-bar-demo.html index 9593e603432..4eb9e8cdc44 100644 --- a/src/demo-app/snack-bar/snack-bar-demo.html +++ b/src/demo-app/snack-bar/snack-bar-demo.html @@ -50,4 +50,12 @@

- +

+ +

+ + + + + Template snack bar: {{message}} + diff --git a/src/demo-app/snack-bar/snack-bar-demo.ts b/src/demo-app/snack-bar/snack-bar-demo.ts index 920bc57c00a..ea95babde2d 100644 --- a/src/demo-app/snack-bar/snack-bar-demo.ts +++ b/src/demo-app/snack-bar/snack-bar-demo.ts @@ -7,7 +7,7 @@ */ import {Dir} from '@angular/cdk/bidi'; -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component, ViewEncapsulation, ViewChild, TemplateRef} from '@angular/core'; import { MatSnackBar, MatSnackBarConfig, @@ -25,6 +25,7 @@ import { preserveWhitespaces: false, }) export class SnackBarDemo { + @ViewChild('template') template: TemplateRef; message: string = 'Snack Bar opened.'; actionButtonLabel: string = 'Retry'; action: boolean = false; @@ -38,12 +39,22 @@ export class SnackBarDemo { } open() { - let config = new MatSnackBarConfig(); + const config = this._createConfig(); + this.snackBar.open(this.message, this.action ? this.actionButtonLabel : undefined, config); + } + + openTemplate() { + const config = this._createConfig(); + this.snackBar.openFromTemplate(this.template, config); + } + + private _createConfig() { + const config = new MatSnackBarConfig(); config.verticalPosition = this.verticalPosition; config.horizontalPosition = this.horizontalPosition; config.duration = this.setAutoHide ? this.autoHide : 0; config.panelClass = this.addExtraClass ? ['party'] : undefined; config.direction = this.dir.value; - this.snackBar.open(this.message, this.action ? this.actionButtonLabel : undefined, config); + return config; } } diff --git a/src/lib/snack-bar/snack-bar-container.ts b/src/lib/snack-bar/snack-bar-container.ts index 85bf299689c..34dd3668994 100644 --- a/src/lib/snack-bar/snack-bar-container.ts +++ b/src/lib/snack-bar/snack-bar-container.ts @@ -23,6 +23,7 @@ import { BasePortalOutlet, ComponentPortal, CdkPortalOutlet, + TemplatePortal, } from '@angular/cdk/portal'; import {take} from 'rxjs/operators/take'; import {Observable} from 'rxjs/Observable'; @@ -78,31 +79,16 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy /** Attach a component portal as content to this snack bar container. */ attachComponentPortal(portal: ComponentPortal): ComponentRef { - if (this._portalOutlet.hasAttached()) { - throw Error('Attempting to attach snack bar content after content is already attached'); - } - - const element: HTMLElement = this._elementRef.nativeElement; - - if (this.snackBarConfig.panelClass || this.snackBarConfig.extraClasses) { - this._setCssClasses(this.snackBarConfig.panelClass); - this._setCssClasses(this.snackBarConfig.extraClasses); - } - - if (this.snackBarConfig.horizontalPosition === 'center') { - element.classList.add('mat-snack-bar-center'); - } - - if (this.snackBarConfig.verticalPosition === 'top') { - element.classList.add('mat-snack-bar-top'); - } - + this._assertNotAttached(); + this._applySnackBarClasses(); return this._portalOutlet.attachComponentPortal(portal); } /** Attach a template portal as content to this snack bar container. */ - attachTemplatePortal(): EmbeddedViewRef { - throw Error('Not yet implemented'); + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { + this._assertNotAttached(); + this._applySnackBarClasses(); + return this._portalOutlet.attachTemplatePortal(portal); } /** Handle end of animations, updating the state of the snackbar. */ @@ -171,4 +157,29 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy element.classList.add(classList); } } + + /** Applies the various positioning and user-configured CSS classes to the snack bar. */ + private _applySnackBarClasses() { + const element: HTMLElement = this._elementRef.nativeElement; + + if (this.snackBarConfig.panelClass || this.snackBarConfig.extraClasses) { + this._setCssClasses(this.snackBarConfig.panelClass); + this._setCssClasses(this.snackBarConfig.extraClasses); + } + + if (this.snackBarConfig.horizontalPosition === 'center') { + element.classList.add('mat-snack-bar-center'); + } + + if (this.snackBarConfig.verticalPosition === 'top') { + element.classList.add('mat-snack-bar-top'); + } + } + + /** Asserts that no content is already attached to the container. */ + private _assertNotAttached() { + if (this._portalOutlet.hasAttached()) { + throw Error('Attempting to attach snack bar content after content is already attached'); + } + } } diff --git a/src/lib/snack-bar/snack-bar.spec.ts b/src/lib/snack-bar/snack-bar.spec.ts index 9bd53fff8d1..0680013e74d 100644 --- a/src/lib/snack-bar/snack-bar.spec.ts +++ b/src/lib/snack-bar/snack-bar.spec.ts @@ -6,7 +6,15 @@ import { tick, flush, } from '@angular/core/testing'; -import {NgModule, Component, Directive, ViewChild, ViewContainerRef, Inject} from '@angular/core'; +import { + NgModule, + Component, + Directive, + ViewChild, + ViewContainerRef, + Inject, + TemplateRef, +} from '@angular/core'; import {CommonModule} from '@angular/common'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {OverlayContainer} from '@angular/cdk/overlay'; @@ -464,6 +472,42 @@ describe('MatSnackBar', () => { }); + describe('with TemplateRef', () => { + let templateFixture: ComponentFixture; + + beforeEach(() => { + templateFixture = TestBed.createComponent(ComponentWithTemplateRef); + templateFixture.detectChanges(); + }); + + it('should be able to open a snack bar using a TemplateRef', () => { + templateFixture.componentInstance.localValue = 'Pizza'; + snackBar.openFromTemplate(templateFixture.componentInstance.templateRef); + templateFixture.detectChanges(); + + const containerElement = overlayContainerElement.querySelector('snack-bar-container')!; + + expect(containerElement.textContent).toContain('Fries'); + expect(containerElement.textContent).toContain('Pizza'); + + templateFixture.componentInstance.localValue = 'Pasta'; + templateFixture.detectChanges(); + + expect(containerElement.textContent).toContain('Pasta'); + }); + + it('should be able to pass in contextual data when opening with a TemplateRef', () => { + snackBar.openFromTemplate(templateFixture.componentInstance.templateRef, { + data: {value: 'Oranges'} + }); + + const containerElement = overlayContainerElement.querySelector('snack-bar-container')!; + + expect(containerElement.textContent).toContain('Oranges'); + }); + + }); + }); describe('MatSnackBar with parent MatSnackBar', () => { @@ -810,6 +854,20 @@ class ComponentWithChildViewContainer { } } +@Component({ + selector: 'arbitrary-component-with-template-ref', + template: ` + + Fries {{localValue}} {{data?.value}} + + `, +}) +class ComponentWithTemplateRef { + @ViewChild(TemplateRef) templateRef: TemplateRef; + localValue: string; +} + + /** Simple component for testing ComponentPortal. */ @Component({template: '

Burritos are on the way.

'}) class BurritosNotification { @@ -835,7 +893,8 @@ class ComponentThatProvidesMatSnackBar { */ const TEST_DIRECTIVES = [ComponentWithChildViewContainer, BurritosNotification, - DirectiveWithViewContainer]; + DirectiveWithViewContainer, + ComponentWithTemplateRef]; @NgModule({ imports: [CommonModule, MatSnackBarModule], exports: TEST_DIRECTIVES, diff --git a/src/lib/snack-bar/snack-bar.ts b/src/lib/snack-bar/snack-bar.ts index c5c2d38ce9a..5cba3f0f74b 100644 --- a/src/lib/snack-bar/snack-bar.ts +++ b/src/lib/snack-bar/snack-bar.ts @@ -9,15 +9,17 @@ import {LiveAnnouncer} from '@angular/cdk/a11y'; import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout'; import {Overlay, OverlayConfig, OverlayRef} from '@angular/cdk/overlay'; -import {ComponentPortal, ComponentType, PortalInjector} from '@angular/cdk/portal'; +import {ComponentPortal, TemplatePortal, ComponentType, PortalInjector} from '@angular/cdk/portal'; import { ComponentRef, + EmbeddedViewRef, Inject, Injectable, Injector, InjectionToken, Optional, SkipSelf, + TemplateRef, } from '@angular/core'; import {take} from 'rxjs/operators/take'; import {takeUntil} from 'rxjs/operators/takeUntil'; @@ -72,41 +74,21 @@ export class MatSnackBar { * @param component Component to be instantiated. * @param config Extra configuration for the snack bar. */ - openFromComponent(component: ComponentType, config?: MatSnackBarConfig): MatSnackBarRef { - const _config = {...this._defaultConfig, ...config}; - const snackBarRef = this._attach(component, _config); + openFromComponent(component: ComponentType, config?: MatSnackBarConfig): + MatSnackBarRef { + return this._attach(component, config) as MatSnackBarRef; + } - // When the snackbar is dismissed, clear the reference to it. - snackBarRef.afterDismissed().subscribe(() => { - // Clear the snackbar ref if it hasn't already been replaced by a newer snackbar. - if (this._openedSnackBarRef == snackBarRef) { - this._openedSnackBarRef = null; - } - }); - - if (this._openedSnackBarRef) { - // If a snack bar is already in view, dismiss it and enter the - // new snack bar after exit animation is complete. - this._openedSnackBarRef.afterDismissed().subscribe(() => { - snackBarRef.containerInstance.enter(); - }); - this._openedSnackBarRef.dismiss(); - } else { - // If no snack bar is in view, enter the new snack bar. - snackBarRef.containerInstance.enter(); - } - - // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. - if (_config.duration && _config.duration > 0) { - snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(_config!.duration!)); - } - - if (_config.announcementMessage) { - this._live.announce(_config.announcementMessage, _config.politeness); - } - - this._openedSnackBarRef = snackBarRef; - return this._openedSnackBarRef; + /** + * Creates and dispatches a snack bar with a custom template for the content, removing any + * currently opened snack bars. + * + * @param template Template to be instantiated. + * @param config Extra configuration for the snack bar. + */ + openFromTemplate(template: TemplateRef, config?: MatSnackBarConfig): + MatSnackBarRef> { + return this._attach(template, config); } /** @@ -148,18 +130,31 @@ export class MatSnackBar { } /** - * Places a new component as the content of the snack bar container. + * Places a new component or a template as the content of the snack bar container. */ - private _attach(component: ComponentType, config: MatSnackBarConfig): MatSnackBarRef { + private _attach(content: ComponentType | TemplateRef, userConfig?: MatSnackBarConfig): + MatSnackBarRef> { + + const config = {...this._defaultConfig, ...userConfig}; const overlayRef = this._createOverlay(config); const container = this._attachSnackBarContainer(overlayRef, config); - const snackBarRef = new MatSnackBarRef(container, overlayRef); - const injector = this._createInjector(config, snackBarRef); - const portal = new ComponentPortal(component, undefined, injector); - const contentRef = container.attachComponentPortal(portal); + const snackBarRef = new MatSnackBarRef>(container, overlayRef); - // We can't pass this via the injector, because the injector is created earlier. - snackBarRef.instance = contentRef.instance; + if (content instanceof TemplateRef) { + const portal = new TemplatePortal(content, null!, { + $implicit: config.data, + snackBarRef + } as any); + + snackBarRef.instance = container.attachTemplatePortal(portal); + } else { + const injector = this._createInjector(config, snackBarRef); + const portal = new ComponentPortal(content, undefined, injector); + const contentRef = container.attachComponentPortal(portal); + + // We can't pass this via the injector, because the injector is created earlier. + snackBarRef.instance = contentRef.instance; + } // Subscribe to the breakpoint observer and attach the mat-snack-bar-handset class as // appropriate. This class is applied to the overlay element because the overlay must expand to @@ -174,7 +169,41 @@ export class MatSnackBar { } }); - return snackBarRef; + this._animateSnackBar(snackBarRef, config); + this._openedSnackBarRef = snackBarRef; + return this._openedSnackBarRef; + } + + /** Animates the old snack bar out and the new one in. */ + private _animateSnackBar(snackBarRef: MatSnackBarRef, config: MatSnackBarConfig) { + // When the snackbar is dismissed, clear the reference to it. + snackBarRef.afterDismissed().subscribe(() => { + // Clear the snackbar ref if it hasn't already been replaced by a newer snackbar. + if (this._openedSnackBarRef == snackBarRef) { + this._openedSnackBarRef = null; + } + }); + + if (this._openedSnackBarRef) { + // If a snack bar is already in view, dismiss it and enter the + // new snack bar after exit animation is complete. + this._openedSnackBarRef.afterDismissed().subscribe(() => { + snackBarRef.containerInstance.enter(); + }); + this._openedSnackBarRef.dismiss(); + } else { + // If no snack bar is in view, enter the new snack bar. + snackBarRef.containerInstance.enter(); + } + + // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. + if (config.duration && config.duration > 0) { + snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); + } + + if (config.announcementMessage) { + this._live.announce(config.announcementMessage, config.politeness); + } } /**