Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tabs): adds the md-tab-group component #376

Merged
merged 1 commit into from
May 17, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add class description explaining what the ink bar is and how it's used.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably also want to add an @internal JsDoc; this makes TypeScript ignore it when generating the .d.ts.. For example:

/**
 * This is the description of the ...
 * @internal
 */

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';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does ElementRef actually have offsetLeft and offsetWidth properties? I think you actually need to pull them through the renderer.

}

/**
* 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({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add class description (here and for other classes throughout the PR)

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For each of the css classes here, could you add a brief overview of what they're for or which element/state they correspond to?

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;
}
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)'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will probably be much cleaner to do the createAsync in a beforeEach, and then capture all of the tab labels in an array and access them directly in each test. I'm doing in in my in-progress rewrite of the radio button tests, I can show you if you're curious.

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(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add explanation of why you're doing the runOutsideAngular

window.requestAnimationFrame(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should work to just say requestAnimationFrame without explicitly dereferencing window.

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the labels and contents generate their own IDs?

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: could do

this.focusIndex = Math.max(0, this.focusIndex - 1);

this.focusIndex--;
}
}
}

export const MD_TAB_GROUP_DIRECTIVES = [MdTabGroup, MdTabLabel, MdTabContent];
Loading