Skip to content

Commit

Permalink
feat(select): support basic usage without @angular/forms (#5871)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
crisbeto authored and andrewseguin committed Jul 25, 2017
1 parent ebb5e9e commit 9a90eaf
Show file tree
Hide file tree
Showing 4 changed files with 245 additions and 7 deletions.
16 changes: 16 additions & 0 deletions src/demo-app/select/select-demo.html
Expand Up @@ -65,6 +65,22 @@
</md-card-content>
</md-card>

<md-card>
<md-card-subtitle>Without Angular forms</md-card-subtitle>

<md-select placeholder="Digimon" [(value)]="currentDigimon">
<md-option>None</md-option>
<md-option *ngFor="let creature of digimon" [value]="creature.value">
{{ creature.viewValue }}
</md-option>
</md-select>

<p>Value: {{ currentDigimon }}</p>

<button md-button (click)="currentDigimon='pajiramon-3'">SET VALUE</button>
<button md-button (click)="currentDigimon=null">RESET</button>
</md-card>

<md-card>
<md-card-subtitle>Option groups</md-card-subtitle>

Expand Down
10 changes: 10 additions & 0 deletions src/demo-app/select/select-demo.ts
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
}
Expand Down
198 changes: 196 additions & 2 deletions src/lib/select/select.spec.ts
Expand Up @@ -63,7 +63,10 @@ describe('MdSelect', () => {
ResetValuesSelect,
FalsyValueSelect,
SelectWithGroups,
InvalidSelectInForm
InvalidSelectInForm,
BasicSelectWithoutForms,
BasicSelectWithoutFormsPreselected,
BasicSelectWithoutFormsMultiple
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -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<HTMLElement>;

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', () => {
Expand Down Expand Up @@ -2361,7 +2496,6 @@ describe('MdSelect', () => {

});


describe('reset values', () => {
let fixture: ComponentFixture<ResetValuesSelect>;
let trigger: HTMLElement;
Expand Down Expand Up @@ -2892,3 +3026,63 @@ class SelectWithGroups {
class InvalidSelectInForm {
value: any;
}


@Component({
template: `
<md-select placeholder="Food" [(value)]="selectedFood">
<md-option *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</md-option>
</md-select>
`
})
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: `
<md-select placeholder="Food" [(value)]="selectedFood">
<md-option *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</md-option>
</md-select>
`
})
class BasicSelectWithoutFormsPreselected {
selectedFood = 'pizza-1';
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
];

@ViewChild(MdSelect) select: MdSelect;
}

@Component({
template: `
<md-select placeholder="Food" [(value)]="selectedFoods" multiple>
<md-option *ngFor="let food of foods" [value]="food.value">
{{ food.viewValue }}
</md-option>
</md-select>
`
})
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;
}
28 changes: 23 additions & 5 deletions src/lib/select/select.ts
Expand Up @@ -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 = '';

Expand All @@ -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<MdSelectChange> = new EventEmitter<MdSelectChange>();

/**
* 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<any>();

constructor(
private _viewportRuler: ViewportRuler,
private _changeDetectorRef: ChangeDetectorRef,
Expand Down Expand Up @@ -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);
});
});
}

Expand Down Expand Up @@ -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. */
Expand Down

0 comments on commit 9a90eaf

Please sign in to comment.