diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 9c7b7ac2daba..0949d5a19849 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -32,9 +32,7 @@ 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'; - - +import {TabsDemoModule} from './tabs/tabs-demo.module'; @NgModule({ imports: [ @@ -43,6 +41,7 @@ import {TabsDemo} from './tabs/tab-group-demo'; HttpModule, RouterModule.forRoot(DEMO_APP_ROUTES), MaterialModule.forRoot(), + TabsDemoModule, ], declarations: [ BaselineDemo, @@ -76,7 +75,6 @@ import {TabsDemo} from './tabs/tab-group-demo'; SliderDemo, SlideToggleDemo, SpagettiPanel, - TabsDemo, ToolbarDemo, TooltipDemo, ], diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index b7c3fa47f3a7..68ec614443db 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -3,7 +3,7 @@ import {Home} from './demo-app'; import {ButtonDemo} from '../button/button-demo'; import {BaselineDemo} from '../baseline/baseline-demo'; import {ButtonToggleDemo} from '../button-toggle/button-toggle-demo'; -import {TabsDemo} from '../tabs/tab-group-demo'; +import {TabsDemo} from '../tabs/tabs-demo'; import {GridListDemo} from '../grid-list/grid-list-demo'; import {GesturesDemo} from '../gestures/gestures-demo'; import {LiveAnnouncerDemo} from '../live-announcer/live-announcer-demo'; @@ -27,7 +27,7 @@ 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'; - +import {TABS_DEMO_ROUTES} from '../tabs/routes'; export const DEMO_APP_ROUTES: Routes = [ {path: '', component: Home}, @@ -51,7 +51,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'live-announcer', component: LiveAnnouncerDemo}, {path: 'gestures', component: GesturesDemo}, {path: 'grid-list', component: GridListDemo}, - {path: 'tabs', component: TabsDemo}, + {path: 'tabs', component: TabsDemo, children: TABS_DEMO_ROUTES}, {path: 'button-toggle', component: ButtonToggleDemo}, {path: 'baseline', component: BaselineDemo}, {path: 'ripple', component: RippleDemo}, diff --git a/src/demo-app/tabs/routes.ts b/src/demo-app/tabs/routes.ts new file mode 100644 index 000000000000..34ce652f7e78 --- /dev/null +++ b/src/demo-app/tabs/routes.ts @@ -0,0 +1,10 @@ +import {Routes} from '@angular/router'; + +import {SunnyTabContent, RainyTabContent, FoggyTabContent} from '../tabs/tabs-demo'; + +export const TABS_DEMO_ROUTES: Routes = [ + {path: '', redirectTo: 'sunny-tab', pathMatch: 'full'}, + {path: 'sunny-tab', component: SunnyTabContent}, + {path: 'rainy-tab', component: RainyTabContent}, + {path: 'foggy-tab', component: FoggyTabContent}, +]; diff --git a/src/demo-app/tabs/tab-group-demo.scss b/src/demo-app/tabs/tab-group-demo.scss deleted file mode 100644 index 5750046c04f6..000000000000 --- a/src/demo-app/tabs/tab-group-demo.scss +++ /dev/null @@ -1,9 +0,0 @@ -.demo-tab-group { - border: 1px solid #e0e0e0; - .md-tab-header { - background: #f9f9f9; - } - .md-tab-body { - padding: 12px; - } -} diff --git a/src/demo-app/tabs/tab-group-demo.ts b/src/demo-app/tabs/tab-group-demo.ts deleted file mode 100644 index 3fc5d0953386..000000000000 --- a/src/demo-app/tabs/tab-group-demo.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {Component, ViewEncapsulation} from '@angular/core'; -import {Observable} from 'rxjs/Observable'; - - -@Component({ - moduleId: module.id, - selector: 'tab-group-demo', - templateUrl: 'tab-group-demo.html', - styleUrls: ['tab-group-demo.css'], - encapsulation: ViewEncapsulation.None, -}) -export class TabsDemo { - tabs = [ - { label: 'Tab One', content: 'This is the body of the first tab' }, - { label: 'Tab Two', content: 'This is the body of the second tab' }, - { label: 'Tab Three', content: 'This is the body of the third tab' }, - ]; - asyncTabs: Observable; - constructor() { - this.asyncTabs = Observable.create((observer: any) => { - setTimeout(() => { - observer.next(this.tabs); - }, 1000); - }); - } -} diff --git a/src/demo-app/tabs/tab-group-demo.html b/src/demo-app/tabs/tabs-demo.html similarity index 67% rename from src/demo-app/tabs/tab-group-demo.html rename to src/demo-app/tabs/tabs-demo.html index 3803814796ed..656b09d02a71 100644 --- a/src/demo-app/tabs/tab-group-demo.html +++ b/src/demo-app/tabs/tabs-demo.html @@ -1,3 +1,19 @@ +

Tab Nav Bar

+ +
+ + +
+ +

Tab Group Demo

@@ -13,6 +29,7 @@

Tab Group Demo

+

Async Tabs

diff --git a/src/demo-app/tabs/tabs-demo.module.ts b/src/demo-app/tabs/tabs-demo.module.ts new file mode 100644 index 000000000000..f564e2dc628a --- /dev/null +++ b/src/demo-app/tabs/tabs-demo.module.ts @@ -0,0 +1,23 @@ +import {NgModule} from '@angular/core'; +import {MaterialModule} from '@angular/material'; +import {FormsModule} from '@angular/forms'; +import {BrowserModule} from '@angular/platform-browser'; +import {RouterModule} from '@angular/router'; + +import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tabs-demo'; + +@NgModule({ + imports: [ + FormsModule, + BrowserModule, + MaterialModule, + RouterModule, + ], + declarations: [ + TabsDemo, + SunnyTabContent, + RainyTabContent, + FoggyTabContent, + ] +}) +export class TabsDemoModule {} diff --git a/src/demo-app/tabs/tabs-demo.scss b/src/demo-app/tabs/tabs-demo.scss new file mode 100644 index 000000000000..0a0337ee419c --- /dev/null +++ b/src/demo-app/tabs/tabs-demo.scss @@ -0,0 +1,22 @@ +.demo-nav-bar { + border: 1px solid #e0e0e0; + [md-tab-nav-bar] { + background: #f9f9f9; + } + sunny-routed-content, + rainy-routed-content, + foggy-routed-content { + display: block; + padding: 12px; + } +} + +.demo-tab-group { + border: 1px solid #e0e0e0; + .md-tab-header { + background: #f9f9f9; + } + .md-tab-body { + padding: 12px; + } +} \ No newline at end of file diff --git a/src/demo-app/tabs/tabs-demo.ts b/src/demo-app/tabs/tabs-demo.ts new file mode 100644 index 000000000000..5fda1dffee88 --- /dev/null +++ b/src/demo-app/tabs/tabs-demo.ts @@ -0,0 +1,65 @@ +import {Component, ViewEncapsulation} from '@angular/core'; +import {Router} from '@angular/router'; +import {Observable} from 'rxjs/Observable'; + +@Component({ + moduleId: module.id, + selector: 'tabs-demo', + templateUrl: 'tabs-demo.html', + styleUrls: ['tabs-demo.css'], + encapsulation: ViewEncapsulation.None, +}) +export class TabsDemo { + tabLinks = [ + { label: 'Sun', link: 'sunny-tab'}, + { label: 'Rain', link: 'rainy-tab'}, + { label: 'Fog', link: 'foggy-tab'}, + ]; + activeLinkIndex = 0; + + tabs = [ + { label: 'Tab One', content: 'This is the body of the first tab' }, + { label: 'Tab Two', content: 'This is the body of the second tab' }, + { label: 'Tab Three', content: 'This is the body of the third tab' }, + ]; + + asyncTabs: Observable; + + constructor(private router: Router) { + this.asyncTabs = Observable.create((observer: any) => { + setTimeout(() => { + observer.next(this.tabs); + }, 1000); + }); + + // Initialize the index by checking if a tab link is contained in the url. + // This is not an ideal check and can be removed if routerLink exposes if it is active. + // https://github.com/angular/angular/pull/12525 + this.activeLinkIndex = + this.tabLinks.findIndex(routedTab => router.url.indexOf(routedTab.link) != -1); + } +} + + +@Component({ + moduleId: module.id, + selector: 'sunny-routed-content', + template: 'This is the routed body of the sunny tab.', +}) +export class SunnyTabContent {} + + +@Component({ + moduleId: module.id, + selector: 'rainy-routed-content', + template: 'This is the routed body of the rainy tab.', +}) +export class RainyTabContent {} + + +@Component({ + moduleId: module.id, + selector: 'foggy-routed-content', + template: 'This is the routed body of the foggy tab.', +}) +export class FoggyTabContent {} diff --git a/src/lib/index.ts b/src/lib/index.ts index 027498aea622..e242a9fc8777 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -20,5 +20,6 @@ export * from './slider/index'; export * from './slide-toggle/index'; export * from './snack-bar/index'; export * from './tabs/index'; +export * from './tabs/tab-nav-bar/index'; export * from './toolbar/index'; export * from './tooltip/index'; diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss new file mode 100644 index 000000000000..672cfa6495e2 --- /dev/null +++ b/src/lib/tabs/_tabs-common.scss @@ -0,0 +1,40 @@ +@import '../core/style/variables'; + +$md-tab-bar-height: 48px !default; + +// Mixin styles for labels that are contained within the tab header. +@mixin tab-label { + line-height: $md-tab-bar-height; + height: $md-tab-bar-height; + padding: 0 12px; + font-size: $md-body-font-size-base; + font-family: $md-font-family; + font-weight: 500; + cursor: pointer; + box-sizing: border-box; + color: currentColor; + opacity: 0.6; + min-width: 160px; + text-align: center; + &:focus { + outline: none; + opacity: 1; + } +} + +// Mixin styles for the top section of the view; contains the tab labels. +@mixin tab-header { + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + flex-shrink: 0; +} + +// Mixin styles for the ink bar that displays near the active tab in the header. +@mixin ink-bar { + position: absolute; + bottom: 0; + height: 2px; + transition: 350ms ease-out; +} \ No newline at end of file diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index abb5ceeb4d5a..ff0017b5ad16 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -8,7 +8,8 @@ $warn: map-get($theme, warn); $background: map-get($theme, background); $foreground: map-get($theme, foreground); - + + [md-tab-nav-bar], .md-tab-header { border-bottom: 1px solid md-color($background, status-bar); } diff --git a/src/lib/tabs/tab-group.scss b/src/lib/tabs/tab-group.scss index c0dfd8e2d050..3e48ae569952 100644 --- a/src/lib/tabs/tab-group.scss +++ b/src/lib/tabs/tab-group.scss @@ -1,7 +1,5 @@ @import '../core/style/variables'; - - -$md-tab-bar-height: 48px !default; +@import 'tabs-common'; :host { display: flex; @@ -9,33 +7,14 @@ $md-tab-bar-height: 48px !default; font-family: $md-font-family; } -// The top section of the view; contains the tab labels +// Styling for the heading containing the tab labels .md-tab-header { - overflow: hidden; - position: relative; - display: flex; - flex-direction: row; - flex-shrink: 0; + @include tab-header; } -// Wraps each tab label +// Wraps each tab label .md-tab-label { - line-height: $md-tab-bar-height; - height: $md-tab-bar-height; - padding: 0 12px; - font-size: $md-body-font-size-base; - font-family: $md-font-family; - font-weight: 500; - cursor: pointer; - box-sizing: border-box; - color: currentColor; - opacity: 0.6; - min-width: 160px; - text-align: center; - &:focus { - outline: none; - opacity: 1; - } + @include tab-label; } @media ($md-xsmall) { @@ -44,9 +23,9 @@ $md-tab-bar-height: 48px !default; } } -.md-tab-disabled { - cursor: default; - pointer-events: none; +// The ink bar that displays next to the active tab +md-ink-bar { + @include ink-bar; } // The bottom section of the view; contains the tab bodies @@ -69,10 +48,8 @@ $md-tab-bar-height: 48px !default; } } -// The colored bar that underlines the active tab -md-ink-bar { - position: absolute; - bottom: 0; - height: 2px; - transition: 350ms ease-out; -} +// Styling for any tab that is marked disabled +.md-tab-disabled { + cursor: default; + pointer-events: none; +} \ No newline at end of file diff --git a/src/lib/tabs/tab-nav-bar/index.ts b/src/lib/tabs/tab-nav-bar/index.ts new file mode 100644 index 000000000000..f66d9f31e3dd --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/index.ts @@ -0,0 +1 @@ +export * from './tab-nav-bar'; diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.html b/src/lib/tabs/tab-nav-bar/tab-nav-bar.html new file mode 100644 index 000000000000..3b849fe4cae3 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.html @@ -0,0 +1,2 @@ + + diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss b/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss new file mode 100644 index 000000000000..01d5ee6155e1 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss @@ -0,0 +1,24 @@ +@import '../tabs-common'; +@import '../../core/style/variables'; + +// Wraps the bar containing the anchors +[md-tab-nav-bar] { + @include tab-header; +} + +// Wraps each link in the header +[md-tab-link] { + @include tab-label; + text-decoration: none; +} + +@media ($md-xsmall) { + [md-tab-link] { + min-width: 72px; + } +} + +// Styling for the ink bar that displays near the activated anchor +md-ink-bar { + @include ink-bar; +} \ No newline at end of file diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts new file mode 100644 index 000000000000..a2074c18fdf5 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -0,0 +1,55 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MdTabsModule} from '../tabs'; +import {Component} from '@angular/core'; +import {By} from '@angular/platform-browser'; + + +describe('MdTabNavBar', () => { + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdTabsModule.forRoot()], + declarations: [ + SimpleTabNavBarTestApp + ], + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + }); + + it('should change active index on click', () => { + let component = fixture.debugElement.componentInstance; + + // select the second link + let tabLink = fixture.debugElement.queryAll(By.css('a'))[1]; + tabLink.nativeElement.click(); + expect(component.activeIndex).toBe(1); + + // select the third link + tabLink = fixture.debugElement.queryAll(By.css('a'))[2]; + tabLink.nativeElement.click(); + expect(component.activeIndex).toBe(2); + }); + }); +}); + +@Component({ + selector: 'test-app', + template: ` + + ` +}) +class SimpleTabNavBarTestApp { + activeIndex = 0; +} diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts new file mode 100644 index 000000000000..cb4eef157100 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts @@ -0,0 +1,43 @@ +import {Component, Input, ViewChild, ElementRef, ViewEncapsulation, Directive} from '@angular/core'; +import {MdInkBar} from '../ink-bar'; + +/** + * Navigation component matching the styles of the tab group header. + * Provides anchored navigation with animated ink bar. + */ +@Component({ + moduleId: module.id, + selector: '[md-tab-nav-bar]', + templateUrl: 'tab-nav-bar.html', + styleUrls: ['tab-nav-bar.css'], + encapsulation: ViewEncapsulation.None, +}) +export class MdTabNavBar { + @ViewChild(MdInkBar) _inkBar: MdInkBar; + + /** Animates the ink bar to the position of the active link element. */ + updateActiveLink(element: HTMLElement) { + this._inkBar.alignToElement(element); + } +} + +@Directive({ + selector: '[md-tab-link]', +}) +export class MdTabLink { + private _isActive: boolean = false; + + @Input() + get active(): boolean { + return this._isActive; + } + + set active(value: boolean) { + this._isActive = value; + if (value) { + this._mdTabNavBar.updateActiveLink(this._element.nativeElement); + } + } + + constructor(private _mdTabNavBar: MdTabNavBar, private _element: ElementRef) {} +} diff --git a/src/lib/tabs/tabs.ts b/src/lib/tabs/tabs.ts index ee77040a63d1..798080b89c9d 100644 --- a/src/lib/tabs/tabs.ts +++ b/src/lib/tabs/tabs.ts @@ -17,6 +17,7 @@ import {PortalModule} from '../core'; import {MdTabLabel} from './tab-label'; import {MdTabContent} from './tab-content'; import {MdTabLabelWrapper} from './tab-label-wrapper'; +import {MdTabNavBar, MdTabLink} from './tab-nav-bar/tab-nav-bar'; import {MdInkBar} from './ink-bar'; import {Observable} from 'rxjs/Observable'; import 'rxjs/add/operator/map'; @@ -119,7 +120,7 @@ export class MdTabGroup { } /** - * Waits one frame for the view to update, then upates the ink bar + * Waits one frame for the view to update, then updates the ink bar * Note: This must be run outside of the zone or it will create an infinite change detection loop * TODO: internal */ @@ -229,9 +230,10 @@ export class MdTabGroup { @NgModule({ imports: [CommonModule, PortalModule], - // Don't export MdInkBar or MdTabLabelWrapper, as they are internal implementatino details. - exports: [MdTabGroup, MdTabLabel, MdTabContent, MdTab], - declarations: [MdTabGroup, MdTabLabel, MdTabContent, MdTab, MdInkBar, MdTabLabelWrapper], + // Don't export MdInkBar or MdTabLabelWrapper, as they are internal implementation details. + exports: [MdTabGroup, MdTabLabel, MdTabContent, MdTab, MdTabNavBar, MdTabLink], + declarations: [MdTabGroup, MdTabLabel, MdTabContent, MdTab, MdInkBar, MdTabLabelWrapper, + MdTabNavBar, MdTabLink], }) export class MdTabsModule { static forRoot(): ModuleWithProviders {