Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/demo-app/snack-bar/snack-bar-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,12 @@ <h1>SnackBar demo</h1>
</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>
17 changes: 14 additions & 3 deletions src/demo-app/snack-bar/snack-bar-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;
}
}
53 changes: 32 additions & 21 deletions src/lib/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
BasePortalOutlet,
ComponentPortal,
CdkPortalOutlet,
TemplatePortal,
} from '@angular/cdk/portal';
import {take} from 'rxjs/operators/take';
import {Observable} from 'rxjs/Observable';
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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');
}
}
}
63 changes: 61 additions & 2 deletions src/lib/snack-bar/snack-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 {
Expand All @@ -835,7 +893,8 @@ class ComponentThatProvidesMatSnackBar {
*/
const TEST_DIRECTIVES = [ComponentWithChildViewContainer,
BurritosNotification,
DirectiveWithViewContainer];
DirectiveWithViewContainer,
ComponentWithTemplateRef];
@NgModule({
imports: [CommonModule, MatSnackBarModule],
exports: TEST_DIRECTIVES,
Expand Down
117 changes: 73 additions & 44 deletions src/lib/snack-bar/snack-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

// 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);
}
openFromComponent<T>(component: ComponentType<T>, config?: MatSnackBarConfig):
MatSnackBarRef<T> {
return this._attach(component, config) as MatSnackBarRef<T>;
}

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):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Point for discussion: currently the font size and color styling are attached to the SimpleSnackBar, which means that consumers that open through openFromComponent and openFromTemplate won't get the styling. Should we switch to attaching them on the MatSnackBarContainer instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do it for templates since its new, but changing it now for components could be seen as a breaking change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should not do it at all, especially if we plan on having it be different between templates and components.

It was done this way as the SimpleSnackBar is the snack bar that matches the spec and we give users full control with opening a custom snackbar.

MatSnackBarRef<EmbeddedViewRef<any>> {
return this._attach(template, config);
}

/**
Expand Down Expand Up @@ -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);

if (content instanceof TemplateRef) {
const portal = new TemplatePortal(content, null!, {
$implicit: config.data,
snackBarRef
} as any);

// We can't pass this via the injector, because the injector is created earlier.
snackBarRef.instance = contentRef.instance;
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
Expand All @@ -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);
}
}

/**
Expand Down