Skip to content

Commit c44e253

Browse files
crisbetojelbourn
authored andcommitted
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.
1 parent 749f2a0 commit c44e253

File tree

5 files changed

+189
-71
lines changed

5 files changed

+189
-71
lines changed

src/demo-app/snack-bar/snack-bar-demo.html

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,12 @@ <h1>SnackBar demo</h1>
5050
</p>
5151
</div>
5252

53-
<button mat-raised-button (click)="open()">OPEN</button>
53+
<p>
54+
<button mat-raised-button (click)="open()">OPEN</button>
55+
</p>
56+
57+
<button mat-raised-button (click)="openTemplate()">OPEN TEMPLATE</button>
58+
59+
<ng-template #template>
60+
Template snack bar: {{message}}
61+
</ng-template>

src/demo-app/snack-bar/snack-bar-demo.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {Dir} from '@angular/cdk/bidi';
10-
import {Component, ViewEncapsulation} from '@angular/core';
10+
import {Component, ViewEncapsulation, ViewChild, TemplateRef} from '@angular/core';
1111
import {
1212
MatSnackBar,
1313
MatSnackBarConfig,
@@ -25,6 +25,7 @@ import {
2525
preserveWhitespaces: false,
2626
})
2727
export class SnackBarDemo {
28+
@ViewChild('template') template: TemplateRef<any>;
2829
message: string = 'Snack Bar opened.';
2930
actionButtonLabel: string = 'Retry';
3031
action: boolean = false;
@@ -38,12 +39,22 @@ export class SnackBarDemo {
3839
}
3940

4041
open() {
41-
let config = new MatSnackBarConfig();
42+
const config = this._createConfig();
43+
this.snackBar.open(this.message, this.action ? this.actionButtonLabel : undefined, config);
44+
}
45+
46+
openTemplate() {
47+
const config = this._createConfig();
48+
this.snackBar.openFromTemplate(this.template, config);
49+
}
50+
51+
private _createConfig() {
52+
const config = new MatSnackBarConfig();
4253
config.verticalPosition = this.verticalPosition;
4354
config.horizontalPosition = this.horizontalPosition;
4455
config.duration = this.setAutoHide ? this.autoHide : 0;
4556
config.panelClass = this.addExtraClass ? ['party'] : undefined;
4657
config.direction = this.dir.value;
47-
this.snackBar.open(this.message, this.action ? this.actionButtonLabel : undefined, config);
58+
return config;
4859
}
4960
}

src/lib/snack-bar/snack-bar-container.ts

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
BasePortalOutlet,
2424
ComponentPortal,
2525
CdkPortalOutlet,
26+
TemplatePortal,
2627
} from '@angular/cdk/portal';
2728
import {take} from 'rxjs/operators/take';
2829
import {Observable} from 'rxjs/Observable';
@@ -78,31 +79,16 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
7879

7980
/** Attach a component portal as content to this snack bar container. */
8081
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
81-
if (this._portalOutlet.hasAttached()) {
82-
throw Error('Attempting to attach snack bar content after content is already attached');
83-
}
84-
85-
const element: HTMLElement = this._elementRef.nativeElement;
86-
87-
if (this.snackBarConfig.panelClass || this.snackBarConfig.extraClasses) {
88-
this._setCssClasses(this.snackBarConfig.panelClass);
89-
this._setCssClasses(this.snackBarConfig.extraClasses);
90-
}
91-
92-
if (this.snackBarConfig.horizontalPosition === 'center') {
93-
element.classList.add('mat-snack-bar-center');
94-
}
95-
96-
if (this.snackBarConfig.verticalPosition === 'top') {
97-
element.classList.add('mat-snack-bar-top');
98-
}
99-
82+
this._assertNotAttached();
83+
this._applySnackBarClasses();
10084
return this._portalOutlet.attachComponentPortal(portal);
10185
}
10286

10387
/** Attach a template portal as content to this snack bar container. */
104-
attachTemplatePortal(): EmbeddedViewRef<any> {
105-
throw Error('Not yet implemented');
88+
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
89+
this._assertNotAttached();
90+
this._applySnackBarClasses();
91+
return this._portalOutlet.attachTemplatePortal(portal);
10692
}
10793

10894
/** Handle end of animations, updating the state of the snackbar. */
@@ -171,4 +157,29 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
171157
element.classList.add(classList);
172158
}
173159
}
160+
161+
/** Applies the various positioning and user-configured CSS classes to the snack bar. */
162+
private _applySnackBarClasses() {
163+
const element: HTMLElement = this._elementRef.nativeElement;
164+
165+
if (this.snackBarConfig.panelClass || this.snackBarConfig.extraClasses) {
166+
this._setCssClasses(this.snackBarConfig.panelClass);
167+
this._setCssClasses(this.snackBarConfig.extraClasses);
168+
}
169+
170+
if (this.snackBarConfig.horizontalPosition === 'center') {
171+
element.classList.add('mat-snack-bar-center');
172+
}
173+
174+
if (this.snackBarConfig.verticalPosition === 'top') {
175+
element.classList.add('mat-snack-bar-top');
176+
}
177+
}
178+
179+
/** Asserts that no content is already attached to the container. */
180+
private _assertNotAttached() {
181+
if (this._portalOutlet.hasAttached()) {
182+
throw Error('Attempting to attach snack bar content after content is already attached');
183+
}
184+
}
174185
}

src/lib/snack-bar/snack-bar.spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ import {
66
tick,
77
flush,
88
} from '@angular/core/testing';
9-
import {NgModule, Component, Directive, ViewChild, ViewContainerRef, Inject} from '@angular/core';
9+
import {
10+
NgModule,
11+
Component,
12+
Directive,
13+
ViewChild,
14+
ViewContainerRef,
15+
Inject,
16+
TemplateRef,
17+
} from '@angular/core';
1018
import {CommonModule} from '@angular/common';
1119
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1220
import {OverlayContainer} from '@angular/cdk/overlay';
@@ -464,6 +472,42 @@ describe('MatSnackBar', () => {
464472

465473
});
466474

475+
describe('with TemplateRef', () => {
476+
let templateFixture: ComponentFixture<ComponentWithTemplateRef>;
477+
478+
beforeEach(() => {
479+
templateFixture = TestBed.createComponent(ComponentWithTemplateRef);
480+
templateFixture.detectChanges();
481+
});
482+
483+
it('should be able to open a snack bar using a TemplateRef', () => {
484+
templateFixture.componentInstance.localValue = 'Pizza';
485+
snackBar.openFromTemplate(templateFixture.componentInstance.templateRef);
486+
templateFixture.detectChanges();
487+
488+
const containerElement = overlayContainerElement.querySelector('snack-bar-container')!;
489+
490+
expect(containerElement.textContent).toContain('Fries');
491+
expect(containerElement.textContent).toContain('Pizza');
492+
493+
templateFixture.componentInstance.localValue = 'Pasta';
494+
templateFixture.detectChanges();
495+
496+
expect(containerElement.textContent).toContain('Pasta');
497+
});
498+
499+
it('should be able to pass in contextual data when opening with a TemplateRef', () => {
500+
snackBar.openFromTemplate(templateFixture.componentInstance.templateRef, {
501+
data: {value: 'Oranges'}
502+
});
503+
504+
const containerElement = overlayContainerElement.querySelector('snack-bar-container')!;
505+
506+
expect(containerElement.textContent).toContain('Oranges');
507+
});
508+
509+
});
510+
467511
});
468512

469513
describe('MatSnackBar with parent MatSnackBar', () => {
@@ -810,6 +854,20 @@ class ComponentWithChildViewContainer {
810854
}
811855
}
812856

857+
@Component({
858+
selector: 'arbitrary-component-with-template-ref',
859+
template: `
860+
<ng-template let-data>
861+
Fries {{localValue}} {{data?.value}}
862+
</ng-template>
863+
`,
864+
})
865+
class ComponentWithTemplateRef {
866+
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
867+
localValue: string;
868+
}
869+
870+
813871
/** Simple component for testing ComponentPortal. */
814872
@Component({template: '<p>Burritos are on the way.</p>'})
815873
class BurritosNotification {
@@ -835,7 +893,8 @@ class ComponentThatProvidesMatSnackBar {
835893
*/
836894
const TEST_DIRECTIVES = [ComponentWithChildViewContainer,
837895
BurritosNotification,
838-
DirectiveWithViewContainer];
896+
DirectiveWithViewContainer,
897+
ComponentWithTemplateRef];
839898
@NgModule({
840899
imports: [CommonModule, MatSnackBarModule],
841900
exports: TEST_DIRECTIVES,

src/lib/snack-bar/snack-bar.ts

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,17 @@
99
import {LiveAnnouncer} from '@angular/cdk/a11y';
1010
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
1111
import {Overlay, OverlayConfig, OverlayRef} from '@angular/cdk/overlay';
12-
import {ComponentPortal, ComponentType, PortalInjector} from '@angular/cdk/portal';
12+
import {ComponentPortal, TemplatePortal, ComponentType, PortalInjector} from '@angular/cdk/portal';
1313
import {
1414
ComponentRef,
15+
EmbeddedViewRef,
1516
Inject,
1617
Injectable,
1718
Injector,
1819
InjectionToken,
1920
Optional,
2021
SkipSelf,
22+
TemplateRef,
2123
} from '@angular/core';
2224
import {take} from 'rxjs/operators/take';
2325
import {takeUntil} from 'rxjs/operators/takeUntil';
@@ -72,41 +74,21 @@ export class MatSnackBar {
7274
* @param component Component to be instantiated.
7375
* @param config Extra configuration for the snack bar.
7476
*/
75-
openFromComponent<T>(component: ComponentType<T>, config?: MatSnackBarConfig): MatSnackBarRef<T> {
76-
const _config = {...this._defaultConfig, ...config};
77-
const snackBarRef = this._attach(component, _config);
78-
79-
// When the snackbar is dismissed, clear the reference to it.
80-
snackBarRef.afterDismissed().subscribe(() => {
81-
// Clear the snackbar ref if it hasn't already been replaced by a newer snackbar.
82-
if (this._openedSnackBarRef == snackBarRef) {
83-
this._openedSnackBarRef = null;
84-
}
85-
});
86-
87-
if (this._openedSnackBarRef) {
88-
// If a snack bar is already in view, dismiss it and enter the
89-
// new snack bar after exit animation is complete.
90-
this._openedSnackBarRef.afterDismissed().subscribe(() => {
91-
snackBarRef.containerInstance.enter();
92-
});
93-
this._openedSnackBarRef.dismiss();
94-
} else {
95-
// If no snack bar is in view, enter the new snack bar.
96-
snackBarRef.containerInstance.enter();
97-
}
98-
99-
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
100-
if (_config.duration && _config.duration > 0) {
101-
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(_config!.duration!));
102-
}
103-
104-
if (_config.announcementMessage) {
105-
this._live.announce(_config.announcementMessage, _config.politeness);
106-
}
77+
openFromComponent<T>(component: ComponentType<T>, config?: MatSnackBarConfig):
78+
MatSnackBarRef<T> {
79+
return this._attach(component, config) as MatSnackBarRef<T>;
80+
}
10781

108-
this._openedSnackBarRef = snackBarRef;
109-
return this._openedSnackBarRef;
82+
/**
83+
* Creates and dispatches a snack bar with a custom template for the content, removing any
84+
* currently opened snack bars.
85+
*
86+
* @param template Template to be instantiated.
87+
* @param config Extra configuration for the snack bar.
88+
*/
89+
openFromTemplate(template: TemplateRef<any>, config?: MatSnackBarConfig):
90+
MatSnackBarRef<EmbeddedViewRef<any>> {
91+
return this._attach(template, config);
11092
}
11193

11294
/**
@@ -148,18 +130,31 @@ export class MatSnackBar {
148130
}
149131

150132
/**
151-
* Places a new component as the content of the snack bar container.
133+
* Places a new component or a template as the content of the snack bar container.
152134
*/
153-
private _attach<T>(component: ComponentType<T>, config: MatSnackBarConfig): MatSnackBarRef<T> {
135+
private _attach<T>(content: ComponentType<T> | TemplateRef<T>, userConfig?: MatSnackBarConfig):
136+
MatSnackBarRef<T | EmbeddedViewRef<any>> {
137+
138+
const config = {...this._defaultConfig, ...userConfig};
154139
const overlayRef = this._createOverlay(config);
155140
const container = this._attachSnackBarContainer(overlayRef, config);
156-
const snackBarRef = new MatSnackBarRef<T>(container, overlayRef);
157-
const injector = this._createInjector(config, snackBarRef);
158-
const portal = new ComponentPortal(component, undefined, injector);
159-
const contentRef = container.attachComponentPortal(portal);
141+
const snackBarRef = new MatSnackBarRef<T | EmbeddedViewRef<any>>(container, overlayRef);
142+
143+
if (content instanceof TemplateRef) {
144+
const portal = new TemplatePortal(content, null!, {
145+
$implicit: config.data,
146+
snackBarRef
147+
} as any);
160148

161-
// We can't pass this via the injector, because the injector is created earlier.
162-
snackBarRef.instance = contentRef.instance;
149+
snackBarRef.instance = container.attachTemplatePortal(portal);
150+
} else {
151+
const injector = this._createInjector(config, snackBarRef);
152+
const portal = new ComponentPortal(content, undefined, injector);
153+
const contentRef = container.attachComponentPortal<T>(portal);
154+
155+
// We can't pass this via the injector, because the injector is created earlier.
156+
snackBarRef.instance = contentRef.instance;
157+
}
163158

164159
// Subscribe to the breakpoint observer and attach the mat-snack-bar-handset class as
165160
// appropriate. This class is applied to the overlay element because the overlay must expand to
@@ -174,7 +169,41 @@ export class MatSnackBar {
174169
}
175170
});
176171

177-
return snackBarRef;
172+
this._animateSnackBar(snackBarRef, config);
173+
this._openedSnackBarRef = snackBarRef;
174+
return this._openedSnackBarRef;
175+
}
176+
177+
/** Animates the old snack bar out and the new one in. */
178+
private _animateSnackBar(snackBarRef: MatSnackBarRef<any>, config: MatSnackBarConfig) {
179+
// When the snackbar is dismissed, clear the reference to it.
180+
snackBarRef.afterDismissed().subscribe(() => {
181+
// Clear the snackbar ref if it hasn't already been replaced by a newer snackbar.
182+
if (this._openedSnackBarRef == snackBarRef) {
183+
this._openedSnackBarRef = null;
184+
}
185+
});
186+
187+
if (this._openedSnackBarRef) {
188+
// If a snack bar is already in view, dismiss it and enter the
189+
// new snack bar after exit animation is complete.
190+
this._openedSnackBarRef.afterDismissed().subscribe(() => {
191+
snackBarRef.containerInstance.enter();
192+
});
193+
this._openedSnackBarRef.dismiss();
194+
} else {
195+
// If no snack bar is in view, enter the new snack bar.
196+
snackBarRef.containerInstance.enter();
197+
}
198+
199+
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
200+
if (config.duration && config.duration > 0) {
201+
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
202+
}
203+
204+
if (config.announcementMessage) {
205+
this._live.announce(config.announcementMessage, config.politeness);
206+
}
178207
}
179208

180209
/**

0 commit comments

Comments
 (0)