feat(md-snack-bar): Create initial MdSnackBar. (#1296)

This commit is contained in:
Joey Perrott
2016-09-23 15:42:24 -07:00
committed by Kara
parent 10eea20feb
commit a732e88939
21 changed files with 590 additions and 0 deletions
+2
View File
@@ -29,6 +29,7 @@ import {MdCheckboxDemoNestedChecklist, CheckboxDemo} from './checkbox/checkbox-d
import {SelectDemo} from './select/select-demo';
import {SliderDemo} from './slider/slider-demo';
import {SidenavDemo} from './sidenav/sidenav-demo';
import {SnackBarDemo} from './snack-bar/snack-bar-demo';
import {PortalDemo, ScienceJoke} from './portal/portal-demo';
import {MenuDemo} from './menu/menu-demo';
import {TabsDemo} from './tabs/tab-group-demo';
@@ -61,6 +62,7 @@ import {TabsDemo} from './tabs/tab-group-demo';
LiveAnnouncerDemo,
MdCheckboxDemoNestedChecklist,
MenuDemo,
SnackBarDemo,
OverlayDemo,
PortalDemo,
ProgressBarDemo,
+1
View File
@@ -23,6 +23,7 @@
<a md-list-item [routerLink]="['sidenav']">Sidenav</a>
<a md-list-item [routerLink]="['slider']">Slider</a>
<a md-list-item [routerLink]="['slide-toggle']">Slide Toggle</a>
<a md-list-item [routerLink]="['snack-bar']">Snack Bar</a>
<a md-list-item [routerLink]="['tabs']">Tabs</a>
<a md-list-item [routerLink]="['toolbar']">Toolbar</a>
<a md-list-item [routerLink]="['tooltip']">Tooltip</a>
+2
View File
@@ -26,6 +26,7 @@ import {MenuDemo} from '../menu/menu-demo';
import {RippleDemo} from '../ripple/ripple-demo';
import {DialogDemo} from '../dialog/dialog-demo';
import {TooltipDemo} from '../tooltip/tooltip-demo';
import {SnackBarDemo} from '../snack-bar/snack-bar-demo';
export const DEMO_APP_ROUTES: Routes = [
@@ -56,4 +57,5 @@ export const DEMO_APP_ROUTES: Routes = [
{path: 'ripple', component: RippleDemo},
{path: 'dialog', component: DialogDemo},
{path: 'tooltip', component: TooltipDemo},
{path: 'snack-bar', component: SnackBarDemo},
];
@@ -0,0 +1,15 @@
<h1>SnackBar demo</h1>
<div>
<div>Message: <md-input type="text" [(ngModel)]="message"></md-input></div>
<div>
<md-checkbox [(ngModel)]="action">
<p *ngIf="!action">Show button on snack bar</p>
<md-input type="text" class="demo-button-label-input"
*ngIf="action"
placeholder="Snack bar action label"
[(ngModel)]="actionButtonLabel"></md-input>
</md-checkbox>
</div>
</div>
<button md-raised-button (click)="open()">OPEN</button>
@@ -0,0 +1,3 @@
.demo-button-label-input {
display: inline-block;
}
+31
View File
@@ -0,0 +1,31 @@
import {Component, ViewContainerRef} from '@angular/core';
import {MdSnackBar, MdSnackBarConfig} from '@angular/material';
@Component({
moduleId: module.id,
selector: 'snack-bar-demo',
templateUrl: 'snack-bar-demo.html',
})
export class SnackBarDemo {
message: string = 'Snack Bar opened.';
actionButtonLabel: string = 'Retry';
action: boolean = false;
constructor(
public snackBar: MdSnackBar,
public viewContainerRef: ViewContainerRef) { }
open() {
let config = new MdSnackBarConfig(this.viewContainerRef);
this.snackBar.open(this.message, this.action && this.actionButtonLabel, config);
}
}
@Component({
moduleId: module.id,
selector: 'demo-snack',
templateUrl: 'snack-bar-demo.html',
styleUrls: ['./snack-bar-demo.css'],
})
export class DemoSnack {}
+1
View File
@@ -18,6 +18,7 @@ export * from './select/index';
export * from './sidenav/index';
export * from './slider/index';
export * from './slide-toggle/index';
export * from './snack-bar/index';
export * from './tabs/index';
export * from './toolbar/index';
export * from './tooltip/index';
+3
View File
@@ -23,6 +23,7 @@ import {MdIconModule} from './icon/index';
import {MdProgressCircleModule} from './progress-circle/index';
import {MdProgressBarModule} from './progress-bar/index';
import {MdInputModule} from './input/index';
import {MdSnackBarModule} from './snack-bar/snack-bar';
import {MdTabsModule} from './tabs/index';
import {MdToolbarModule} from './toolbar/index';
import {MdTooltipModule} from './tooltip/index';
@@ -49,6 +50,7 @@ const MATERIAL_MODULES = [
MdSidenavModule,
MdSliderModule,
MdSlideToggleModule,
MdSnackBarModule,
MdTabsModule,
MdToolbarModule,
MdTooltipModule,
@@ -83,6 +85,7 @@ const MATERIAL_MODULES = [
MdRadioModule.forRoot(),
MdSliderModule.forRoot(),
MdSlideToggleModule.forRoot(),
MdSnackBarModule.forRoot(),
MdTooltipModule.forRoot(),
OverlayModule.forRoot(),
],
+38
View File
@@ -0,0 +1,38 @@
# MdSnackBar
`MdSnackBar` is a service, which opens snack bar notifications in the view.
### Methods
| Name | Description |
| --- | --- |
| `open(message: string,<br> actionLabel: string, config: MdSnackBarConfig): MdSnackBarRef<SimpleSnackBar>` | Creates and opens a simple snack bar noticiation matching material spec. |
| `openFromComponent(component: ComponentType<T>, config: MdSnackBarConfig): MdSnackBarRef<T>` | Creates and opens a snack bar noticiation with a custom component as content. |
### Config
| Key | Description |
| --- | --- |
| `viewContainerRef: ViewContainerRef` | The view container ref to attach the snack bar to. |
| `role: AriaLivePoliteness = 'assertive'` | The politeness level to announce the snack bar with. |
| `announcementMessage: string` | The message to announce with the snack bar. |
### Example
The service can be injected in a component.
```ts
@Component({
selector: 'my-component'
providers: [MdSnackBar]
})
export class MyComponent {
constructor(snackBar: MdSnackBar
viewContainerRef: ViewContainerRef) {}
failedAttempt() {
config = new MdSnackBarConfig(this.viewContainerRef);
this.snackBar.open('It didn\'t quite work!', 'Try Again', config);
}
}
```
+1
View File
@@ -0,0 +1 @@
export * from './snack-bar';
+3
View File
@@ -0,0 +1,3 @@
<span class="md-simple-snackbar-message">{{message}}</span>
<button md-button class="md-simple-snackbar-action"
*ngIf="hasAction" (click)="dismiss()">{{action}}</button>
+28
View File
@@ -0,0 +1,28 @@
md-simple-snackbar {
display: flex;
justify-content: space-between;
}
.md-simple-snackbar-message {
box-sizing: border-box;
border: none;
color: white;
font-family: Roboto, 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 20px;
outline: none;
text-decoration: none;
word-break: break-all;
}
.md-simple-snackbar-action {
box-sizing: border-box;
color: white;
float: right;
font-weight: 600;
line-height: 20px;
margin: -5px 0 0 48px;
min-width: initial;
padding: 5px;
text-transform: uppercase;
}
+32
View File
@@ -0,0 +1,32 @@
import {Component} from '@angular/core';
import {MdSnackBarRef} from './snack-bar-ref';
/**
* A component used to open as the default snack bar, matching material spec.
* This should only be used internally by the snack bar service.
*/
@Component({
moduleId: module.id,
selector: 'simple-snack-bar',
templateUrl: 'simple-snack-bar.html',
styleUrls: ['simple-snack-bar.css'],
})
export class SimpleSnackBar {
/** The message to be shown in the snack bar. */
message: string;
/** The label for the button in the snack bar. */
action: string;
/** The instance of the component making up the content of the snack bar. */
snackBarRef: MdSnackBarRef<SimpleSnackBar>;
/** Dismisses the snack bar. */
dismiss(): void {
this.snackBarRef.dismiss();
}
/** If the action button should be shown. */
get hasAction(): boolean { return !!this.action; }
}
+18
View File
@@ -0,0 +1,18 @@
import {ViewContainerRef} from '@angular/core';
import {AriaLivePoliteness} from '../core';
export class MdSnackBarConfig {
/** The politeness level for the MdAriaLiveAnnouncer announcement. */
politeness: AriaLivePoliteness = 'assertive';
/** Message to be announced by the MdAriaLiveAnnouncer */
announcementMessage: string;
/** The view container to place the overlay for the snack bar into. */
viewContainerRef: ViewContainerRef;
constructor(viewContainerRef: ViewContainerRef) {
this.viewContainerRef = viewContainerRef;
}
}
@@ -0,0 +1 @@
<template portalHost></template>
@@ -0,0 +1,19 @@
@import '../core/style/elevation';
$md-snack-bar-padding: 14px 24px !default;
$md-snack-bar-height: 20px !default;
$md-snack-bar-min-width: 288px !default;
$md-snack-bar-max-width: 568px !default;
:host {
@include md-elevation(24);
background: #323232;
border-radius: 2px;
display: block;
height: $md-snack-bar-height;
max-width: $md-snack-bar-max-width;
min-width: $md-snack-bar-min-width;
overflow: hidden;
padding: $md-snack-bar-padding;
}
+47
View File
@@ -0,0 +1,47 @@
import {
Component,
ComponentRef,
ViewChild
} from '@angular/core';
import {
BasePortalHost,
ComponentPortal,
TemplatePortal,
PortalHostDirective
} from '../core';
import {MdSnackBarConfig} from './snack-bar-config';
import {MdSnackBarContentAlreadyAttached} from './snack-bar-errors';
/**
* Internal component that wraps user-provided snack bar content.
*/
@Component({
moduleId: module.id,
selector: 'snack-bar-container',
templateUrl: 'snack-bar-container.html',
styleUrls: ['snack-bar-container.css'],
host: {
'role': 'alert'
}
})
export class MdSnackBarContainer extends BasePortalHost {
/** The portal host inside of this container into which the snack bar content will be loaded. */
@ViewChild(PortalHostDirective) _portalHost: PortalHostDirective;
/** The snack bar configuration. */
snackBarConfig: MdSnackBarConfig;
/** Attach a portal as content to this snack bar container. */
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T> {
if (this._portalHost.hasAttached()) {
throw new MdSnackBarContentAlreadyAttached();
}
return this._portalHost.attachComponentPortal(portal);
}
attachTemplatePortal(portal: TemplatePortal): Map<string, any> {
throw Error('Not yet implemented');
}
}
+8
View File
@@ -0,0 +1,8 @@
import {MdError} from '../core';
export class MdSnackBarContentAlreadyAttached extends MdError {
constructor() {
super('Attempting to attach snack bar content after content is already attached');
}
}
+35
View File
@@ -0,0 +1,35 @@
import {OverlayRef} from '../core';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
// TODO(josephperrott): Implement onAction observable.
/**
* Reference to a snack bar dispatched from the snack bar service.
*/
export class MdSnackBarRef<T> {
/** The instance of the component making up the content of the snack bar. */
readonly instance: T;
/** Subject for notifying the user that the snack bar has closed. */
private _afterClosed: Subject<any> = new Subject();
constructor(instance: T, private _overlayRef: OverlayRef) {
// Sets the readonly instance of the snack bar content component.
this.instance = instance;
}
/** Dismisses the snack bar. */
dismiss(): void {
if (!this._afterClosed.closed) {
this._overlayRef.dispose();
this._afterClosed.complete();
}
}
/** Gets an observable that is notified when the snack bar is finished closing. */
afterDismissed(): Observable<void> {
return this._afterClosed.asObservable();
}
}
+178
View File
@@ -0,0 +1,178 @@
import {
inject,
async,
ComponentFixture,
TestBed
} from '@angular/core/testing';
import {
NgModule,
Component,
Directive,
ViewChild,
ViewContainerRef
} from '@angular/core';
import {MdSnackBar, MdSnackBarModule} from './snack-bar';
import {OverlayContainer} from '../core';
import {MdSnackBarConfig} from './snack-bar-config';
import {SimpleSnackBar} from './simple-snack-bar';
describe('MdSnackBar', () => {
let snackBar: MdSnackBar;
let overlayContainerElement: HTMLElement;
let testViewContainerRef: ViewContainerRef;
let viewContainerFixture: ComponentFixture<ComponentWithChildViewContainer>;
let simpleMessage = 'Burritos are here!';
let simpleActionLabel = 'pickup';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MdSnackBarModule.forRoot(), SnackBarTestModule],
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
return {getContainerElement: () => overlayContainerElement};
}}
],
});
TestBed.compileComponents();
}));
beforeEach(inject([MdSnackBar], (sb: MdSnackBar) => {
snackBar = sb;
}));
beforeEach(() => {
viewContainerFixture = TestBed.createComponent(ComponentWithChildViewContainer);
viewContainerFixture.detectChanges();
testViewContainerRef = viewContainerFixture.componentInstance.childViewContainer;
});
it('should have the role of alert', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
snackBar.open(simpleMessage, simpleActionLabel, config);
let containerElement = overlayContainerElement.querySelector('snack-bar-container');
expect(containerElement.getAttribute('role'))
.toBe('alert', 'Expected snack bar container to have role="alert"');
});
it('should open a simple message with a button', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, simpleActionLabel, config);
viewContainerFixture.detectChanges();
expect(snackBarRef.instance)
.toEqual(jasmine.any(SimpleSnackBar),
'Expected the snack bar content component to be SimpleSnackBar');
expect(snackBarRef.instance.snackBarRef)
.toBe(snackBarRef, 'Expected the snack bar reference to be placed in the component instance');
let messageElement = overlayContainerElement.querySelector('span.md-simple-snackbar-message');
expect(messageElement.tagName).toBe('SPAN', 'Expected snack bar message element to be <span>');
expect(messageElement.textContent)
.toBe(simpleMessage, `Expected the snack bar message to be '${simpleMessage}''`);
let buttonElement = overlayContainerElement.querySelector('button.md-simple-snackbar-action');
expect(buttonElement.tagName)
.toBe('BUTTON', 'Expected snack bar action label to be a <button>');
expect(buttonElement.textContent)
.toBe(simpleActionLabel,
`Expected the snack bar action labe; to be '${simpleActionLabel}'`);
});
it('should open a simple message with no button', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, null, config);
viewContainerFixture.detectChanges();
expect(snackBarRef.instance)
.toEqual(jasmine.any(SimpleSnackBar),
'Expected the snack bar content component to be SimpleSnackBar');
expect(snackBarRef.instance.snackBarRef)
.toBe(snackBarRef, 'Expected the snack bar reference to be placed in the component instance');
let messageElement = overlayContainerElement.querySelector('span.md-simple-snackbar-message');
expect(messageElement.tagName).toBe('SPAN', 'Expected snack bar message element to be <span>');
expect(messageElement.textContent)
.toBe(simpleMessage, `Expected the snack bar message to be '${simpleMessage}''`);
expect(overlayContainerElement.querySelector('button.md-simple-snackbar-action'))
.toBeNull('Expected the query selection for action label to be null');
});
it('should dismiss the snack bar and remove itself from the view', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.open(simpleMessage, null, config);
let dismissed = true;
snackBarRef.afterDismissed().subscribe(result => {
dismissed = true;
});
viewContainerFixture.detectChanges();
expect(overlayContainerElement.childElementCount)
.toBeGreaterThan(0, 'Expected overlay container element to have at least one child');
snackBarRef.dismiss();
expect(dismissed).toBeTruthy('Expected the snack bar to be dismissed');
expect(overlayContainerElement.childElementCount)
.toBe(0, 'Expected the overlay container element to have no child elements');
});
it('should open a custom component', () => {
let config = new MdSnackBarConfig(testViewContainerRef);
let snackBarRef = snackBar.openFromComponent(BurritosNotification, config);
expect(snackBarRef.instance)
.toEqual(jasmine.any(BurritosNotification),
'Expected the snack bar content component to be BurritosNotification');
expect(overlayContainerElement.textContent)
.toBe('Burritos are on the way.',
`Expected the overlay text content to be 'Burritos are on the way'`);
});
});
@Directive({selector: 'dir-with-view-container'})
class DirectiveWithViewContainer {
constructor(public viewContainerRef: ViewContainerRef) { }
}
@Component({
selector: 'arbitrary-component',
template: `<dir-with-view-container></dir-with-view-container>`,
})
class ComponentWithChildViewContainer {
@ViewChild(DirectiveWithViewContainer) childWithViewContainer: DirectiveWithViewContainer;
get childViewContainer() {
return this.childWithViewContainer.viewContainerRef;
}
}
/** Simple component for testing ComponentPortal. */
@Component({template: '<p>Burritos are on the way.</p>'})
class BurritosNotification {}
/** Simple component to open snack bars from.
* Create a real (non-test) NgModule as a workaround forRoot
* https://github.com/angular/angular/issues/10760
*/
const TEST_DIRECTIVES = [ComponentWithChildViewContainer,
BurritosNotification,
DirectiveWithViewContainer];
@NgModule({
imports: [MdSnackBarModule],
exports: TEST_DIRECTIVES,
declarations: TEST_DIRECTIVES,
entryComponents: [ComponentWithChildViewContainer, BurritosNotification],
})
class SnackBarTestModule { }
+124
View File
@@ -0,0 +1,124 @@
import {
NgModule,
ModuleWithProviders,
Injectable,
ComponentRef,
} from '@angular/core';
import {
ComponentType,
ComponentPortal,
Overlay,
OverlayModule,
OverlayRef,
OverlayState,
PortalModule,
OVERLAY_PROVIDERS,
MdLiveAnnouncer,
} from '../core';
import {CommonModule} from '@angular/common';
import {MdSnackBarConfig} from './snack-bar-config';
import {MdSnackBarRef} from './snack-bar-ref';
import {MdSnackBarContainer} from './snack-bar-container';
import {SimpleSnackBar} from './simple-snack-bar';
export {MdSnackBarRef} from './snack-bar-ref';
export {MdSnackBarConfig} from './snack-bar-config';
// TODO(josephperrott): Animate entrance and exit of snack bars.
// TODO(josephperrott): Automate dismiss after timeout.
/**
* Service to dispatch Material Design snack bar messages.
*/
@Injectable()
export class MdSnackBar {
/** A reference to the current snack bar in the view. */
private _snackBarRef: MdSnackBarRef<any>;
constructor(private _overlay: Overlay,
private _live: MdLiveAnnouncer) {}
/**
* Creates and dispatches a snack bar with a custom component for the content, removing any
* currently opened snack bars.
*/
openFromComponent<T>(component: ComponentType<T>,
config: MdSnackBarConfig): MdSnackBarRef<T> {
if (this._snackBarRef) {
this._snackBarRef.dismiss();
}
let overlayRef = this._createOverlay();
let snackBarContainer = this._attachSnackBarContainer(overlayRef, config);
let mdSnackBarRef = this._attachSnackbarContent(component, snackBarContainer, overlayRef);
this._live.announce(config.announcementMessage, config.politeness);
return mdSnackBarRef;
}
/**
* Creates and dispatches a snack bar.
*/
open(message: string, actionLabel: string,
config: MdSnackBarConfig): MdSnackBarRef<SimpleSnackBar> {
config.announcementMessage = message;
let simpleSnackBarRef = this.openFromComponent(SimpleSnackBar, config);
simpleSnackBarRef.instance.snackBarRef = simpleSnackBarRef;
simpleSnackBarRef.instance.message = message;
simpleSnackBarRef.instance.action = actionLabel;
return simpleSnackBarRef;
}
/**
* Attaches the snack bar container component to the overlay.
*/
private _attachSnackBarContainer(overlayRef: OverlayRef,
config: MdSnackBarConfig): MdSnackBarContainer {
let containerPortal = new ComponentPortal(MdSnackBarContainer, config.viewContainerRef);
let containerRef: ComponentRef<MdSnackBarContainer> = overlayRef.attach(containerPortal);
containerRef.instance.snackBarConfig = config;
return containerRef.instance;
}
/**
* Places a new component as the content of the snack bar container.
*/
private _attachSnackbarContent<T>(component: ComponentType<T>,
container: MdSnackBarContainer,
overlayRef: OverlayRef): MdSnackBarRef<T> {
let portal = new ComponentPortal(component);
let contentRef = container.attachComponentPortal(portal);
let snackBarRef = <MdSnackBarRef<T>> new MdSnackBarRef(contentRef.instance, overlayRef);
this._snackBarRef = snackBarRef;
return snackBarRef;
}
/**
* Creates a new overlay and places it in the correct location.
*/
private _createOverlay(): OverlayRef {
let state = new OverlayState();
state.positionStrategy = this._overlay.position().global()
.fixed()
.centerHorizontally()
.bottom('0');
return this._overlay.create(state);
}
}
@NgModule({
imports: [OverlayModule, PortalModule, CommonModule],
exports: [MdSnackBarContainer],
declarations: [MdSnackBarContainer, SimpleSnackBar],
entryComponents: [MdSnackBarContainer, SimpleSnackBar],
})
export class MdSnackBarModule {
static forRoot(): ModuleWithProviders {
return {
ngModule: MdSnackBarModule,
providers: [MdSnackBar, OVERLAY_PROVIDERS, MdLiveAnnouncer]
};
}
}