diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 4d20d1ee3047..9c7b7ac2daba 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -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, diff --git a/src/demo-app/demo-app/demo-app.html b/src/demo-app/demo-app/demo-app.html index ff618689e032..15084fbcdc9f 100644 --- a/src/demo-app/demo-app/demo-app.html +++ b/src/demo-app/demo-app/demo-app.html @@ -23,6 +23,7 @@ Sidenav Slider Slide Toggle + Snack Bar Tabs Toolbar Tooltip diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index 03dbfd0ec3ac..b7c3fa47f3a7 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -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}, ]; diff --git a/src/demo-app/snack-bar/snack-bar-demo.html b/src/demo-app/snack-bar/snack-bar-demo.html new file mode 100644 index 000000000000..232a83f6a643 --- /dev/null +++ b/src/demo-app/snack-bar/snack-bar-demo.html @@ -0,0 +1,15 @@ +

SnackBar demo

+
+
Message:
+
+ +

Show button on snack bar

+ +
+
+
+ + \ No newline at end of file diff --git a/src/demo-app/snack-bar/snack-bar-demo.scss b/src/demo-app/snack-bar/snack-bar-demo.scss new file mode 100644 index 000000000000..8d737ab226f6 --- /dev/null +++ b/src/demo-app/snack-bar/snack-bar-demo.scss @@ -0,0 +1,3 @@ +.demo-button-label-input { + display: inline-block; +} \ No newline at end of file diff --git a/src/demo-app/snack-bar/snack-bar-demo.ts b/src/demo-app/snack-bar/snack-bar-demo.ts new file mode 100644 index 000000000000..a684a2e39bd4 --- /dev/null +++ b/src/demo-app/snack-bar/snack-bar-demo.ts @@ -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 {} diff --git a/src/lib/index.ts b/src/lib/index.ts index ead3f4846688..027498aea622 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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'; diff --git a/src/lib/module.ts b/src/lib/module.ts index 4a13daae74a9..ade62b770cec 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -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(), ], diff --git a/src/lib/snack-bar/README.md b/src/lib/snack-bar/README.md new file mode 100644 index 000000000000..33b2a4b0bee3 --- /dev/null +++ b/src/lib/snack-bar/README.md @@ -0,0 +1,38 @@ +# MdSnackBar +`MdSnackBar` is a service, which opens snack bar notifications in the view. + +### Methods + +| Name | Description | +| --- | --- | +| `open(message: string,
actionLabel: string, config: MdSnackBarConfig): MdSnackBarRef` | Creates and opens a simple snack bar noticiation matching material spec. | +| `openFromComponent(component: ComponentType, config: MdSnackBarConfig): MdSnackBarRef` | 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); + } + +} +``` \ No newline at end of file diff --git a/src/lib/snack-bar/index.ts b/src/lib/snack-bar/index.ts new file mode 100644 index 000000000000..259852618083 --- /dev/null +++ b/src/lib/snack-bar/index.ts @@ -0,0 +1 @@ +export * from './snack-bar'; diff --git a/src/lib/snack-bar/simple-snack-bar.html b/src/lib/snack-bar/simple-snack-bar.html new file mode 100644 index 000000000000..9e1fa7da019c --- /dev/null +++ b/src/lib/snack-bar/simple-snack-bar.html @@ -0,0 +1,3 @@ +{{message}} + \ No newline at end of file diff --git a/src/lib/snack-bar/simple-snack-bar.scss b/src/lib/snack-bar/simple-snack-bar.scss new file mode 100644 index 000000000000..9935cd92950e --- /dev/null +++ b/src/lib/snack-bar/simple-snack-bar.scss @@ -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; +} \ No newline at end of file diff --git a/src/lib/snack-bar/simple-snack-bar.ts b/src/lib/snack-bar/simple-snack-bar.ts new file mode 100644 index 000000000000..d74d6736c3df --- /dev/null +++ b/src/lib/snack-bar/simple-snack-bar.ts @@ -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; + + /** Dismisses the snack bar. */ + dismiss(): void { + this.snackBarRef.dismiss(); + } + + /** If the action button should be shown. */ + get hasAction(): boolean { return !!this.action; } +} diff --git a/src/lib/snack-bar/snack-bar-config.ts b/src/lib/snack-bar/snack-bar-config.ts new file mode 100644 index 000000000000..8eaebb8202cc --- /dev/null +++ b/src/lib/snack-bar/snack-bar-config.ts @@ -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; + } +} diff --git a/src/lib/snack-bar/snack-bar-container.html b/src/lib/snack-bar/snack-bar-container.html new file mode 100644 index 000000000000..23e1d44627f8 --- /dev/null +++ b/src/lib/snack-bar/snack-bar-container.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/snack-bar/snack-bar-container.scss b/src/lib/snack-bar/snack-bar-container.scss new file mode 100644 index 000000000000..dd5c4b48dd06 --- /dev/null +++ b/src/lib/snack-bar/snack-bar-container.scss @@ -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; +} \ No newline at end of file diff --git a/src/lib/snack-bar/snack-bar-container.ts b/src/lib/snack-bar/snack-bar-container.ts new file mode 100644 index 000000000000..3ef2d71fc592 --- /dev/null +++ b/src/lib/snack-bar/snack-bar-container.ts @@ -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(portal: ComponentPortal): ComponentRef { + if (this._portalHost.hasAttached()) { + throw new MdSnackBarContentAlreadyAttached(); + } + + return this._portalHost.attachComponentPortal(portal); + } + + attachTemplatePortal(portal: TemplatePortal): Map { + throw Error('Not yet implemented'); + } +} diff --git a/src/lib/snack-bar/snack-bar-errors.ts b/src/lib/snack-bar/snack-bar-errors.ts new file mode 100644 index 000000000000..abd3fb3892ff --- /dev/null +++ b/src/lib/snack-bar/snack-bar-errors.ts @@ -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'); + } +} diff --git a/src/lib/snack-bar/snack-bar-ref.ts b/src/lib/snack-bar/snack-bar-ref.ts new file mode 100644 index 000000000000..10453bdf772d --- /dev/null +++ b/src/lib/snack-bar/snack-bar-ref.ts @@ -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 { + /** 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 = 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 { + return this._afterClosed.asObservable(); + } +} diff --git a/src/lib/snack-bar/snack-bar.spec.ts b/src/lib/snack-bar/snack-bar.spec.ts new file mode 100644 index 000000000000..574346af4470 --- /dev/null +++ b/src/lib/snack-bar/snack-bar.spec.ts @@ -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; + + 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 '); + 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