From ada285c509649c3463c7b4bbc87907c5ccfc46ad Mon Sep 17 00:00:00 2001 From: Robert Messerle Date: Tue, 17 May 2016 11:03:04 -0700 Subject: [PATCH] feat(tabs): adds the `md-tab-group` component (#376) --- src/components/tab-group/ink-bar.ts | 41 ++++++ src/components/tab-group/tab-content.ts | 12 ++ src/components/tab-group/tab-group.html | 25 ++++ src/components/tab-group/tab-group.scss | 64 +++++++++ src/components/tab-group/tab-group.spec.ts | 132 ++++++++++++++++++ src/components/tab-group/tab-group.ts | 115 +++++++++++++++ src/components/tab-group/tab-label-wrapper.ts | 19 +++ src/components/tab-group/tab-label.ts | 12 ++ src/core/style/_mixins.scss | 11 ++ src/demo-app/demo-app.html | 2 +- src/demo-app/demo-app.ts | 2 + src/demo-app/tab-group/tab-group-demo.html | 14 ++ src/demo-app/tab-group/tab-group-demo.scss | 6 + src/demo-app/tab-group/tab-group-demo.ts | 19 +++ 14 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 src/components/tab-group/ink-bar.ts create mode 100644 src/components/tab-group/tab-content.ts create mode 100644 src/components/tab-group/tab-group.html create mode 100644 src/components/tab-group/tab-group.scss create mode 100644 src/components/tab-group/tab-group.spec.ts create mode 100644 src/components/tab-group/tab-group.ts create mode 100644 src/components/tab-group/tab-label-wrapper.ts create mode 100644 src/components/tab-group/tab-label.ts create mode 100644 src/demo-app/tab-group/tab-group-demo.html create mode 100644 src/demo-app/tab-group/tab-group-demo.scss create mode 100644 src/demo-app/tab-group/tab-group-demo.ts diff --git a/src/components/tab-group/ink-bar.ts b/src/components/tab-group/ink-bar.ts new file mode 100644 index 000000000000..a2ea9fd6e1f8 --- /dev/null +++ b/src/components/tab-group/ink-bar.ts @@ -0,0 +1,41 @@ +import {Directive, Renderer, ElementRef} from '@angular/core'; + +/** + * The ink-bar is used to display and animate the line underneath the current active tab label. + * @internal + */ +@Directive({ + selector: 'md-ink-bar', +}) +export class MdInkBar { + constructor(private _renderer: Renderer, private _elementRef: ElementRef) {} + + /** + * Calculates the styles from the provided element in order to align the ink-bar to that element. + * @param element + */ + alignToElement(element: HTMLElement) { + this._renderer.setElementStyle(this._elementRef.nativeElement, 'left', + this._getLeftPosition(element)); + this._renderer.setElementStyle(this._elementRef.nativeElement, 'width', + this._getElementWidth(element)); + } + + /** + * Generates the pixel distance from the left based on the provided element in string format. + * @param element + * @returns {string} + */ + private _getLeftPosition(element: HTMLElement): string { + return element.offsetLeft + 'px'; + } + + /** + * Generates the pixel width from the provided element in string format. + * @param element + * @returns {string} + */ + private _getElementWidth(element: HTMLElement): string { + return element.offsetWidth + 'px'; + } +} diff --git a/src/components/tab-group/tab-content.ts b/src/components/tab-group/tab-content.ts new file mode 100644 index 000000000000..a90d5f26535e --- /dev/null +++ b/src/components/tab-group/tab-content.ts @@ -0,0 +1,12 @@ +import {Directive, TemplateRef, ViewContainerRef} from '@angular/core'; +import {TemplatePortalDirective} from '../../core/portal/portal-directives'; + +/** Used to flag tab contents for use with the portal directive */ +@Directive({ + selector: '[md-tab-content]' +}) +export class MdTabContent extends TemplatePortalDirective { + constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { + super(templateRef, viewContainerRef); + } +} diff --git a/src/components/tab-group/tab-group.html b/src/components/tab-group/tab-group.html new file mode 100644 index 000000000000..d9acaef078e8 --- /dev/null +++ b/src/components/tab-group/tab-group.html @@ -0,0 +1,25 @@ +
+ + +
+
+
+ +
+
diff --git a/src/components/tab-group/tab-group.scss b/src/components/tab-group/tab-group.scss new file mode 100644 index 000000000000..c3b6b2df32d6 --- /dev/null +++ b/src/components/tab-group/tab-group.scss @@ -0,0 +1,64 @@ +@import 'variables'; +@import 'default-theme'; + +$md-tab-bar-height: 48px !default; + +:host { + display: block; + font-family: $md-font-family; +} + +/** The top section of the view; contains the tab labels */ +.md-tab-header { + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + border-bottom: 1px solid md-color($md-background, status-bar); +} + +/** 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; + background-color: md-color($md-primary, 100, 0.3); + } +} + +/** The bottom section of the view; contains the tab bodies */ +.md-tab-body-wrapper { + position: relative; + height: 200px; + overflow: hidden; + padding: 12px; +} + +/** Wraps each tab body */ +.md-tab-body { + display: none; + &.md-active { + display: block; + } +} + +/** The colored bar that underlines the active tab */ +md-ink-bar { + position: absolute; + bottom: 0; + height: 2px; + background-color: md-color($md-primary, 500); + transition: 0.35s ease-out; +} diff --git a/src/components/tab-group/tab-group.spec.ts b/src/components/tab-group/tab-group.spec.ts new file mode 100644 index 000000000000..557f419be806 --- /dev/null +++ b/src/components/tab-group/tab-group.spec.ts @@ -0,0 +1,132 @@ +import { + it, + expect, + beforeEach, + inject, + describe, + async +} from '@angular/core/testing'; +import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing'; +import {MD_TAB_GROUP_DIRECTIVES, MdTabGroup} from './tab-group'; +import {Component} from '@angular/core'; +import {By} from '@angular/platform-browser'; + +describe('MdTabGroup', () => { + let builder: TestComponentBuilder; + let fixture: ComponentFixture; + + beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => { + builder = tcb; + })); + + describe('basic behavior', () => { + beforeEach(async(() => { + builder.createAsync(SimpleTabsTestApp).then(f => { + fixture = f; + }); + })); + + it('should default to the first tab', () => { + checkSelectedIndex(1); + }); + + it('should change selected index on click', () => { + let component = fixture.debugElement.componentInstance; + component.selectedIndex = 0; + checkSelectedIndex(0); + + // select the second tab + let tabLabel = fixture.debugElement.query(By.css('.md-tab-label:nth-of-type(2)')); + tabLabel.nativeElement.click(); + checkSelectedIndex(1); + + // select the third tab + tabLabel = fixture.debugElement.query(By.css('.md-tab-label:nth-of-type(3)')); + tabLabel.nativeElement.click(); + checkSelectedIndex(2); + }); + + it('should cycle through tab focus with focusNextTab/focusPreviousTab functions', () => { + let tabComponent = fixture.debugElement.query(By.css('md-tab-group')).componentInstance; + tabComponent.focusIndex = 0; + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(0); + + tabComponent.focusNextTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(1); + + tabComponent.focusNextTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(2); + + tabComponent.focusNextTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(2); // should stop at 2 + + tabComponent.focusPreviousTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(1); + + tabComponent.focusPreviousTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(0); + + tabComponent.focusPreviousTab(); + fixture.detectChanges(); + expect(tabComponent.focusIndex).toBe(0); // should stop at 0 + }); + + it('should change tabs based on selectedIndex', () => { + let component = fixture.debugElement.componentInstance; + checkSelectedIndex(1); + + component.selectedIndex = 2; + checkSelectedIndex(2); + }); + }); + + /** + * Checks that the `selectedIndex` has been updated; checks that the label and body have the + * `md-active` class + */ + function checkSelectedIndex(index: number) { + fixture.detectChanges(); + + let tabComponent: MdTabGroup = fixture.debugElement + .query(By.css('md-tab-group')).componentInstance; + expect(tabComponent.selectedIndex).toBe(index); + + let tabLabelElement = fixture.debugElement + .query(By.css(`.md-tab-label:nth-of-type(${index + 1})`)).nativeElement; + expect(tabLabelElement.classList.contains('md-active')).toBe(true); + + let tabContentElement = fixture.debugElement + .query(By.css(`#${tabLabelElement.id}`)).nativeElement; + expect(tabContentElement.classList.contains('md-active')).toBe(true); + } +}); + +@Component({ + selector: 'test-app', + template: ` + + + + + + + + + + + + + + + `, + directives: [MD_TAB_GROUP_DIRECTIVES] +}) +class SimpleTabsTestApp { + selectedIndex: number = 1; +} diff --git a/src/components/tab-group/tab-group.ts b/src/components/tab-group/tab-group.ts new file mode 100644 index 000000000000..0254ac65109d --- /dev/null +++ b/src/components/tab-group/tab-group.ts @@ -0,0 +1,115 @@ +import {Component, Input, ViewChildren, NgZone} from '@angular/core'; +import {QueryList} from '@angular/core'; +import {ContentChildren} from '@angular/core'; +import {PortalHostDirective} from '../../core/portal/portal-directives'; +import {MdTabLabel} from './tab-label'; +import {MdTabContent} from './tab-content'; +import {MdTabLabelWrapper} from './tab-label-wrapper'; +import {MdInkBar} from './ink-bar'; + +/** Used to generate unique ID's for each tab component */ +let nextId = 0; + +/** + * Material design tab-group component. Supports basic tab pairs (label + content) and includes + * animated ink-bar, keyboard navigation, and screen reader. + * See: https://www.google.com/design/spec/components/tabs.html + */ +@Component({ + selector: 'md-tab-group', + templateUrl: './components/tab-group/tab-group.html', + styleUrls: ['./components/tab-group/tab-group.css'], + directives: [PortalHostDirective, MdTabLabelWrapper, MdInkBar], +}) +export class MdTabGroup { + /** @internal */ + @ContentChildren(MdTabLabel) labels: QueryList; + + /** @internal */ + @ContentChildren(MdTabContent) contents: QueryList; + + @ViewChildren(MdTabLabelWrapper) private _labelWrappers: QueryList; + @ViewChildren(MdInkBar) private _inkBar: QueryList; + + @Input() selectedIndex: number = 0; + + private _focusIndex: number = 0; + private _groupId: number; + + constructor(private _zone: NgZone) { + this._groupId = nextId++; + } + + /** + * Waits one frame for the view to update, then upates the ink bar + * Note: This must be run outside of the zone or it will create an infinite change detection loop + * @internal + */ + ngAfterViewChecked(): void { + this._zone.runOutsideAngular(() => { + window.requestAnimationFrame(() => { + this._updateInkBar(); + }); + }); + } + + /** Tells the ink-bar to align itself to the current label wrapper */ + private _updateInkBar(): void { + this._inkBar.toArray()[0].alignToElement(this._currentLabelWrapper); + } + + /** + * Reference to the current label wrapper; defaults to null for initial render before the + * ViewChildren references are ready. + */ + private get _currentLabelWrapper(): HTMLElement { + return this._labelWrappers + ? this._labelWrappers.toArray()[this.selectedIndex].elementRef.nativeElement + : null; + } + + /** Tracks which element has focus; used for keyboard navigation */ + get focusIndex(): number { + return this._focusIndex; + } + + /** When the focus index is set, we must manually send focus to the correct label */ + set focusIndex(value: number) { + this._focusIndex = value; + if (this._labelWrappers && this._labelWrappers.length) { + this._labelWrappers.toArray()[value].focus(); + } + } + + /** + * Returns a unique id for each tab label element + * @internal + */ + getTabLabelId(i: number): string { + return `md-tab-label-${this._groupId}-${i}`; + } + + /** + * Returns a unique id for each tab content element + * @internal + */ + getTabContentId(i: number): string { + return `md-tab-content-${this._groupId}-${i}`; + } + + /** Increment the focus index by 1; prevent going over the number of tabs */ + focusNextTab(): void { + if (this._labelWrappers && this.focusIndex < this._labelWrappers.length - 1) { + this.focusIndex++; + } + } + + /** Decrement the focus index by 1; prevent going below 0 */ + focusPreviousTab(): void { + if (this.focusIndex > 0) { + this.focusIndex--; + } + } +} + +export const MD_TAB_GROUP_DIRECTIVES = [MdTabGroup, MdTabLabel, MdTabContent]; diff --git a/src/components/tab-group/tab-label-wrapper.ts b/src/components/tab-group/tab-label-wrapper.ts new file mode 100644 index 000000000000..0137593de738 --- /dev/null +++ b/src/components/tab-group/tab-label-wrapper.ts @@ -0,0 +1,19 @@ +import {Directive, ElementRef} from '@angular/core'; + +/** + * Used in the `md-tab-group` view to display tab labels + * @internal + */ +@Directive({ + selector: '[md-tab-label-wrapper]' +}) +export class MdTabLabelWrapper { + constructor(public elementRef: ElementRef) {} + + /** + * Sets focus on the wrapper element + */ + focus(): void { + this.elementRef.nativeElement.focus(); + } +} diff --git a/src/components/tab-group/tab-label.ts b/src/components/tab-group/tab-label.ts new file mode 100644 index 000000000000..3ea219587d1c --- /dev/null +++ b/src/components/tab-group/tab-label.ts @@ -0,0 +1,12 @@ +import {Directive, TemplateRef, ViewContainerRef} from '@angular/core'; +import {TemplatePortalDirective} from '../../core/portal/portal-directives'; + +/** Used to flag tab labels for use with the portal directive */ +@Directive({ + selector: '[md-tab-label]', +}) +export class MdTabLabel extends TemplatePortalDirective { + constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { + super(templateRef, viewContainerRef); + } +} diff --git a/src/core/style/_mixins.scss b/src/core/style/_mixins.scss index b760828f1773..46ebdd6546ef 100644 --- a/src/core/style/_mixins.scss +++ b/src/core/style/_mixins.scss @@ -26,6 +26,17 @@ width: 1px; } +/** + * Forces an element to grow to fit floated contents; used as as an alternative to + * `overflow: hidden;` because it doesn't cut off contents. + */ +@mixin md-clearfix { + &:before, &:after { + content: ''; + clear: both; + display: table; + } +} @mixin md-temporary-ink-ripple($component) { // TODO(mtlin): Replace when ink ripple component is implemented. // A placeholder ink ripple, shown when keyboard focused. diff --git a/src/demo-app/demo-app.html b/src/demo-app/demo-app.html index b2efa0388274..5e42b23dab4c 100644 --- a/src/demo-app/demo-app.html +++ b/src/demo-app/demo-app.html @@ -17,11 +17,11 @@ Radio Sidenav Toolbar + Tab Group
-