From d886e7fad8481134ef30758e482f742bfa84ca88 Mon Sep 17 00:00:00 2001 From: Ivey Padgett Date: Thu, 11 Aug 2016 17:45:46 -0700 Subject: [PATCH 1/4] Add two way binding support for slider --- src/components/slider/slider.ts | 69 ++++++++++++++++++++++++++-- src/demo-app/demo-app-module.ts | 3 +- src/demo-app/slider/slider-demo.html | 32 ++++++------- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/components/slider/slider.ts b/src/components/slider/slider.ts index b6e18d9921af..536d21e08c27 100644 --- a/src/components/slider/slider.ts +++ b/src/components/slider/slider.ts @@ -6,7 +6,13 @@ import { Input, ViewEncapsulation, AfterContentInit, + forwardRef, } from '@angular/core'; +import { + NG_VALUE_ACCESSOR, + ControlValueAccessor, + FormsModule, +} from '@angular/forms'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value'; import {applyCssTransform} from '@angular2-material/core/style/apply-transform'; @@ -18,9 +24,20 @@ import {MdGestureConfig} from '@angular2-material/core/core'; */ const MIN_AUTO_TICK_SEPARATION = 30; +/** + * Provider Expression that allows md-slider to register as a ControlValueAccessor. + * This allows it to support [(ngModel)] and [formControl]. + */ +export const MD_SLIDER_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MdSlider), + multi: true +}; + @Component({ moduleId: module.id, selector: 'md-slider', + providers: [MD_SLIDER_VALUE_ACCESSOR], host: { 'tabindex': '0', '(click)': 'onClick($event)', @@ -34,7 +51,7 @@ const MIN_AUTO_TICK_SEPARATION = 30; styleUrls: ['slider.css'], encapsulation: ViewEncapsulation.None, }) -export class MdSlider implements AfterContentInit { +export class MdSlider implements AfterContentInit, ControlValueAccessor { /** A renderer to handle updating the slider's thumb and fill track. */ private _renderer: SliderRenderer = null; @@ -61,6 +78,11 @@ export class MdSlider implements AfterContentInit { /** The percentage of the slider that coincides with the value. */ private _percent: number = 0; + private _controlValueAccessorChangeFn: (value: any) => void = (value) => {}; + + /** onTouch function registered via registerOnTouch (ControlValueAccessor). */ + onTouched: () => any = () => {}; + /** The values at which the thumb will snap. */ @Input() step: number = 1; @@ -123,8 +145,15 @@ export class MdSlider implements AfterContentInit { } set value(v: number) { + // Only set the value to a valid number. v is casted to an any as we know it will come in as a + // string but it is labeled as a number which causes parseFloat to not accept it. + if (isNaN(parseFloat( v))) { + return; + } + this._value = Number(v); this._isInitialized = true; + this._controlValueAccessorChangeFn(this._value); } constructor(elementRef: ElementRef) { @@ -138,7 +167,8 @@ export class MdSlider implements AfterContentInit { */ ngAfterContentInit() { this._sliderDimensions = this._renderer.getSliderDimensions(); - this.snapToValue(); + this._controlValueAccessorChangeFn(this.value); + this.snapThumbToValue(); this._updateTickSeparation(); } @@ -152,7 +182,7 @@ export class MdSlider implements AfterContentInit { this.isSliding = false; this._renderer.addFocus(); this.updateValueFromPosition(event.clientX); - this.snapToValue(); + this.snapThumbToValue(); } /** TODO: internal */ @@ -182,7 +212,7 @@ export class MdSlider implements AfterContentInit { /** TODO: internal */ onSlideEnd() { this.isSliding = false; - this.snapToValue(); + this.snapThumbToValue(); } /** TODO: internal */ @@ -230,7 +260,7 @@ export class MdSlider implements AfterContentInit { * Snaps the thumb to the current value. * Called after a click or drag event is over. */ - snapToValue() { + snapThumbToValue() { this.updatePercentFromValue(); this._renderer.updateThumbAndFillPosition(this._percent, this._sliderDimensions.width); } @@ -315,6 +345,34 @@ export class MdSlider implements AfterContentInit { clamp(value: number, min = 0, max = 1) { return Math.max(min, Math.min(value, max)); } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: internal + */ + writeValue(value: any) { + this.value = value; + + if (this._sliderDimensions) { + this.snapThumbToValue(); + } + } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: internal + */ + registerOnChange(fn: (value: any) => void) { + this._controlValueAccessorChangeFn = fn; + } + + /** + * Implemented as part of ControlValueAccessor. + * TODO: internal + */ + registerOnTouched(fn: any) { + this.onTouched = fn; + } } /** @@ -392,6 +450,7 @@ export const MD_SLIDER_DIRECTIVES = [MdSlider]; @NgModule({ + imports: [FormsModule], exports: MD_SLIDER_DIRECTIVES, declarations: MD_SLIDER_DIRECTIVES, providers: [ diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 0f8742dbb327..7783bcb3926a 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -1,7 +1,7 @@ import {NgModule, ApplicationRef} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {HttpModule} from '@angular/http'; -import {FormsModule} from '@angular/forms'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import {DemoApp, Home} from './demo-app/demo-app'; import {DEMO_APP_ROUTE_PROVIDER} from './demo-app/routes'; import {RouterModule} from '@angular/router'; @@ -37,6 +37,7 @@ import {TabsDemo} from './tabs/tab-group-demo'; imports: [ BrowserModule, FormsModule, + ReactiveFormsModule, HttpModule, MaterialModule, RouterModule, diff --git a/src/demo-app/slider/slider-demo.html b/src/demo-app/slider/slider-demo.html index a03d64082a38..caa923afb276 100644 --- a/src/demo-app/slider/slider-demo.html +++ b/src/demo-app/slider/slider-demo.html @@ -1,31 +1,21 @@

Default Slider

-
- Label - {{slidey.value}} -
+Label +{{slidey.value}}

Slider with Min and Max

-
- - {{slider2.value}} -
+ +{{slider2.value}}

Disabled Slider

-
- - {{slider3.value}} -
+ +{{slider3.value}}

Slider with set value

-
- -
+

Slider with step defined

-
- - {{slider5.value}} -
+ +{{slider5.value}}

Slider with set tick interval

@@ -33,3 +23,7 @@

Slider with set tick interval

Slider with Thumb Label

+ +

Slider with two-way binding

+ + From 949772925af5886e0f727385a3a9e5d216dfdb48 Mon Sep 17 00:00:00 2001 From: Ivey Padgett Date: Fri, 12 Aug 2016 12:15:22 -0700 Subject: [PATCH 2/4] Beginning of tests for two way binding --- src/components/slider/slider.spec.ts | 52 +++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/components/slider/slider.spec.ts b/src/components/slider/slider.spec.ts index d87c58877200..170b32d0fe37 100644 --- a/src/components/slider/slider.spec.ts +++ b/src/components/slider/slider.spec.ts @@ -6,6 +6,7 @@ import { ComponentFixture, TestBed, } from '@angular/core/testing'; +import {ReactiveFormsModule, FormControl} from '@angular/forms'; import {Component, DebugElement, ViewEncapsulation} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdSlider, MdSliderModule} from './slider'; @@ -18,7 +19,7 @@ describe('MdSlider', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdSliderModule], + imports: [MdSliderModule, ReactiveFormsModule], declarations: [ StandardSlider, DisabledSlider, @@ -28,6 +29,7 @@ describe('MdSlider', () => { SliderWithAutoTickInterval, SliderWithSetTickInterval, SliderWithThumbLabel, + SliderWithTwoWayBinding, ], }); @@ -588,6 +590,47 @@ describe('MdSlider', () => { expect(sliderContainerElement.classList).toContain('md-slider-active'); }); }); + + describe('slider with two-way binding', () => { + let fixture: ComponentFixture; + let sliderDebugElement: DebugElement; + let sliderNativeElement: HTMLElement; + let sliderInstance: MdSlider; + let sliderTrackElement: HTMLElement; + let testComponent: SliderWithTwoWayBinding; + + beforeEach(async(() => { + builder.createAsync(SliderWithTwoWayBinding).then(f => { + fixture = f; + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + + sliderDebugElement = fixture.debugElement.query(By.directive(MdSlider)); + sliderNativeElement = sliderDebugElement.nativeElement; + sliderInstance = sliderDebugElement.injector.get(MdSlider); + sliderTrackElement = sliderNativeElement.querySelector('.md-slider-track'); + }); + })); + + it('should update the control when the value is updated', () => { + expect(testComponent.control.value).toBe(0); + + sliderInstance.value = 11; + fixture.detectChanges(); + + expect(testComponent.control.value).toBe(11); + }); + + it('should update the control on click', () => { + expect(testComponent.control.value).toBe(0); + + dispatchClickEvent(sliderTrackElement, 0.76); + fixture.detectChanges(); + + expect(testComponent.control.value).toBe(76); + }); + }); }); // The transition has to be removed in order to test the updated positions without setTimeout. @@ -655,6 +698,13 @@ class SliderWithSetTickInterval { } }) class SliderWithThumbLabel { } +@Component({ + template: `` +}) +class SliderWithTwoWayBinding { + control = new FormControl(''); +} + /** * Dispatches a click event from an element. * Note: The mouse event truncates the position for the click. From c85a0f6579c4a02c900d486448a0f9bb94e2b43f Mon Sep 17 00:00:00 2001 From: Ivey Padgett Date: Fri, 12 Aug 2016 16:44:29 -0700 Subject: [PATCH 3/4] Add more tests to two way binding on slider --- src/components/slider/slider.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/components/slider/slider.spec.ts b/src/components/slider/slider.spec.ts index 170b32d0fe37..8c22f3bf5fc5 100644 --- a/src/components/slider/slider.spec.ts +++ b/src/components/slider/slider.spec.ts @@ -630,6 +630,24 @@ describe('MdSlider', () => { expect(testComponent.control.value).toBe(76); }); + + it('should update the control on slide', () => { + expect(testComponent.control.value).toBe(0); + + dispatchSlideEvent(sliderTrackElement, sliderNativeElement, 0, 0.19, gestureConfig); + fixture.detectChanges(); + + expect(testComponent.control.value).toBe(19); + }); + + it('should update the value when the control is set', () => { + expect(sliderInstance.value).toBe(0); + + testComponent.control.setValue(7); + fixture.detectChanges(); + + expect(sliderInstance.value).toBe(7); + }) }); }); From 72691e21bbc10eea54f6c27adcb1a6ec8e9790ed Mon Sep 17 00:00:00 2001 From: Ivey Padgett Date: Fri, 12 Aug 2016 17:01:35 -0700 Subject: [PATCH 4/4] Comments and call onTouched --- src/components/slider/slider.spec.ts | 6 ++++-- src/components/slider/slider.ts | 9 ++++++--- src/demo-app/demo-app-module.ts | 3 +-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/slider/slider.spec.ts b/src/components/slider/slider.spec.ts index 8c22f3bf5fc5..75a15eb8a349 100644 --- a/src/components/slider/slider.spec.ts +++ b/src/components/slider/slider.spec.ts @@ -591,7 +591,7 @@ describe('MdSlider', () => { }); }); - describe('slider with two-way binding', () => { + describe('slider as a custom form control', () => { let fixture: ComponentFixture; let sliderDebugElement: DebugElement; let sliderNativeElement: HTMLElement; @@ -647,7 +647,9 @@ describe('MdSlider', () => { fixture.detectChanges(); expect(sliderInstance.value).toBe(7); - }) + }); + + // TODO: Add tests for ng-pristine, ng-touched, ng-invalid. }); }); diff --git a/src/components/slider/slider.ts b/src/components/slider/slider.ts index 536d21e08c27..8027f1584bb3 100644 --- a/src/components/slider/slider.ts +++ b/src/components/slider/slider.ts @@ -9,9 +9,9 @@ import { forwardRef, } from '@angular/core'; import { - NG_VALUE_ACCESSOR, - ControlValueAccessor, - FormsModule, + NG_VALUE_ACCESSOR, + ControlValueAccessor, + FormsModule, } from '@angular/forms'; import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser'; import {BooleanFieldValue} from '@angular2-material/core/annotations/field-value'; @@ -167,6 +167,8 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { */ ngAfterContentInit() { this._sliderDimensions = this._renderer.getSliderDimensions(); + // This needs to be called after content init because the value can be set to the min if the + // value itself isn't set. If this happens, the control value accessor needs to be updated. this._controlValueAccessorChangeFn(this.value); this.snapThumbToValue(); this._updateTickSeparation(); @@ -226,6 +228,7 @@ export class MdSlider implements AfterContentInit, ControlValueAccessor { /** TODO: internal */ onBlur() { this.isActive = false; + this.onTouched(); } /** diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 7783bcb3926a..0f8742dbb327 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -1,7 +1,7 @@ import {NgModule, ApplicationRef} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {HttpModule} from '@angular/http'; -import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {FormsModule} from '@angular/forms'; import {DemoApp, Home} from './demo-app/demo-app'; import {DEMO_APP_ROUTE_PROVIDER} from './demo-app/routes'; import {RouterModule} from '@angular/router'; @@ -37,7 +37,6 @@ import {TabsDemo} from './tabs/tab-group-demo'; imports: [ BrowserModule, FormsModule, - ReactiveFormsModule, HttpModule, MaterialModule, RouterModule,