feat(snack-bar): add the ability to open from a TemplateRef (#10268)

Allows consumers to open a snack bar through a custom `TemplateRef`, in addition to a component type.

Fixes #6136.
This commit is contained in:
Kristiyan Kostadinov
2018-03-06 10:39:40 -05:00
committed by Jeremy Elbourn
parent 749f2a0c82
commit c44e253261
5 changed files with 189 additions and 71 deletions
+9 -1
View File
@@ -50,4 +50,12 @@
</p>
</div>
<button mat-raised-button (click)="open()">OPEN</button>
<p>
<button mat-raised-button (click)="open()">OPEN</button>
</p>
<button mat-raised-button (click)="openTemplate()">OPEN TEMPLATE</button>
<ng-template #template>
Template snack bar: {{message}}
</ng-template>
+14 -3
View File
@@ -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<any>;
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;
}
}
+32 -21
View File
@@ -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<T>(portal: ComponentPortal<T>): ComponentRef<T> {
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<any> {
throw Error('Not yet implemented');
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
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');
}
}
}
+61 -2
View File
@@ -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<ComponentWithTemplateRef>;
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: `
<ng-template let-data>
Fries {{localValue}} {{data?.value}}
</ng-template>
`,
})
class ComponentWithTemplateRef {
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
localValue: string;
}
/** Simple component for testing ComponentPortal. */
@Component({template: '<p>Burritos are on the way.</p>'})
class BurritosNotification {
@@ -835,7 +893,8 @@ class ComponentThatProvidesMatSnackBar {
*/
const TEST_DIRECTIVES = [ComponentWithChildViewContainer,
BurritosNotification,
DirectiveWithViewContainer];
DirectiveWithViewContainer,
ComponentWithTemplateRef];
@NgModule({
imports: [CommonModule, MatSnackBarModule],
exports: TEST_DIRECTIVES,
+73 -44
View File
@@ -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<T>(component: ComponentType<T>, config?: MatSnackBarConfig): MatSnackBarRef<T> {
const _config = {...this._defaultConfig, ...config};
const snackBarRef = this._attach(component, _config);
openFromComponent<T>(component: ComponentType<T>, config?: MatSnackBarConfig):
MatSnackBarRef<T> {
return this._attach(component, config) as MatSnackBarRef<T>;
}
// 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<any>, config?: MatSnackBarConfig):
MatSnackBarRef<EmbeddedViewRef<any>> {
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<T>(component: ComponentType<T>, config: MatSnackBarConfig): MatSnackBarRef<T> {
private _attach<T>(content: ComponentType<T> | TemplateRef<T>, userConfig?: MatSnackBarConfig):
MatSnackBarRef<T | EmbeddedViewRef<any>> {
const config = {...this._defaultConfig, ...userConfig};
const overlayRef = this._createOverlay(config);
const container = this._attachSnackBarContainer(overlayRef, config);
const snackBarRef = new MatSnackBarRef<T>(container, overlayRef);
const injector = this._createInjector(config, snackBarRef);
const portal = new ComponentPortal(component, undefined, injector);
const contentRef = container.attachComponentPortal(portal);
const snackBarRef = new MatSnackBarRef<T | EmbeddedViewRef<any>>(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<T>(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<any>, 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);
}
}
/**