From 66503a98402bb8968b1cab968d2a91dfa61d0656 Mon Sep 17 00:00:00 2001 From: Ed Morales Date: Tue, 10 Jul 2018 14:08:22 -0700 Subject: [PATCH] feat(tab-select): initial implementation for tab select *experimental* (#1187) * feat(tab-select): initial implementation for tab select *experimental* first pass on tab select component and will be treated as experimental while we use it in multiple products to ensure quality * docs(tab-select): make backgroundColor optional in README * chore(tab-select): add initial set of unit tests * feat(): add disabled ripple on tabs for test-bed --- src/platform/experimental/package.json | 8 +- src/platform/experimental/public-api.ts | 1 + .../experimental/tab-select/README.md | 90 +++++++++ src/platform/experimental/tab-select/index.ts | 1 + .../experimental/tab-select/package.json | 7 + .../experimental/tab-select/public-api.ts | 3 + .../tab-select/tab-option.component.html | 3 + .../tab-select/tab-option.component.scss | 0 .../tab-select/tab-option.component.ts | 54 +++++ .../tab-select/tab-select.component.html | 16 ++ .../tab-select/tab-select.component.scss | 0 .../tab-select/tab-select.component.spec.ts | 185 ++++++++++++++++++ .../tab-select/tab-select.component.ts | 161 +++++++++++++++ .../tab-select/tab-select.module.ts | 30 +++ src/test-bed/test-bed.module.ts | 3 + src/test-bed/test-bed/test-bed.component.html | 71 ++++++- src/test-bed/test-bed/test-bed.component.ts | 3 + 17 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 src/platform/experimental/tab-select/README.md create mode 100644 src/platform/experimental/tab-select/index.ts create mode 100644 src/platform/experimental/tab-select/package.json create mode 100644 src/platform/experimental/tab-select/public-api.ts create mode 100644 src/platform/experimental/tab-select/tab-option.component.html create mode 100644 src/platform/experimental/tab-select/tab-option.component.scss create mode 100644 src/platform/experimental/tab-select/tab-option.component.ts create mode 100644 src/platform/experimental/tab-select/tab-select.component.html create mode 100644 src/platform/experimental/tab-select/tab-select.component.scss create mode 100644 src/platform/experimental/tab-select/tab-select.component.spec.ts create mode 100644 src/platform/experimental/tab-select/tab-select.component.ts create mode 100644 src/platform/experimental/tab-select/tab-select.module.ts diff --git a/src/platform/experimental/package.json b/src/platform/experimental/package.json index 7c9cee5731..7b6f0992d9 100644 --- a/src/platform/experimental/package.json +++ b/src/platform/experimental/package.json @@ -12,5 +12,11 @@ "Steven Ov ", "Jenn Medellin ", "Julie Knowles " - ] + ], + "peerDependencies": { + "@angular/common": "^0.0.0-NG", + "@angular/core": "^0.0.0-NG", + "@angular/cdk": "^0.0.0-MATERIAL", + "@angular/material": "^0.0.0-MATERIAL" + } } diff --git a/src/platform/experimental/public-api.ts b/src/platform/experimental/public-api.ts index dd69357be5..1012583450 100644 --- a/src/platform/experimental/public-api.ts +++ b/src/platform/experimental/public-api.ts @@ -1 +1,2 @@ export * from './template-rename-me-experiment-module/index'; +export * from './tab-select/index'; diff --git a/src/platform/experimental/tab-select/README.md b/src/platform/experimental/tab-select/README.md new file mode 100644 index 0000000000..0daf640b33 --- /dev/null +++ b/src/platform/experimental/tab-select/README.md @@ -0,0 +1,90 @@ +# td-tab-select (experimental) + +`td-tab-select` element generates a tab group component that behaves like a `mat-select`. + +## API Summary + +#### Inputs + ++ value?: any + + Sets the value of the component. ++ disabled?: boolean + + Sets disabled state of the component. ++ disabledRipple?: boolean + + Disables ripple effect on component. ++ color?: ThemePalette + + Color of the tab group. ++ backgroundColor?: ThemePalette + + Background color of the tab group. + +#### Events + ++ valueChange: function(value: any) + + Event that emits whenever the raw value of the select changes. + + This is here primarily to facilitate the two-way binding for the `value` input. + +# td-tab-option + +`td-tab-option` element generates a tab component to which a value can be binded to. + +## API Summary + +#### Inputs + ++ value?: any + + Bind a value to the component. ++ disabled?: boolean + + Sets disabled state of the component. + +## Setup + +Import the [CovalentTabSelectModule] in your NgModule: + +```typescript +import { CovalentTabSelectModule } from '@covalent/experimental/tab-select'; +@NgModule({ + imports: [ + CovalentTabSelectModule, + ... + ], + ... +}) +export class MyModule {} +``` + +## Usage + +Example without forms: + +```html + + Label 1 + Label 2 + Label 3 + +``` + +Example with forms: + +```html + + Label 1 + Label 2 + Label 3 + +``` + +Example with all inputs/outputs: + +```html + + Label 1 + Label 2 + Label 3 + +``` diff --git a/src/platform/experimental/tab-select/index.ts b/src/platform/experimental/tab-select/index.ts new file mode 100644 index 0000000000..7e1a213e3e --- /dev/null +++ b/src/platform/experimental/tab-select/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/src/platform/experimental/tab-select/package.json b/src/platform/experimental/tab-select/package.json new file mode 100644 index 0000000000..dedb72ce9c --- /dev/null +++ b/src/platform/experimental/tab-select/package.json @@ -0,0 +1,7 @@ +{ + "ngPackage": { + "lib": { + "entryFile": "index.ts" + } + } +} diff --git a/src/platform/experimental/tab-select/public-api.ts b/src/platform/experimental/tab-select/public-api.ts new file mode 100644 index 0000000000..d9150a82e3 --- /dev/null +++ b/src/platform/experimental/tab-select/public-api.ts @@ -0,0 +1,3 @@ +export * from './tab-select.module'; +export * from './tab-select.component'; +export * from './tab-option.component'; diff --git a/src/platform/experimental/tab-select/tab-option.component.html b/src/platform/experimental/tab-select/tab-option.component.html new file mode 100644 index 0000000000..a4bd3d8f63 --- /dev/null +++ b/src/platform/experimental/tab-select/tab-option.component.html @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/experimental/tab-select/tab-option.component.scss b/src/platform/experimental/tab-select/tab-option.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/platform/experimental/tab-select/tab-option.component.ts b/src/platform/experimental/tab-select/tab-option.component.ts new file mode 100644 index 0000000000..d24c39bf3c --- /dev/null +++ b/src/platform/experimental/tab-select/tab-option.component.ts @@ -0,0 +1,54 @@ +import { + Component, + Input, + ChangeDetectionStrategy, + ChangeDetectorRef, + ViewChild, + TemplateRef, + OnInit, + ViewContainerRef, +} from '@angular/core'; + +import { TemplatePortal } from '@angular/cdk/portal'; +import { mixinDisabled, ICanDisable } from '@covalent/core/common'; + +export class TdTabOptionBase { + constructor(public _viewContainerRef: ViewContainerRef, + public _changeDetectorRef: ChangeDetectorRef) {} +} + +/* tslint:disable-next-line */ +export const _TdTabOptionMixinBase = mixinDisabled(TdTabOptionBase); + +@Component({ + selector: 'td-tab-option', + templateUrl: './tab-option.component.html', + styleUrls: ['./tab-option.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + /* tslint:disable-next-line */ + inputs: ['disabled'], +}) +export class TdTabOptionComponent extends _TdTabOptionMixinBase implements ICanDisable, OnInit { + + private _contentPortal: TemplatePortal; + get content(): TemplatePortal { + return this._contentPortal; + } + + @ViewChild(TemplateRef) _content: TemplateRef; + + /** + * Value to which the option will be binded to. + */ + @Input('value') value: any; + + constructor(_viewContainerRef: ViewContainerRef, + _changeDetectorRef: ChangeDetectorRef) { + super(_viewContainerRef, _changeDetectorRef); + } + + ngOnInit(): void { + this._contentPortal = new TemplatePortal(this._content, this._viewContainerRef); + } + +} diff --git a/src/platform/experimental/tab-select/tab-select.component.html b/src/platform/experimental/tab-select/tab-select.component.html new file mode 100644 index 0000000000..1010ec245b --- /dev/null +++ b/src/platform/experimental/tab-select/tab-select.component.html @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/src/platform/experimental/tab-select/tab-select.component.scss b/src/platform/experimental/tab-select/tab-select.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/platform/experimental/tab-select/tab-select.component.spec.ts b/src/platform/experimental/tab-select/tab-select.component.spec.ts new file mode 100644 index 0000000000..b3f6038d5a --- /dev/null +++ b/src/platform/experimental/tab-select/tab-select.component.spec.ts @@ -0,0 +1,185 @@ +import { + TestBed, + inject, + async, + ComponentFixture, +} from '@angular/core/testing'; +import { + Component, + DebugElement, +} from '@angular/core'; +import { + FormsModule, +} from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { + CovalentTabSelectModule, +} from './public-api'; + +describe('Component: TabSelect', () => { + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + TdTabSelectBasicTestComponent, + TdTabSelectFormsTestComponent, + TdTabSelectDynamicTestComponent, + ], + imports: [ + NoopAnimationsModule, + FormsModule, + CovalentTabSelectModule, + ], + }); + TestBed.compileComponents(); + })); + + it('should render tab select with all tabs disabled', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectBasicTestComponent); + let component: TdTabSelectBasicTestComponent = fixture.debugElement.componentInstance; + component.disabled = true; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.componentInstance.value).toBe(undefined); + expect(fixture.debugElement.queryAll(By.css('.mat-tab-disabled')).length).toBe(3); + }); + }), + )); + + it('should render tab select with all options and click on second option to activate it', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectBasicTestComponent); + let component: TdTabSelectBasicTestComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label')).length).toBe(3); + fixture.debugElement.queryAll(By.css('.mat-tab-label'))[1].nativeElement.click(); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[1].nativeElement.className).toContain('mat-tab-label-active'); + }); + }); + }), + )); + + it('should render tab select with all options with the second option active', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectBasicTestComponent); + let component: TdTabSelectBasicTestComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.componentInstance.value).toBe(undefined); + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label')).length).toBe(3); + fixture.componentInstance.value = 2; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[1].nativeElement.className).toContain('mat-tab-label-active'); + }); + }); + }), + )); + + it('should render tab select with first option active and then switch to 3rd option (value)', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectBasicTestComponent); + let component: TdTabSelectBasicTestComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.componentInstance.value).toBe(undefined); + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label')).length).toBe(3); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[0].nativeElement.className).toContain('mat-tab-label-active'); + fixture.componentInstance.value = 3; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[2].nativeElement.className).toContain('mat-tab-label-active'); + }); + }); + }); + }), + )); + + it('should render tab select with first option active and then switch to 3rd option (ngModel)', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectFormsTestComponent); + let component: TdTabSelectFormsTestComponent = fixture.debugElement.componentInstance; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.componentInstance.value).toBe(1); + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label')).length).toBe(3); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[0].nativeElement.className).toContain('mat-tab-label-active'); + fixture.componentInstance.value = 3; + fixture.detectChanges(); + fixture.whenStable().then(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label'))[2].nativeElement.className).toContain('mat-tab-label-active'); + }); + }); + }); + }); + }), + )); + + it('should render dynamic tab options from an ngFor loop', + async(inject([], () => { + let fixture: ComponentFixture = TestBed.createComponent(TdTabSelectDynamicTestComponent); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(fixture.componentInstance.value).toBe(undefined); + expect(fixture.debugElement.queryAll(By.css('.mat-tab-label')).length).toBe(3); + + let tabNo: number = 1; + fixture.debugElement.queryAll(By.css('.mat-tab-label .mat-tab-label-content')).forEach((element: DebugElement) => { + expect((element.nativeElement).innerHTML).toContain('Option ' + tabNo++); + }); + }); + }))); +}); + +@Component({ + selector: 'td-tab-select-basic-test', + template: ` + + Option 1 + Option 2 + Option 3 + + `, +}) +class TdTabSelectBasicTestComponent { + disabled: boolean = false; + value: any; +} + +@Component({ + selector: 'td-tab-forms-basic-test', + template: ` + + Option 1 + Option 2 + Option 3 + + `, +}) +class TdTabSelectFormsTestComponent { + value: any; +} + +@Component({ + selector: 'td-tab-select-dynamic-test', + template: ` + + {{option.label}} + + `, +}) +class TdTabSelectDynamicTestComponent { + options: any[] = [{label: 'Option 1', value: 1}, {label: 'Option 2', value: 2}, {label: 'Option 3', value: 3}]; + value: any; +} diff --git a/src/platform/experimental/tab-select/tab-select.component.ts b/src/platform/experimental/tab-select/tab-select.component.ts new file mode 100644 index 0000000000..8de5dc7f75 --- /dev/null +++ b/src/platform/experimental/tab-select/tab-select.component.ts @@ -0,0 +1,161 @@ +import { + Component, + Input, + ChangeDetectionStrategy, + ChangeDetectorRef, + ContentChildren, + QueryList, + OnInit, + AfterContentInit, + forwardRef, + Output, + EventEmitter, + OnDestroy, +} from '@angular/core'; +import { + NG_VALUE_ACCESSOR, +} from '@angular/forms'; + +import { ThemePalette } from '@angular/material/core'; + +import { ICanDisable, + mixinDisabled, + IControlValueAccessor, + mixinControlValueAccessor, + ICanDisableRipple, + mixinDisableRipple, +} from '@covalent/core/common'; + +import { Subscription } from 'rxjs'; + +import { TdTabOptionComponent } from './tab-option.component'; + +export class TdTabSelectBase { + constructor(public _changeDetectorRef: ChangeDetectorRef) {} +} + +/* tslint:disable-next-line */ +export const _TdTabSelectMixinBase = mixinControlValueAccessor(mixinDisabled(mixinDisableRipple(TdTabSelectBase))); + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TdTabSelectComponent), + multi: true, + }], + selector: 'td-tab-select', + templateUrl: './tab-select.component.html', + styleUrls: ['./tab-select.component.scss'], + /* tslint:disable-next-line */ + inputs: ['value', 'disabled', 'disableRipple'], +}) +export class TdTabSelectComponent extends _TdTabSelectMixinBase + implements IControlValueAccessor, ICanDisable, ICanDisableRipple, OnInit, AfterContentInit, OnDestroy { + + private _subs: Subscription[] = []; + + private _values: any[] = []; + private _selectedIndex: number = 0; + + get selectedIndex(): number { + return this._selectedIndex; + } + + /** + * Gets all tab option children + */ + @ContentChildren(TdTabOptionComponent) readonly _tabOptions: QueryList; + + get tabOptions(): TdTabOptionComponent[] { + return this._tabOptions ? this._tabOptions.toArray() : undefined; + } + + /** + * Color of the tab group. + */ + @Input('color') color: ThemePalette; + + /** + * Background color of the tab group. + */ + @Input('backgroundColor') backgroundColor: ThemePalette; + + /** + * Event that emits whenever the raw value of the select changes. This is here primarily + * to facilitate the two-way binding for the `value` input. + */ + @Output() readonly valueChange: EventEmitter = new EventEmitter(); + + constructor(_changeDetectorRef: ChangeDetectorRef) { + super(_changeDetectorRef); + } + + ngOnInit(): void { + // subscribe to check if value changes and update the selectedIndex internally. + this._subs.push( + this.valueChanges.subscribe((value: any) => { + this._setValue(value); + }), + ); + } + + ngAfterContentInit(): void { + // subscribe to listen to any tab changes. + this._refreshValues(); + this._subs.push( + this._tabOptions.changes.subscribe(() => { + this._refreshValues(); + }), + ); + // initialize value + Promise.resolve().then(() => { + this._setValue(this.value); + }); + } + + ngOnDestroy(): void { + if (this._subs && this._subs.length) { + this._subs.forEach((sub: Subscription) => { + sub.unsubscribe(); + }); + } + } + + /** + * Method executed when user selects a different tab + * This updates the new selectedIndex and infers what value should be mapped to. + */ + selectedIndexChange(selectedIndex: number): void { + this._selectedIndex = selectedIndex; + let value: any = this._values[selectedIndex]; + this.value = value; + this.valueChange.emit(value); + this.onChange(value); + } + + /** + * Refresh the values array whenever the number of tabs gets updated + */ + private _refreshValues(): void { + this._values = this.tabOptions.map((tabOption: TdTabOptionComponent) => { + return tabOption.value; + }); + } + + /** + * Try to set value depending if its part of our options + * else set the value of the first tab. + */ + private _setValue(value: any): void { + let index: number = this._values.indexOf(value); + if (index > -1) { + this._selectedIndex = index; + } else { + this.value = this._values.length ? this._values[0] : undefined; + this._selectedIndex = 0; + } + this._changeDetectorRef.markForCheck(); + } + +} diff --git a/src/platform/experimental/tab-select/tab-select.module.ts b/src/platform/experimental/tab-select/tab-select.module.ts new file mode 100644 index 0000000000..37f4eb862d --- /dev/null +++ b/src/platform/experimental/tab-select/tab-select.module.ts @@ -0,0 +1,30 @@ +import { NgModule } from '@angular/core'; + +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; + +import { PortalModule } from '@angular/cdk/portal'; +import { MatTabsModule } from '@angular/material/tabs'; + +import { TdTabSelectComponent } from './tab-select.component'; +import { TdTabOptionComponent } from './tab-option.component'; + +@NgModule({ + declarations: [ + TdTabSelectComponent, + TdTabOptionComponent, + ], // directives, components, and pipes owned by this NgModule + imports: [ + /** Angular Modules */ + CommonModule, + FormsModule, + /** Material Modules */ + PortalModule, + MatTabsModule, + ], // modules needed to run this module + exports: [ + TdTabSelectComponent, + TdTabOptionComponent, + ], +}) +export class CovalentTabSelectModule {} diff --git a/src/test-bed/test-bed.module.ts b/src/test-bed/test-bed.module.ts index 2da2a6a0b1..b046320ed1 100644 --- a/src/test-bed/test-bed.module.ts +++ b/src/test-bed/test-bed.module.ts @@ -6,6 +6,8 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TestBedComponent } from './test-bed/test-bed.component'; +import { CovalentTabSelectModule } from '../platform/experimental/tab-select'; + @NgModule({ declarations: [TestBedComponent], imports: [ @@ -14,6 +16,7 @@ import { TestBedComponent } from './test-bed/test-bed.component'; BrowserAnimationsModule, FormsModule, /** Experimental Modules */ + CovalentTabSelectModule, ], bootstrap: [TestBedComponent], }) diff --git a/src/test-bed/test-bed/test-bed.component.html b/src/test-bed/test-bed/test-bed.component.html index 710493b9e7..acbb38ccd1 100644 --- a/src/test-bed/test-bed/test-bed.component.html +++ b/src/test-bed/test-bed/test-bed.component.html @@ -1 +1,70 @@ -

Test Bed

+
+

Tab Select

+

Default with accent color with a disabled tab

+ + + MY LABEL + + + MY LABEL 2 + + + MY LABEL 3 + + +

value: {{tabSelect.value}}

+ +

Value usage with primary backgroundColor

+ + + MY LABEL + + + MY LABEL 2 + + + MY LABEL 3 + + +

value: {{value}}

+ +

Forms Usage

+ + + MY LABEL + + + MY LABEL 2 + + + MY LABEL 3 + + +

value: {{formValue}}

+ +

Disabled tabs

+ + + MY LABEL + + + MY LABEL 2 + + + MY LABEL 3 + + + +

Disabled ripple on tabs

+ + + MY LABEL + + + MY LABEL 2 + + + MY LABEL 3 + + +
\ No newline at end of file diff --git a/src/test-bed/test-bed/test-bed.component.ts b/src/test-bed/test-bed/test-bed.component.ts index c79d0c5d59..03792bfc5f 100644 --- a/src/test-bed/test-bed/test-bed.component.ts +++ b/src/test-bed/test-bed/test-bed.component.ts @@ -7,4 +7,7 @@ import { Component } from '@angular/core'; }) export class TestBedComponent { + value: string = 'test3'; + formValue: string = 'test2'; + }