diff --git a/src/components/sidenav/sidenav.html b/src/components/sidenav/sidenav.html new file mode 100644 index 000000000000..bcdd25dc42eb --- /dev/null +++ b/src/components/sidenav/sidenav.html @@ -0,0 +1,13 @@ +
+ + + + + + diff --git a/src/components/sidenav/sidenav.scss b/src/components/sidenav/sidenav.scss new file mode 100644 index 000000000000..dd4fc822cb25 --- /dev/null +++ b/src/components/sidenav/sidenav.scss @@ -0,0 +1,103 @@ +@import "default-theme"; +@import "variables"; +@import "shadows"; + + +$md-sidenav-background-color: md-color($md-background, 100) !default; + + +@mixin md-sidenav-transition($open, $close) { + transform: translateX($close); + + &.md-sidenav-closed { + visibility: hidden; + } + &.md-sidenav-closing { + transform: translateX($close); + will-change: transform; + } + &.md-sidenav-opening { + visibility: visible; + transform: translateX($open); + will-change: transform; + box-shadow: $md-shadow-bottom-z-1; + } + &.md-sidenav-opened { + transform: translateX($open); + box-shadow: $md-shadow-bottom-z-1; + } +} + + +:host { + position: relative; + display: block; + // Use a transform to create a new stacking context. + transform: translate3D(0, 0, 0); + overflow-x: hidden; + + transition: margin-left $swift-ease-out-duration $swift-ease-out-timing-function, + margin-right $swift-ease-out-duration $swift-ease-out-timing-function; + + & > .md-sidenav-backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $z-index-drawer; + visibility: hidden; + display: block; + + &.md-sidenav-shown { + visibility: visible; + background-color: rgba(0, 0, 0, 0.21); + transition: background-color $swift-ease-out-duration $swift-ease-out-timing-function; + } + } + + & > md-content { + display: block; + position: relative; + transition: margin-left $swift-ease-out-duration $swift-ease-out-timing-function, + margin-right $swift-ease-out-duration $swift-ease-out-timing-function, + left $swift-ease-out-duration $swift-ease-out-timing-function, + right $swift-ease-out-duration $swift-ease-out-timing-function; + } + + > md-sidenav { + position: fixed; + top: 0; + bottom: 0; + z-index: $z-index-drawer + 1; + background-color: $md-sidenav-background-color; + + transition: transform $swift-ease-out-duration $swift-ease-out-timing-function; + + @include md-sidenav-transition(0, -100%); + + &.md-sidenav-side { + z-index: $z-index-drawer - 1; + } + + &.md-sidenav-end { + right: 0; + + @include md-sidenav-transition(0, 100%); + } + } +} + + +:host-context([dir="rtl"]) { + > md-sidenav { + @include md-sidenav-transition(0, 100%); + + &.md-sidenav-end { + left: 0; + right: auto; + + @include md-sidenav-transition(0, -100%); + } + } +} \ No newline at end of file diff --git a/src/components/sidenav/sidenav.ts b/src/components/sidenav/sidenav.ts new file mode 100644 index 000000000000..808fbda327b9 --- /dev/null +++ b/src/components/sidenav/sidenav.ts @@ -0,0 +1,251 @@ +import { + AfterContentInit, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Host, + HostBinding, + Input, + View, + ViewEncapsulation, + OnChanges, + Optional, + Output, + Query, + QueryList, + SimpleChange +} from 'angular2/core'; +import {BaseException} from 'angular2/src/facade/exceptions'; +import {Dir} from "../../directives/dir/dir"; +import {OneOf} from "../../core/annotations/one-of"; + + +/** + * Exception thrown when a MdSidenavLayout is missing both sidenavs. + */ +export class MdMissingSidenavException extends BaseException {} + +/** + * Exception thrown when two MdSidenav are matching the same side. + */ +export class MdDuplicatedSidenavException extends BaseException { + constructor(align: string) { + super(`A sidenav was already declared for 'align="${align}"'`); + } +} + + +/** + * component. + * + * This component corresponds to the drawer of the sidenav. + * + * Please refer to README.md for examples on how to use it. + */ +@Component({ + selector: 'md-sidenav', + template: '', +}) +export class MdSidenav { + /** Alignment of the sidenav (direction neutral); whether 'start' or 'end'. */ + @Input() @OneOf('start', 'end') align: string = 'start'; + + /** Mode of the sidenav; whether 'over' or 'side'. */ + @Input() @OneOf('push', 'over', 'side') mode: string = 'over'; + + /** Whether the sidenav is opened. */ + @Input() set opened(v: boolean) { + this.opened_ = v; + this.transition_ = true; + if (v) { + this.onOpenStart.emit(null); + } else { + this.onCloseStart.emit(null); + } + } + get opened(): boolean { return this.opened_; } + + /** Event emitted when the sidenav is being opened. Use this to synchronize animations. */ + @Output('open-start') onOpenStart = new EventEmitter(); + + /** Event emitted when the sidenav is fully opened. */ + @Output('open') onOpen = new EventEmitter(); + + /** Event emitted when the sidenav is being closed. Use this to synchronize animations. */ + @Output('close-start') onCloseStart = new EventEmitter(); + + /** Event emitted when the sidenav is fully closed. */ + @Output('close') onClose = new EventEmitter(); + + + // TODO(hansl): Get rid of ElementRef. + constructor(private elementRef_: ElementRef) { + if (elementRef_) { + elementRef_.nativeElement.addEventListener('transitionend', + (e: TransitionEvent) => { + if (e.target == elementRef_.nativeElement && e.propertyName == 'transform') { + this.transition_ = false; + if (this.opened_) { + this.onOpen.emit(null); + } else { + this.onClose.emit(null); + } + } + }, false); + } + } + + + /** Width of the sidenav. */ + get width() { + if (this.elementRef_.nativeElement) { + return this.elementRef_.nativeElement.offsetWidth; + } + return 0; + } + + /** Open this sidenav, and return a Promise that will resolve when it's fully opened (or get + * rejected if it didn't). */ + open(): Promise { + return this.toggle(true); + } + + /** + * Close this sidenav, and return a Promise that will resolve when it's fully closed (or get + * rejected if it didn't). + */ + close(): Promise { + return this.toggle(false); + } + + /** + * Toggle this sidenav. This is equivalent to calling open() when it's already opened, or + * close() when it's closed. + * @param isOpen + * @returns {Promise} + */ + toggle(isOpen: boolean = !this.opened): Promise { + // Shortcut it if we're already opened. + if (isOpen === this.opened) { + return Promise.resolve(); + } + + this.opened = isOpen; + + // We hook up both onOpen and onClose, but we reject the promise if + // the animation was cut or cancelled for some reason. + return new Promise((resolve, reject) => { + const property = isOpen ? this.onOpen : this.onClose; + const other = isOpen ? this.onClose : this.onOpen; + const subscription = property.subscribe(() => { + resolve(); + property.remove(subscription); + other.remove(otherSubscription); + }); + const otherSubscription = property.subscribe(() => { + reject(); + property.remove(subscription); + other.remove(otherSubscription); + }); + }); + } + + + /************************************************************************************************ + * Private members. + */ + @HostBinding('class.md-sidenav-closing') private get isClosing_() { + return !this.opened_ && this.transition_; + } + @HostBinding('class.md-sidenav-opening') private get isOpening_() { + return this.opened_ && this.transition_; + } + @HostBinding('class.md-sidenav-closed') get isClosed() { + return !this.opened_ && !this.transition_; + } + @HostBinding('class.md-sidenav-opened') get isOpened() { + return this.opened_ && !this.transition_; + } + @HostBinding('class.md-sidenav-end') get isEnd() { + return this.align == 'end'; + } + @HostBinding('class.md-sidenav-side') get modeSide() { + return this.mode == 'side'; + } + @HostBinding('class.md-sidenav-over') get modeOver() { + return this.mode == 'over'; + } + @HostBinding('class.md-sidenav-push') get modePush() { + return this.mode == 'push'; + } + + private transition_: boolean = false; + private opened_: boolean = false; +} + + +/** + * component. + */ +@Component({ + selector: 'md-sidenav-layout', + directives: [MdSidenav], + templateUrl: './components/sidenav/sidenav.html', + styleUrls: ['./components/sidenav/sidenav.css'], +}) +export class MdSidenavLayout implements AfterContentInit { + @ContentChildren(MdSidenav) private drawers_: QueryList; + + get start() { return this.start_; } + get end() { return this.end_; } + + private start_: MdSidenav; + private end_: MdSidenav; + private right_: MdSidenav; + private left_: MdSidenav; + + private validateDrawers_() { + this.start_ = this.end_ = null; + if (this.drawers_.length === 0) { + throw new MdMissingSidenavException(); + } + + for (const drawer of this.drawers_.toArray()) { + if (drawer.align == 'end') { + if (this.end_) { + throw new MdDuplicatedSidenavException('end'); + } + this.end_ = drawer; + } else { + if (this.start_) { + throw new MdDuplicatedSidenavException('start'); + } + this.start_ = drawer; + } + } + + this.right_ = this.left_ = null; + this.left_ = this.start_; + this.right_ = this.end_; + + // Detect if we're LTR or RTL. + if (this.dir_.dir == 'ltr') { + this.left_ = this.start_; + this.right_ = this.end_; + } else { + this.left_ = this.end_; + this.right_ = this.start_; + } + } + + constructor(@Optional() @Host() private dir_: Dir) { + this.dir_.onDirChange.subscribe(() => this.validateDrawers_()); + } + + ngAfterContentInit() { + // On changes, assert on consistency. + this.drawers_.changes.subscribe(() => this.validateDrawers_()); + this.validateDrawers_(); + } +} diff --git a/src/core/annotations/one-of.ts b/src/core/annotations/one-of.ts new file mode 100644 index 000000000000..d42d19ddfb42 --- /dev/null +++ b/src/core/annotations/one-of.ts @@ -0,0 +1,33 @@ +import {isPresent} from "angular2/src/facade/lang"; + + +declare var Symbol: any; + + +/** + * Annotation Factory that only allows a set of values to be set for a property. + * @param values The list of allowed values. The first value listed will be used as a default. + */ +function OneOfFactory(...values: T[]) { + return function OneOfMetadata(target: any, key: string): void { + const defaultValue = values[0]; + + // Use a fallback if Symbol isn't available. + const localKey = isPresent(Symbol) ? Symbol.for(key) : `@@_${key}_`; + target[localKey] = defaultValue; + + Object.defineProperty(target, key, { + get() { return this[localKey]; }, + set(v: T) { + if (values.indexOf(v) == -1) { + this[localKey] = defaultValue; + } else { + this[localKey] = v; + } + } + }); + } +} + + +export const OneOf = OneOfFactory; diff --git a/src/core/style/_variables.scss b/src/core/style/_variables.scss index 880a00913366..ce2696a3e543 100644 --- a/src/core/style/_variables.scss +++ b/src/core/style/_variables.scss @@ -6,6 +6,7 @@ $md-body-font-size-base: rem(1.400) !default; // z-index master list $z-index-fab: 20 !default; +$z-index-drawer: 100 !default; // Easing Curves // TODO(jelbourn): all of these need to be revisited diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index 8cfb86efe370..4afe8efc68bd 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -1,7 +1,44 @@ -

- material2 Works! + - - - -

+
+

material2 Works!

+ + + + Start Side Drawer +
+ +
+ +
+ +
+ +
+ + + End Side Drawer +
+ +
+ +
+

My Content

+ +
+
Sidenav
+ + +
+ + + + +
+
+ +

Content after Sidenav

+
diff --git a/src/demo-app/demo-app.scss b/src/demo-app/demo-app.scss index e69de29bb2d1..ed48bfc07c1d 100644 --- a/src/demo-app/demo-app.scss +++ b/src/demo-app/demo-app.scss @@ -0,0 +1,12 @@ + +md-sidenav-layout { + border: 3px solid black; + + md-sidenav { + padding: 10px; + } +} + +div.content { + padding: 10px; +} diff --git a/src/demo-app/demo-app.ts b/src/demo-app/demo-app.ts index 9589221d781d..de8d07d3b55c 100644 --- a/src/demo-app/demo-app.ts +++ b/src/demo-app/demo-app.ts @@ -1,12 +1,15 @@ import {Component} from 'angular2/core'; import {MdButton} from '../components/button/button'; +import {MdSidenav, MdSidenavLayout} from '../components/sidenav/sidenav'; +import {Dir} from "../directives/dir/dir"; @Component({ selector: 'demo-app', providers: [], templateUrl: 'demo-app/demo-app.html', - directives: [MdButton], + styleUrls: ['demo-app/demo-app.css'], + directives: [Dir, MdButton, MdSidenav, MdSidenavLayout], pipes: [] }) export class DemoApp { } diff --git a/src/directives/dir/dir.ts b/src/directives/dir/dir.ts new file mode 100644 index 000000000000..5e1e13d41752 --- /dev/null +++ b/src/directives/dir/dir.ts @@ -0,0 +1,27 @@ +import {Directive, EventEmitter, Output, Input} from 'angular2/core'; +import {HostBinding} from 'angular2/core'; + + +@Directive({ + selector: '[dir]', + exportAs: '$implicit' +}) +export class Dir { + @Input('dir') private dir_: string = 'ltr'; + + @Output('dir-changed') onDirChange = new EventEmitter(); + + @HostBinding('attr.dir') + get dir(): string { + return this.dir_; + } + set dir(v: string) { + if (v != 'rtl') { + v = 'ltr'; + } + if (v != this.dir_) { + this.dir_ = v; + this.onDirChange.emit(null); + } + } +} diff --git a/src/index.html b/src/index.html index c89b6cfc4383..bd042e267ab1 100644 --- a/src/index.html +++ b/src/index.html @@ -12,6 +12,7 @@ Loading... + @@ -26,7 +27,15 @@ 'components': { format: 'register', defaultExtension: 'js' - } + }, + 'directives': { + format: 'register', + defaultExtension: 'js' + }, + 'core': { + format: 'register', + defaultExtension: 'js' + }, } }); System.import('main.js').then(null, console.error.bind(console)); diff --git a/tests/unit/services/md-media-query-test.js b/tests/unit/services/md-media-query-test.js new file mode 100644 index 000000000000..11e77c1867d3 --- /dev/null +++ b/tests/unit/services/md-media-query-test.js @@ -0,0 +1,12 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('service:md-media-query', 'Unit | Service | md media query', { + // Specify the other units that are required for this test. + // needs: ['service:foo'] +}); + +// Replace this with your real tests. +test('it exists', function(assert) { + let service = this.subject(); + assert.ok(service); +}); diff --git a/tests/unit/services/media-query-test.js b/tests/unit/services/media-query-test.js new file mode 100644 index 000000000000..4fefd01636af --- /dev/null +++ b/tests/unit/services/media-query-test.js @@ -0,0 +1,12 @@ +import { moduleFor, test } from 'ember-qunit'; + +moduleFor('service:media-query', 'Unit | Service | media query', { + // Specify the other units that are required for this test. + // needs: ['service:foo'] +}); + +// Replace this with your real tests. +test('it exists', function(assert) { + let service = this.subject(); + assert.ok(service); +});