-
Notifications
You must be signed in to change notification settings - Fork 6.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(tabs): adds the
md-tab-group
component (#376)
- Loading branch information
1 parent
f22fa86
commit ada285c
Showing
14 changed files
with
473 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Sorry, something went wrong.
This comment has been minimized.
Sorry, something went wrong.
robertmesserle
via email
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; |
Oops, something went wrong.
@robertmesserle Is this temporary? Content of tab can not be re-sized because of this.