Skip to content

Commit 9a90eaf

Browse files
crisbetoandrewseguin
authored andcommitted
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
1 parent ebb5e9e commit 9a90eaf

File tree

4 files changed

+245
-7
lines changed

4 files changed

+245
-7
lines changed

src/demo-app/select/select-demo.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,22 @@
6565
</md-card-content>
6666
</md-card>
6767

68+
<md-card>
69+
<md-card-subtitle>Without Angular forms</md-card-subtitle>
70+
71+
<md-select placeholder="Digimon" [(value)]="currentDigimon">
72+
<md-option>None</md-option>
73+
<md-option *ngFor="let creature of digimon" [value]="creature.value">
74+
{{ creature.viewValue }}
75+
</md-option>
76+
</md-select>
77+
78+
<p>Value: {{ currentDigimon }}</p>
79+
80+
<button md-button (click)="currentDigimon='pajiramon-3'">SET VALUE</button>
81+
<button md-button (click)="currentDigimon=null">RESET</button>
82+
</md-card>
83+
6884
<md-card>
6985
<md-card-subtitle>Option groups</md-card-subtitle>
7086

src/demo-app/select/select-demo.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export class SelectDemo {
1717
currentDrink: string;
1818
currentPokemon: string[];
1919
currentPokemonFromGroup: string;
20+
currentDigimon: string;
2021
latestChangeEvent: MdSelectChange;
2122
floatPlaceholder: string = 'auto';
2223
foodControl = new FormControl('pizza-1');
@@ -94,6 +95,15 @@ export class SelectDemo {
9495
}
9596
];
9697

98+
digimon = [
99+
{ value: 'mihiramon-0', viewValue: 'Mihiramon' },
100+
{ value: 'sandiramon-1', viewValue: 'Sandiramon' },
101+
{ value: 'sinduramon-2', viewValue: 'Sinduramon' },
102+
{ value: 'pajiramon-3', viewValue: 'Pajiramon' },
103+
{ value: 'vajiramon-4', viewValue: 'Vajiramon' },
104+
{ value: 'indramon-5', viewValue: 'Indramon' }
105+
];
106+
97107
toggleDisabled() {
98108
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
99109
}

src/lib/select/select.spec.ts

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,10 @@ describe('MdSelect', () => {
6363
ResetValuesSelect,
6464
FalsyValueSelect,
6565
SelectWithGroups,
66-
InvalidSelectInForm
66+
InvalidSelectInForm,
67+
BasicSelectWithoutForms,
68+
BasicSelectWithoutFormsPreselected,
69+
BasicSelectWithoutFormsMultiple
6770
],
6871
providers: [
6972
{provide: OverlayContainer, useFactory: () => {
@@ -706,6 +709,138 @@ describe('MdSelect', () => {
706709

707710
});
708711

712+
describe('selection without Angular forms', () => {
713+
it('should set the value when options are clicked', () => {
714+
const fixture = TestBed.createComponent(BasicSelectWithoutForms);
715+
716+
fixture.detectChanges();
717+
expect(fixture.componentInstance.selectedFood).toBeFalsy();
718+
719+
const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
720+
721+
trigger.click();
722+
fixture.detectChanges();
723+
724+
(overlayContainerElement.querySelector('md-option') as HTMLElement).click();
725+
fixture.detectChanges();
726+
727+
expect(fixture.componentInstance.selectedFood).toBe('steak-0');
728+
expect(fixture.componentInstance.select.value).toBe('steak-0');
729+
expect(trigger.textContent).toContain('Steak');
730+
731+
trigger.click();
732+
fixture.detectChanges();
733+
734+
(overlayContainerElement.querySelectorAll('md-option')[2] as HTMLElement).click();
735+
fixture.detectChanges();
736+
737+
expect(fixture.componentInstance.selectedFood).toBe('sandwich-2');
738+
expect(fixture.componentInstance.select.value).toBe('sandwich-2');
739+
expect(trigger.textContent).toContain('Sandwich');
740+
});
741+
742+
it('should mark options as selected when the value is set', () => {
743+
const fixture = TestBed.createComponent(BasicSelectWithoutForms);
744+
745+
fixture.detectChanges();
746+
fixture.componentInstance.selectedFood = 'sandwich-2';
747+
fixture.detectChanges();
748+
749+
const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
750+
expect(trigger.textContent).toContain('Sandwich');
751+
752+
trigger.click();
753+
fixture.detectChanges();
754+
755+
const option = overlayContainerElement.querySelectorAll('md-option')[2];
756+
757+
expect(option.classList).toContain('mat-selected');
758+
expect(fixture.componentInstance.select.value).toBe('sandwich-2');
759+
});
760+
761+
it('should reset the placeholder when a null value is set', () => {
762+
const fixture = TestBed.createComponent(BasicSelectWithoutForms);
763+
764+
fixture.detectChanges();
765+
expect(fixture.componentInstance.selectedFood).toBeFalsy();
766+
767+
const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
768+
769+
trigger.click();
770+
fixture.detectChanges();
771+
772+
(overlayContainerElement.querySelector('md-option') as HTMLElement).click();
773+
fixture.detectChanges();
774+
775+
expect(fixture.componentInstance.selectedFood).toBe('steak-0');
776+
expect(fixture.componentInstance.select.value).toBe('steak-0');
777+
expect(trigger.textContent).toContain('Steak');
778+
779+
fixture.componentInstance.selectedFood = null;
780+
fixture.detectChanges();
781+
782+
expect(fixture.componentInstance.select.value).toBeNull();
783+
expect(trigger.textContent).not.toContain('Steak');
784+
});
785+
786+
it('should reflect the preselected value', async(() => {
787+
const fixture = TestBed.createComponent(BasicSelectWithoutFormsPreselected);
788+
789+
fixture.detectChanges();
790+
fixture.whenStable().then(() => {
791+
const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
792+
793+
fixture.detectChanges();
794+
expect(trigger.textContent).toContain('Pizza');
795+
796+
trigger.click();
797+
fixture.detectChanges();
798+
799+
const option = overlayContainerElement.querySelectorAll('md-option')[1];
800+
801+
expect(option.classList).toContain('mat-selected');
802+
expect(fixture.componentInstance.select.value).toBe('pizza-1');
803+
});
804+
}));
805+
806+
it('should be able to select multiple values', () => {
807+
const fixture = TestBed.createComponent(BasicSelectWithoutFormsMultiple);
808+
809+
fixture.detectChanges();
810+
expect(fixture.componentInstance.selectedFoods).toBeFalsy();
811+
812+
const trigger = fixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement;
813+
814+
trigger.click();
815+
fixture.detectChanges();
816+
817+
const options =
818+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
819+
820+
options[0].click();
821+
fixture.detectChanges();
822+
823+
expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0']);
824+
expect(fixture.componentInstance.select.value).toEqual(['steak-0']);
825+
expect(trigger.textContent).toContain('Steak');
826+
827+
options[2].click();
828+
fixture.detectChanges();
829+
830+
expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0', 'sandwich-2']);
831+
expect(fixture.componentInstance.select.value).toEqual(['steak-0', 'sandwich-2']);
832+
expect(trigger.textContent).toContain('Steak, Sandwich');
833+
834+
options[1].click();
835+
fixture.detectChanges();
836+
837+
expect(fixture.componentInstance.selectedFoods).toEqual(['steak-0', 'pizza-1', 'sandwich-2']);
838+
expect(fixture.componentInstance.select.value).toEqual(['steak-0', 'pizza-1', 'sandwich-2']);
839+
expect(trigger.textContent).toContain('Steak, Pizza, Sandwich');
840+
});
841+
842+
});
843+
709844
describe('disabled behavior', () => {
710845

711846
it('should disable itself when control is disabled programmatically', () => {
@@ -2361,7 +2496,6 @@ describe('MdSelect', () => {
23612496

23622497
});
23632498

2364-
23652499
describe('reset values', () => {
23662500
let fixture: ComponentFixture<ResetValuesSelect>;
23672501
let trigger: HTMLElement;
@@ -2892,3 +3026,63 @@ class SelectWithGroups {
28923026
class InvalidSelectInForm {
28933027
value: any;
28943028
}
3029+
3030+
3031+
@Component({
3032+
template: `
3033+
<md-select placeholder="Food" [(value)]="selectedFood">
3034+
<md-option *ngFor="let food of foods" [value]="food.value">
3035+
{{ food.viewValue }}
3036+
</md-option>
3037+
</md-select>
3038+
`
3039+
})
3040+
class BasicSelectWithoutForms {
3041+
selectedFood: string | null;
3042+
foods: any[] = [
3043+
{ value: 'steak-0', viewValue: 'Steak' },
3044+
{ value: 'pizza-1', viewValue: 'Pizza' },
3045+
{ value: 'sandwich-2', viewValue: 'Sandwich' },
3046+
];
3047+
3048+
@ViewChild(MdSelect) select: MdSelect;
3049+
}
3050+
3051+
@Component({
3052+
template: `
3053+
<md-select placeholder="Food" [(value)]="selectedFood">
3054+
<md-option *ngFor="let food of foods" [value]="food.value">
3055+
{{ food.viewValue }}
3056+
</md-option>
3057+
</md-select>
3058+
`
3059+
})
3060+
class BasicSelectWithoutFormsPreselected {
3061+
selectedFood = 'pizza-1';
3062+
foods: any[] = [
3063+
{ value: 'steak-0', viewValue: 'Steak' },
3064+
{ value: 'pizza-1', viewValue: 'Pizza' },
3065+
];
3066+
3067+
@ViewChild(MdSelect) select: MdSelect;
3068+
}
3069+
3070+
@Component({
3071+
template: `
3072+
<md-select placeholder="Food" [(value)]="selectedFoods" multiple>
3073+
<md-option *ngFor="let food of foods" [value]="food.value">
3074+
{{ food.viewValue }}
3075+
</md-option>
3076+
</md-select>
3077+
`
3078+
})
3079+
class BasicSelectWithoutFormsMultiple {
3080+
selectedFoods: string[];
3081+
foods: any[] = [
3082+
{ value: 'steak-0', viewValue: 'Steak' },
3083+
{ value: 'pizza-1', viewValue: 'Pizza' },
3084+
{ value: 'sandwich-2', viewValue: 'Sandwich' },
3085+
];
3086+
3087+
@ViewChild(MdSelect) select: MdSelect;
3088+
}

src/lib/select/select.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,15 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
325325
}
326326
}
327327

328+
/** Value of the select control. */
329+
@Input()
330+
get value() { return this._value; }
331+
set value(newValue: any) {
332+
this.writeValue(newValue);
333+
this._value = newValue;
334+
}
335+
private _value: any;
336+
328337
/** Aria label of the select. If not specified, the placeholder will be used as label. */
329338
@Input('aria-label') ariaLabel: string = '';
330339

@@ -345,6 +354,13 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
345354
/** Event emitted when the selected value has been changed by the user. */
346355
@Output() change: EventEmitter<MdSelectChange> = new EventEmitter<MdSelectChange>();
347356

357+
/**
358+
* Event that emits whenever the raw value of the select changes. This is here primarily
359+
* to facilitate the two-way binding for the `value` input.
360+
* @docs-private
361+
*/
362+
@Output() valueChange = new EventEmitter<any>();
363+
348364
constructor(
349365
private _viewportRuler: ViewportRuler,
350366
private _changeDetectorRef: ChangeDetectorRef,
@@ -377,11 +393,11 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
377393
this._changeSubscription = startWith.call(this.options.changes, null).subscribe(() => {
378394
this._resetOptions();
379395

380-
if (this._control) {
381-
// Defer setting the value in order to avoid the "Expression
382-
// has changed after it was checked" errors from Angular.
383-
Promise.resolve(null).then(() => this._setSelectionByValue(this._control.value));
384-
}
396+
// Defer setting the value in order to avoid the "Expression
397+
// has changed after it was checked" errors from Angular.
398+
Promise.resolve().then(() => {
399+
this._setSelectionByValue(this._control ? this._control.value : this._value);
400+
});
385401
});
386402
}
387403

@@ -750,8 +766,10 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
750766
valueToEmit = this.selected ? this.selected.value : fallbackValue;
751767
}
752768

769+
this._value = valueToEmit;
753770
this._onChange(valueToEmit);
754771
this.change.emit(new MdSelectChange(this, valueToEmit));
772+
this.valueChange.emit(valueToEmit);
755773
}
756774

757775
/** Records option IDs to pass to the aria-owns property. */

0 commit comments

Comments
 (0)