Skip to content

Commit ada285c

Browse files
feat(tabs): adds the md-tab-group component (#376)
1 parent f22fa86 commit ada285c

14 files changed

+473
-1
lines changed

src/components/tab-group/ink-bar.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {Directive, Renderer, ElementRef} from '@angular/core';
2+
3+
/**
4+
* The ink-bar is used to display and animate the line underneath the current active tab label.
5+
* @internal
6+
*/
7+
@Directive({
8+
selector: 'md-ink-bar',
9+
})
10+
export class MdInkBar {
11+
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
12+
13+
/**
14+
* Calculates the styles from the provided element in order to align the ink-bar to that element.
15+
* @param element
16+
*/
17+
alignToElement(element: HTMLElement) {
18+
this._renderer.setElementStyle(this._elementRef.nativeElement, 'left',
19+
this._getLeftPosition(element));
20+
this._renderer.setElementStyle(this._elementRef.nativeElement, 'width',
21+
this._getElementWidth(element));
22+
}
23+
24+
/**
25+
* Generates the pixel distance from the left based on the provided element in string format.
26+
* @param element
27+
* @returns {string}
28+
*/
29+
private _getLeftPosition(element: HTMLElement): string {
30+
return element.offsetLeft + 'px';
31+
}
32+
33+
/**
34+
* Generates the pixel width from the provided element in string format.
35+
* @param element
36+
* @returns {string}
37+
*/
38+
private _getElementWidth(element: HTMLElement): string {
39+
return element.offsetWidth + 'px';
40+
}
41+
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {Directive, TemplateRef, ViewContainerRef} from '@angular/core';
2+
import {TemplatePortalDirective} from '../../core/portal/portal-directives';
3+
4+
/** Used to flag tab contents for use with the portal directive */
5+
@Directive({
6+
selector: '[md-tab-content]'
7+
})
8+
export class MdTabContent extends TemplatePortalDirective {
9+
constructor(templateRef: TemplateRef<any>, viewContainerRef: ViewContainerRef) {
10+
super(templateRef, viewContainerRef);
11+
}
12+
}
+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<div class="md-tab-header" role="tablist"
2+
(keydown.arrowRight)="focusNextTab()"
3+
(keydown.arrowLeft)="focusPreviousTab()"
4+
(keydown.enter)="selectedIndex = focusIndex">
5+
<div class="md-tab-label" role="tab" md-tab-label-wrapper
6+
*ngFor="let label of labels; let i = index"
7+
[id]="getTabLabelId(i)"
8+
[tabIndex]="selectedIndex == i ? 0 : -1"
9+
[attr.aria-controls]="getTabContentId(i)"
10+
[attr.aria-selected]="selectedIndex == i"
11+
[class.md-active]="selectedIndex == i"
12+
(click)="focusIndex = selectedIndex = i">
13+
<template [portalHost]="label"></template>
14+
</div>
15+
<md-ink-bar></md-ink-bar>
16+
</div>
17+
<div class="md-tab-body-wrapper">
18+
<div class="md-tab-body"
19+
*ngFor="let content of contents; let i = index"
20+
[id]="getTabContentId(i)"
21+
[class.md-active]="selectedIndex == i"
22+
[attr.aria-labelledby]="getTabLabelId(i)">
23+
<template role="tabpanel" [portalHost]="content" *ngIf="selectedIndex == i"></template>
24+
</div>
25+
</div>
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@import 'variables';
2+
@import 'default-theme';
3+
4+
$md-tab-bar-height: 48px !default;
5+
6+
:host {
7+
display: block;
8+
font-family: $md-font-family;
9+
}
10+
11+
/** The top section of the view; contains the tab labels */
12+
.md-tab-header {
13+
overflow: hidden;
14+
position: relative;
15+
display: flex;
16+
flex-direction: row;
17+
border-bottom: 1px solid md-color($md-background, status-bar);
18+
}
19+
20+
/** Wraps each tab label */
21+
.md-tab-label {
22+
line-height: $md-tab-bar-height;
23+
height: $md-tab-bar-height;
24+
padding: 0 12px;
25+
font-size: $md-body-font-size-base;
26+
font-family: $md-font-family;
27+
font-weight: 500;
28+
cursor: pointer;
29+
box-sizing: border-box;
30+
color: currentColor;
31+
opacity: 0.6;
32+
min-width: 160px;
33+
text-align: center;
34+
&:focus {
35+
outline: none;
36+
opacity: 1;
37+
background-color: md-color($md-primary, 100, 0.3);
38+
}
39+
}
40+
41+
/** The bottom section of the view; contains the tab bodies */
42+
.md-tab-body-wrapper {
43+
position: relative;
44+
height: 200px;
45+
overflow: hidden;
46+
padding: 12px;
47+
}
48+
49+
/** Wraps each tab body */
50+
.md-tab-body {
51+
display: none;
52+
&.md-active {
53+
display: block;
54+
}
55+
}
56+
57+
/** The colored bar that underlines the active tab */
58+
md-ink-bar {
59+
position: absolute;
60+
bottom: 0;
61+
height: 2px;
62+
background-color: md-color($md-primary, 500);
63+
transition: 0.35s ease-out;
64+
}
+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {
2+
it,
3+
expect,
4+
beforeEach,
5+
inject,
6+
describe,
7+
async
8+
} from '@angular/core/testing';
9+
import {TestComponentBuilder, ComponentFixture} from '@angular/compiler/testing';
10+
import {MD_TAB_GROUP_DIRECTIVES, MdTabGroup} from './tab-group';
11+
import {Component} from '@angular/core';
12+
import {By} from '@angular/platform-browser';
13+
14+
describe('MdTabGroup', () => {
15+
let builder: TestComponentBuilder;
16+
let fixture: ComponentFixture<SimpleTabsTestApp>;
17+
18+
beforeEach(inject([TestComponentBuilder], (tcb: TestComponentBuilder) => {
19+
builder = tcb;
20+
}));
21+
22+
describe('basic behavior', () => {
23+
beforeEach(async(() => {
24+
builder.createAsync(SimpleTabsTestApp).then(f => {
25+
fixture = f;
26+
});
27+
}));
28+
29+
it('should default to the first tab', () => {
30+
checkSelectedIndex(1);
31+
});
32+
33+
it('should change selected index on click', () => {
34+
let component = fixture.debugElement.componentInstance;
35+
component.selectedIndex = 0;
36+
checkSelectedIndex(0);
37+
38+
// select the second tab
39+
let tabLabel = fixture.debugElement.query(By.css('.md-tab-label:nth-of-type(2)'));
40+
tabLabel.nativeElement.click();
41+
checkSelectedIndex(1);
42+
43+
// select the third tab
44+
tabLabel = fixture.debugElement.query(By.css('.md-tab-label:nth-of-type(3)'));
45+
tabLabel.nativeElement.click();
46+
checkSelectedIndex(2);
47+
});
48+
49+
it('should cycle through tab focus with focusNextTab/focusPreviousTab functions', () => {
50+
let tabComponent = fixture.debugElement.query(By.css('md-tab-group')).componentInstance;
51+
tabComponent.focusIndex = 0;
52+
fixture.detectChanges();
53+
expect(tabComponent.focusIndex).toBe(0);
54+
55+
tabComponent.focusNextTab();
56+
fixture.detectChanges();
57+
expect(tabComponent.focusIndex).toBe(1);
58+
59+
tabComponent.focusNextTab();
60+
fixture.detectChanges();
61+
expect(tabComponent.focusIndex).toBe(2);
62+
63+
tabComponent.focusNextTab();
64+
fixture.detectChanges();
65+
expect(tabComponent.focusIndex).toBe(2); // should stop at 2
66+
67+
tabComponent.focusPreviousTab();
68+
fixture.detectChanges();
69+
expect(tabComponent.focusIndex).toBe(1);
70+
71+
tabComponent.focusPreviousTab();
72+
fixture.detectChanges();
73+
expect(tabComponent.focusIndex).toBe(0);
74+
75+
tabComponent.focusPreviousTab();
76+
fixture.detectChanges();
77+
expect(tabComponent.focusIndex).toBe(0); // should stop at 0
78+
});
79+
80+
it('should change tabs based on selectedIndex', () => {
81+
let component = fixture.debugElement.componentInstance;
82+
checkSelectedIndex(1);
83+
84+
component.selectedIndex = 2;
85+
checkSelectedIndex(2);
86+
});
87+
});
88+
89+
/**
90+
* Checks that the `selectedIndex` has been updated; checks that the label and body have the
91+
* `md-active` class
92+
*/
93+
function checkSelectedIndex(index: number) {
94+
fixture.detectChanges();
95+
96+
let tabComponent: MdTabGroup = fixture.debugElement
97+
.query(By.css('md-tab-group')).componentInstance;
98+
expect(tabComponent.selectedIndex).toBe(index);
99+
100+
let tabLabelElement = fixture.debugElement
101+
.query(By.css(`.md-tab-label:nth-of-type(${index + 1})`)).nativeElement;
102+
expect(tabLabelElement.classList.contains('md-active')).toBe(true);
103+
104+
let tabContentElement = fixture.debugElement
105+
.query(By.css(`#${tabLabelElement.id}`)).nativeElement;
106+
expect(tabContentElement.classList.contains('md-active')).toBe(true);
107+
}
108+
});
109+
110+
@Component({
111+
selector: 'test-app',
112+
template: `
113+
<md-tab-group class="tab-group" [selectedIndex]="selectedIndex">
114+
<md-tab>
115+
<template md-tab-label>Tab One</template>
116+
<template md-tab-content>Tab one content</template>
117+
</md-tab>
118+
<md-tab>
119+
<template md-tab-label>Tab Two</template>
120+
<template md-tab-content>Tab two content</template>
121+
</md-tab>
122+
<md-tab>
123+
<template md-tab-label>Tab Three</template>
124+
<template md-tab-content>Tab three content</template>
125+
</md-tab>
126+
</md-tab-group>
127+
`,
128+
directives: [MD_TAB_GROUP_DIRECTIVES]
129+
})
130+
class SimpleTabsTestApp {
131+
selectedIndex: number = 1;
132+
}

src/components/tab-group/tab-group.ts

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {Component, Input, ViewChildren, NgZone} from '@angular/core';
2+
import {QueryList} from '@angular/core';
3+
import {ContentChildren} from '@angular/core';
4+
import {PortalHostDirective} from '../../core/portal/portal-directives';
5+
import {MdTabLabel} from './tab-label';
6+
import {MdTabContent} from './tab-content';
7+
import {MdTabLabelWrapper} from './tab-label-wrapper';
8+
import {MdInkBar} from './ink-bar';
9+
10+
/** Used to generate unique ID's for each tab component */
11+
let nextId = 0;
12+
13+
/**
14+
* Material design tab-group component. Supports basic tab pairs (label + content) and includes
15+
* animated ink-bar, keyboard navigation, and screen reader.
16+
* See: https://www.google.com/design/spec/components/tabs.html
17+
*/
18+
@Component({
19+
selector: 'md-tab-group',
20+
templateUrl: './components/tab-group/tab-group.html',
21+
styleUrls: ['./components/tab-group/tab-group.css'],
22+
directives: [PortalHostDirective, MdTabLabelWrapper, MdInkBar],
23+
})
24+
export class MdTabGroup {
25+
/** @internal */
26+
@ContentChildren(MdTabLabel) labels: QueryList<MdTabLabel>;
27+
28+
/** @internal */
29+
@ContentChildren(MdTabContent) contents: QueryList<MdTabContent>;
30+
31+
@ViewChildren(MdTabLabelWrapper) private _labelWrappers: QueryList<MdTabLabelWrapper>;
32+
@ViewChildren(MdInkBar) private _inkBar: QueryList<MdInkBar>;
33+
34+
@Input() selectedIndex: number = 0;
35+
36+
private _focusIndex: number = 0;
37+
private _groupId: number;
38+
39+
constructor(private _zone: NgZone) {
40+
this._groupId = nextId++;
41+
}
42+
43+
/**
44+
* Waits one frame for the view to update, then upates the ink bar
45+
* Note: This must be run outside of the zone or it will create an infinite change detection loop
46+
* @internal
47+
*/
48+
ngAfterViewChecked(): void {
49+
this._zone.runOutsideAngular(() => {
50+
window.requestAnimationFrame(() => {
51+
this._updateInkBar();
52+
});
53+
});
54+
}
55+
56+
/** Tells the ink-bar to align itself to the current label wrapper */
57+
private _updateInkBar(): void {
58+
this._inkBar.toArray()[0].alignToElement(this._currentLabelWrapper);
59+
}
60+
61+
/**
62+
* Reference to the current label wrapper; defaults to null for initial render before the
63+
* ViewChildren references are ready.
64+
*/
65+
private get _currentLabelWrapper(): HTMLElement {
66+
return this._labelWrappers
67+
? this._labelWrappers.toArray()[this.selectedIndex].elementRef.nativeElement
68+
: null;
69+
}
70+
71+
/** Tracks which element has focus; used for keyboard navigation */
72+
get focusIndex(): number {
73+
return this._focusIndex;
74+
}
75+
76+
/** When the focus index is set, we must manually send focus to the correct label */
77+
set focusIndex(value: number) {
78+
this._focusIndex = value;
79+
if (this._labelWrappers && this._labelWrappers.length) {
80+
this._labelWrappers.toArray()[value].focus();
81+
}
82+
}
83+
84+
/**
85+
* Returns a unique id for each tab label element
86+
* @internal
87+
*/
88+
getTabLabelId(i: number): string {
89+
return `md-tab-label-${this._groupId}-${i}`;
90+
}
91+
92+
/**
93+
* Returns a unique id for each tab content element
94+
* @internal
95+
*/
96+
getTabContentId(i: number): string {
97+
return `md-tab-content-${this._groupId}-${i}`;
98+
}
99+
100+
/** Increment the focus index by 1; prevent going over the number of tabs */
101+
focusNextTab(): void {
102+
if (this._labelWrappers && this.focusIndex < this._labelWrappers.length - 1) {
103+
this.focusIndex++;
104+
}
105+
}
106+
107+
/** Decrement the focus index by 1; prevent going below 0 */
108+
focusPreviousTab(): void {
109+
if (this.focusIndex > 0) {
110+
this.focusIndex--;
111+
}
112+
}
113+
}
114+
115+
export const MD_TAB_GROUP_DIRECTIVES = [MdTabGroup, MdTabLabel, MdTabContent];

0 commit comments

Comments
 (0)