Skip to content

Commit

Permalink
feat(tabs): adds the md-tab-group component (#376)
Browse files Browse the repository at this point in the history
  • Loading branch information
robertmesserle committed May 17, 2016
1 parent f22fa86 commit ada285c
Show file tree
Hide file tree
Showing 14 changed files with 473 additions and 1 deletion.
41 changes: 41 additions & 0 deletions src/components/tab-group/ink-bar.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
12 changes: 12 additions & 0 deletions src/components/tab-group/tab-content.ts
Original file line number Diff line number Diff line change
@@ -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<any>, viewContainerRef: ViewContainerRef) {
super(templateRef, viewContainerRef);
}
}
25 changes: 25 additions & 0 deletions src/components/tab-group/tab-group.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<div class="md-tab-header" role="tablist"
(keydown.arrowRight)="focusNextTab()"
(keydown.arrowLeft)="focusPreviousTab()"
(keydown.enter)="selectedIndex = focusIndex">
<div class="md-tab-label" role="tab" md-tab-label-wrapper
*ngFor="let label of labels; let i = index"
[id]="getTabLabelId(i)"
[tabIndex]="selectedIndex == i ? 0 : -1"
[attr.aria-controls]="getTabContentId(i)"
[attr.aria-selected]="selectedIndex == i"
[class.md-active]="selectedIndex == i"
(click)="focusIndex = selectedIndex = i">
<template [portalHost]="label"></template>
</div>
<md-ink-bar></md-ink-bar>
</div>
<div class="md-tab-body-wrapper">
<div class="md-tab-body"
*ngFor="let content of contents; let i = index"
[id]="getTabContentId(i)"
[class.md-active]="selectedIndex == i"
[attr.aria-labelledby]="getTabLabelId(i)">
<template role="tabpanel" [portalHost]="content" *ngIf="selectedIndex == i"></template>
</div>
</div>
64 changes: 64 additions & 0 deletions src/components/tab-group/tab-group.scss
Original file line number Diff line number Diff line change
@@ -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;

This comment has been minimized.

Copy link
@davidgabrichidze

davidgabrichidze May 23, 2016

Contributor

@robertmesserle Is this temporary? Content of tab can not be re-sized because of this.

This comment has been minimized.

Copy link
@robertmesserle

robertmesserle via email May 23, 2016

Author Contributor
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;
}
132 changes: 132 additions & 0 deletions src/components/tab-group/tab-group.spec.ts
Original file line number Diff line number Diff line change
@@ -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<SimpleTabsTestApp>;

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: `
<md-tab-group class="tab-group" [selectedIndex]="selectedIndex">
<md-tab>
<template md-tab-label>Tab One</template>
<template md-tab-content>Tab one content</template>
</md-tab>
<md-tab>
<template md-tab-label>Tab Two</template>
<template md-tab-content>Tab two content</template>
</md-tab>
<md-tab>
<template md-tab-label>Tab Three</template>
<template md-tab-content>Tab three content</template>
</md-tab>
</md-tab-group>
`,
directives: [MD_TAB_GROUP_DIRECTIVES]
})
class SimpleTabsTestApp {
selectedIndex: number = 1;
}
115 changes: 115 additions & 0 deletions src/components/tab-group/tab-group.ts
Original file line number Diff line number Diff line change
@@ -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<MdTabLabel>;

/** @internal */
@ContentChildren(MdTabContent) contents: QueryList<MdTabContent>;

@ViewChildren(MdTabLabelWrapper) private _labelWrappers: QueryList<MdTabLabelWrapper>;
@ViewChildren(MdInkBar) private _inkBar: QueryList<MdInkBar>;

@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];
Loading

0 comments on commit ada285c

Please sign in to comment.