-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does |
||
} | ||
|
||
/** | ||
* Generates the pixel width from the provided element in string format. | ||
* @param element | ||
* @returns {string} | ||
*/ | ||
private _getElementWidth(element: HTMLElement): string { | ||
return element.offsetWidth + 'px'; | ||
} | ||
} |
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({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
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> |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} |
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)')); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It will probably be much cleaner to do the |
||
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; | ||
} |
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(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add explanation of why you're doing the |
||
window.requestAnimationFrame(() => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It should work to just say |
||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: