From 9a90eaf97da850d0c889c488dc86e20325795649 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 26 Jul 2017 02:23:58 +0300 Subject: [PATCH] feat(select): support basic usage without @angular/forms (#5871) * feat(select): support basic usage without @angular/forms Currently `md-select` can only really be used together with `@angular/forms` which is overkill for simple usages where it only sets a value (for example, the only reason the paginator module brings in the `FormsModule` is the select). These changes introduce the `value` two-way binding that can be used to read/write the value without using `ngModel` or a `formControl`. This also aligns it with the input module. Relates to #5717. * chore: add demo --- src/demo-app/select/select-demo.html | 16 +++ src/demo-app/select/select-demo.ts | 10 ++ src/lib/select/select.spec.ts | 198 ++++++++++++++++++++++++++- src/lib/select/select.ts | 28 +++- 4 files changed, 245 insertions(+), 7 deletions(-) diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html index fbb317a33f89..e5444b1b66e7 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -65,6 +65,22 @@ + + Without Angular forms + + + None + + {{ creature.viewValue }} + + + +

Value: {{ currentDigimon }}

+ + + +
+ Option groups diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts index 6318b2a6272c..9a35b612e1c2 100644 --- a/src/demo-app/select/select-demo.ts +++ b/src/demo-app/select/select-demo.ts @@ -17,6 +17,7 @@ export class SelectDemo { currentDrink: string; currentPokemon: string[]; currentPokemonFromGroup: string; + currentDigimon: string; latestChangeEvent: MdSelectChange; floatPlaceholder: string = 'auto'; foodControl = new FormControl('pizza-1'); @@ -94,6 +95,15 @@ export class SelectDemo { } ]; + digimon = [ + { value: 'mihiramon-0', viewValue: 'Mihiramon' }, + { value: 'sandiramon-1', viewValue: 'Sandiramon' }, + { value: 'sinduramon-2', viewValue: 'Sinduramon' }, + { value: 'pajiramon-3', viewValue: 'Pajiramon' }, + { value: 'vajiramon-4', viewValue: 'Vajiramon' }, + { value: 'indramon-5', viewValue: 'Indramon' } + ]; + toggleDisabled() { this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable(); } diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 01c09e97ca10..ef8bd567ad59 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -63,7 +63,10 @@ describe('MdSelect', () => { ResetValuesSelect, FalsyValueSelect, SelectWithGroups, - InvalidSelectInForm + InvalidSelectInForm, + BasicSelectWithoutForms, + BasicSelectWithoutFormsPreselected, + BasicSelectWithoutFormsMultiple ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -706,6 +709,138 @@ describe('MdSelect', () => { }); + describe('selection without Angular forms', () => { + it('should set the value when options are clicked', () => { + const fixture = TestBed.createComponent(BasicSelectWithoutForms); + + fixture.detectChanges(); + expect(fixture.componentInstance.selectedFood).toBeFalsy(); + + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + + trigger.click(); + fixture.detectChanges(); + + (overlayContainerElement.querySelector('md-option') as HTMLElement).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFood).toBe('steak-0'); + expect(fixture.componentInstance.select.value).toBe('steak-0'); + expect(trigger.textContent).toContain('Steak'); + + trigger.click(); + fixture.detectChanges(); + + (overlayContainerElement.querySelectorAll('md-option')[2] as HTMLElement).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFood).toBe('sandwich-2'); + expect(fixture.componentInstance.select.value).toBe('sandwich-2'); + expect(trigger.textContent).toContain('Sandwich'); + }); + + it('should mark options as selected when the value is set', () => { + const fixture = TestBed.createComponent(BasicSelectWithoutForms); + + fixture.detectChanges(); + fixture.componentInstance.selectedFood = 'sandwich-2'; + fixture.detectChanges(); + + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + expect(trigger.textContent).toContain('Sandwich'); + + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelectorAll('md-option')[2]; + + expect(option.classList).toContain('mat-selected'); + expect(fixture.componentInstance.select.value).toBe('sandwich-2'); + }); + + it('should reset the placeholder when a null value is set', () => { + const fixture = TestBed.createComponent(BasicSelectWithoutForms); + + fixture.detectChanges(); + expect(fixture.componentInstance.selectedFood).toBeFalsy(); + + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + + trigger.click(); + fixture.detectChanges(); + + (overlayContainerElement.querySelector('md-option') as HTMLElement).click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFood).toBe('steak-0'); + expect(fixture.componentInstance.select.value).toBe('steak-0'); + expect(trigger.textContent).toContain('Steak'); + + fixture.componentInstance.selectedFood = null; + fixture.detectChanges(); + + expect(fixture.componentInstance.select.value).toBeNull(); + expect(trigger.textContent).not.toContain('Steak'); + }); + + it('should reflect the preselected value', async(() => { + const fixture = TestBed.createComponent(BasicSelectWithoutFormsPreselected); + + fixture.detectChanges(); + fixture.whenStable().then(() => { + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + + fixture.detectChanges(); + expect(trigger.textContent).toContain('Pizza'); + + trigger.click(); + fixture.detectChanges(); + + const option = overlayContainerElement.querySelectorAll('md-option')[1]; + + expect(option.classList).toContain('mat-selected'); + expect(fixture.componentInstance.select.value).toBe('pizza-1'); + }); + })); + + it('should be able to select multiple values', () => { + const fixture = TestBed.createComponent(BasicSelectWithoutFormsMultiple); + + fixture.detectChanges(); + expect(fixture.componentInstance.selectedFoods).toBeFalsy(); + + const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + + trigger.click(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + + options[0].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0']); + expect(fixture.componentInstance.select.value).toEqual(['steak-0']); + expect(trigger.textContent).toContain('Steak'); + + options[2].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0', 'sandwich-2']); + expect(fixture.componentInstance.select.value).toEqual(['steak-0', 'sandwich-2']); + expect(trigger.textContent).toContain('Steak, Sandwich'); + + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0', 'pizza-1', 'sandwich-2']); + expect(fixture.componentInstance.select.value).toEqual(['steak-0', 'pizza-1', 'sandwich-2']); + expect(trigger.textContent).toContain('Steak, Pizza, Sandwich'); + }); + + }); + describe('disabled behavior', () => { it('should disable itself when control is disabled programmatically', () => { @@ -2361,7 +2496,6 @@ describe('MdSelect', () => { }); - describe('reset values', () => { let fixture: ComponentFixture; let trigger: HTMLElement; @@ -2892,3 +3026,63 @@ class SelectWithGroups { class InvalidSelectInForm { value: any; } + + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class BasicSelectWithoutForms { + selectedFood: string | null; + foods: any[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + { value: 'sandwich-2', viewValue: 'Sandwich' }, + ]; + + @ViewChild(MdSelect) select: MdSelect; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class BasicSelectWithoutFormsPreselected { + selectedFood = 'pizza-1'; + foods: any[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + ]; + + @ViewChild(MdSelect) select: MdSelect; +} + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class BasicSelectWithoutFormsMultiple { + selectedFoods: string[]; + foods: any[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + { value: 'sandwich-2', viewValue: 'Sandwich' }, + ]; + + @ViewChild(MdSelect) select: MdSelect; +} diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 37173b0eab71..9bd7ff685fed 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -325,6 +325,15 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On } } + /** Value of the select control. */ + @Input() + get value() { return this._value; } + set value(newValue: any) { + this.writeValue(newValue); + this._value = newValue; + } + private _value: any; + /** Aria label of the select. If not specified, the placeholder will be used as label. */ @Input('aria-label') ariaLabel: string = ''; @@ -345,6 +354,13 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On /** Event emitted when the selected value has been changed by the user. */ @Output() change: EventEmitter = new EventEmitter(); + /** + * 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. + * @docs-private + */ + @Output() valueChange = new EventEmitter(); + constructor( private _viewportRuler: ViewportRuler, private _changeDetectorRef: ChangeDetectorRef, @@ -377,11 +393,11 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => { this._resetOptions(); - if (this._control) { - // Defer setting the value in order to avoid the "Expression - // has changed after it was checked" errors from Angular. - Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value)); - } + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + Promise.resolve().then(() => { + this._setSelectionByValue(this._control ? this._control.value : this._value); + }); }); } @@ -750,8 +766,10 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On valueToEmit = this.selected ? this.selected.value : fallbackValue; } + this._value = valueToEmit; this._onChange(valueToEmit); this.change.emit(new MdSelectChange(this, valueToEmit)); + this.valueChange.emit(valueToEmit); } /** Records option IDs to pass to the aria-owns property. */