Skip to content

Commit d6d5ca9

Browse files
committed
feat(select): integrate with angular forms
1 parent 5a781a0 commit d6d5ca9

File tree

8 files changed

+251
-33
lines changed

8 files changed

+251
-33
lines changed

src/demo-app/demo-app-module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {NgModule, ApplicationRef} from '@angular/core';
22
import {BrowserModule} from '@angular/platform-browser';
33
import {HttpModule} from '@angular/http';
4-
import {FormsModule} from '@angular/forms';
4+
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
55
import {DemoApp, Home} from './demo-app/demo-app';
66
import {RouterModule} from '@angular/router';
77
import {MaterialModule} from '@angular/material';
@@ -39,6 +39,7 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
3939
BrowserModule,
4040
FormsModule,
4141
HttpModule,
42+
ReactiveFormsModule,
4243
RouterModule.forRoot(DEMO_APP_ROUTES),
4344
MaterialModule.forRoot(),
4445
],

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
<div class="demo-select">
2-
<md-select placeholder="Food">
3-
<md-option *ngFor="let food of foods"> {{ food.viewValue }} </md-option>
2+
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
3+
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
44
</md-select>
5+
<p> Value: {{ control.value }} </p>
6+
<p> Touched: {{ control.touched }} </p>
7+
<p> Dirty: {{ control.dirty }} </p>
8+
<p> Status: {{ control.status }} </p>
9+
<button md-button (click)="control.setValue('pizza-1')">SET VALUE</button>
10+
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
511
</div>

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Component} from '@angular/core';
2-
2+
import {FormControl} from '@angular/forms';
33

44
@Component({
55
moduleId: module.id,
@@ -9,8 +9,11 @@ import {Component} from '@angular/core';
99
})
1010
export class SelectDemo {
1111
foods = [
12-
{value: 'steak', viewValue: 'Steak'},
13-
{value: 'pizza', viewValue: 'Pizza'},
14-
{value: 'tacos', viewValue: 'Tacos'}
12+
{value: 'steak-0', viewValue: 'Steak'},
13+
{value: 'pizza-1', viewValue: 'Pizza'},
14+
{value: 'tacos-2', viewValue: 'Tacos'}
1515
];
16+
17+
control = new FormControl('');
18+
1619
}

src/lib/select/_select-theme.scss

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@
55
$foreground: map-get($theme, foreground);
66
$background: map-get($theme, background);
77
$primary: map-get($theme, primary);
8+
$warn: map-get($theme, warn);
89

910
.md-select-trigger {
1011
color: md-color($foreground, hint-text);
1112
border-bottom: 1px solid md-color($foreground, divider);
1213

1314
md-select:focus & {
15+
color: md-color($primary);
1416
border-bottom: 1px solid md-color($primary);
1517
}
16-
}
1718

18-
.md-select-placeholder {
19-
md-select:focus & {
20-
color: md-color($primary);
19+
.ng-invalid.ng-touched & {
20+
color: md-color($warn);
21+
border-bottom: 1px solid md-color($warn);
2122
}
2223
}
2324

@@ -27,6 +28,10 @@
2728
md-select:focus & {
2829
color: md-color($primary);
2930
}
31+
32+
.ng-invalid.ng-touched & {
33+
color: md-color($warn);
34+
}
3035
}
3136

3237
.md-select-content {

src/lib/select/option.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
Component,
33
ElementRef,
44
EventEmitter,
5+
Input,
56
Output,
67
Renderer,
78
ViewEncapsulation
@@ -16,7 +17,7 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
1617
'tabindex': '0',
1718
'[class.md-selected]': 'selected',
1819
'[attr.aria-selected]': 'selected.toString()',
19-
'(click)': 'select()',
20+
'(click)': 'select(true)',
2021
'(keydown)': '_handleKeydown($event)'
2122
},
2223
templateUrl: 'option.html',
@@ -26,6 +27,9 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
2627
export class MdOption {
2728
private _selected = false;
2829

30+
/** The form value of the option. */
31+
@Input() value: any;
32+
2933
/** Event emitted when the option is selected. */
3034
@Output() onSelect = new EventEmitter();
3135

@@ -46,9 +50,9 @@ export class MdOption {
4650
}
4751

4852
/** Selects the option. */
49-
select(): void {
53+
select(isUserInput = false): void {
5054
this._selected = true;
51-
this.onSelect.emit();
55+
this.onSelect.emit(isUserInput);
5256
}
5357

5458
/** Deselects the option. */
@@ -64,7 +68,7 @@ export class MdOption {
6468
/** Ensures the option is selected when activated from the keyboard. */
6569
_handleKeydown(event: KeyboardEvent): void {
6670
if (event.keyCode === ENTER || event.keyCode === SPACE) {
67-
this.select();
71+
this.select(true);
6872
}
6973
}
7074

src/lib/select/select.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ md-select {
2525
[dir='rtl'] & {
2626
transform-origin: right top;
2727
}
28+
29+
[aria-required=true] &::after {
30+
content: '*';
31+
}
2832
}
2933

3034
.md-select-value {

src/lib/select/select.spec.ts

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
22
import {By} from '@angular/platform-browser';
3-
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
3+
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
44
import {MdSelectModule} from './index';
55
import {OverlayContainer} from '../core/overlay/overlay-container';
66
import {MdSelect} from './select';
77
import {MdOption} from './option';
88
import {Dir} from '../core/rtl/dir';
9+
import {FormControl, ReactiveFormsModule} from '@angular/forms';
910

1011
describe('MdSelect', () => {
1112
let overlayContainerElement: HTMLElement;
1213
let dir: {value: string};
1314

1415
beforeEach(async(() => {
1516
TestBed.configureTestingModule({
16-
imports: [MdSelectModule.forRoot()],
17+
imports: [MdSelectModule.forRoot(), ReactiveFormsModule],
1718
declarations: [BasicSelect],
1819
providers: [
1920
{provide: OverlayContainer, useFactory: () => {
@@ -190,7 +191,7 @@ describe('MdSelect', () => {
190191
}));
191192

192193
it('should select an option that was added after initialization', () => {
193-
fixture.componentInstance.foods.push({viewValue: 'Pasta'});
194+
fixture.componentInstance.foods.push({viewValue: 'Pasta', value: 'pasta-3'});
194195
trigger.click();
195196
fixture.detectChanges();
196197

@@ -206,6 +207,94 @@ describe('MdSelect', () => {
206207

207208
});
208209

210+
describe('forms integration', () => {
211+
let fixture: ComponentFixture<BasicSelect>;
212+
let trigger: HTMLElement;
213+
214+
beforeEach(() => {
215+
fixture = TestBed.createComponent(BasicSelect);
216+
fixture.detectChanges();
217+
218+
trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
219+
});
220+
221+
it('should set the view value from the form', () => {
222+
let value = fixture.debugElement.query(By.css('.md-select-value'));
223+
expect(value).toBeNull();
224+
225+
fixture.componentInstance.control.setValue('pizza-1');
226+
fixture.detectChanges();
227+
228+
value = fixture.debugElement.query(By.css('.md-select-value'));
229+
expect(value.nativeElement.textContent).toContain('Pizza');
230+
});
231+
232+
it('should update the form value when the view changes', () => {
233+
expect(fixture.componentInstance.control.value).toEqual(null);
234+
235+
trigger.click();
236+
fixture.detectChanges();
237+
238+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
239+
option.click();
240+
fixture.detectChanges();
241+
242+
expect(fixture.componentInstance.control.value).toEqual('steak-0');
243+
});
244+
245+
it('should set the control to touched when the select is touched', () => {
246+
expect(fixture.componentInstance.control.touched).toEqual(false);
247+
248+
trigger.click();
249+
fixture.detectChanges();
250+
expect(fixture.componentInstance.control.touched).toEqual(false);
251+
252+
const backdrop =
253+
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
254+
backdrop.click();
255+
dispatchEvent('blur', trigger);
256+
fixture.detectChanges();
257+
258+
expect(fixture.componentInstance.control.touched).toEqual(true);
259+
});
260+
261+
it('should set the control to dirty when the select\'s value changes in the DOM', () => {
262+
expect(fixture.componentInstance.control.dirty).toEqual(false);
263+
264+
trigger.click();
265+
fixture.detectChanges();
266+
267+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
268+
option.click();
269+
fixture.detectChanges();
270+
271+
expect(fixture.componentInstance.control.dirty).toEqual(true);
272+
});
273+
274+
it('should not set the control to dirty when the value changes programmatically', () => {
275+
expect(fixture.componentInstance.control.dirty).toEqual(false);
276+
277+
fixture.componentInstance.control.setValue('pizza-1');
278+
279+
expect(fixture.componentInstance.control.dirty).toEqual(false);
280+
});
281+
282+
283+
it('should set an asterisk after the placeholder if the control is required', () => {
284+
const placeholder =
285+
fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement;
286+
const initialContent = getComputedStyle(placeholder, '::after').getPropertyValue('content');
287+
288+
// must support both default cases to work in all browsers in Saucelabs
289+
expect(initialContent === 'none' || initialContent === '').toBe(true);
290+
291+
fixture.componentInstance.isRequired = true;
292+
fixture.detectChanges();
293+
expect(getComputedStyle(placeholder, '::after').getPropertyValue('content')).toContain('*');
294+
});
295+
296+
});
297+
209298
describe('animations', () => {
210299
let fixture: ComponentFixture<BasicSelect>;
211300
let trigger: HTMLElement;
@@ -278,22 +367,40 @@ describe('MdSelect', () => {
278367
});
279368

280369
describe('for select', () => {
281-
let select: DebugElement;
370+
let select: HTMLElement;
282371

283372
beforeEach(() => {
284-
select = fixture.debugElement.query(By.css('md-select'));
373+
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
285374
});
286375

287376
it('should set the role of the select to listbox', () => {
288-
expect(select.nativeElement.getAttribute('role')).toEqual('listbox');
377+
expect(select.getAttribute('role')).toEqual('listbox');
289378
});
290379

291380
it('should set the aria label of the select to the placeholder', () => {
292-
expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food');
381+
expect(select.getAttribute('aria-label')).toEqual('Food');
293382
});
294383

295384
it('should set the tabindex of the select to 0', () => {
296-
expect(select.nativeElement.getAttribute('tabindex')).toEqual('0');
385+
expect(select.getAttribute('tabindex')).toEqual('0');
386+
});
387+
388+
it('should set aria-required for required selects', () => {
389+
expect(select.getAttribute('aria-required')).toEqual('false');
390+
391+
fixture.componentInstance.isRequired = true;
392+
fixture.detectChanges();
393+
394+
expect(select.getAttribute('aria-required')).toEqual('true');
395+
});
396+
397+
it('should set aria-invalid for selects that are invalid', () => {
398+
expect(select.getAttribute('aria-invalid')).toEqual('false');
399+
400+
fixture.componentInstance.isRequired = true;
401+
fixture.detectChanges();
402+
403+
expect(select.getAttribute('aria-invalid')).toEqual('true');
297404
});
298405

299406
});
@@ -347,19 +454,34 @@ describe('MdSelect', () => {
347454
@Component({
348455
selector: 'basic-select',
349456
template: `
350-
<md-select placeholder="Food">
351-
<md-option *ngFor="let food of foods">{{ food.viewValue }}</md-option>
457+
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
458+
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
352459
</md-select>
353460
`
354461
})
355462
class BasicSelect {
356463
foods = [
357-
{ viewValue: 'Steak' },
358-
{ viewValue: 'Pizza' },
359-
{ viewValue: 'Tacos' },
464+
{ value: 'steak-0', viewValue: 'Steak' },
465+
{ value: 'pizza-1', viewValue: 'Pizza' },
466+
{ value: 'tacos-2', viewValue: 'Tacos' },
360467
];
468+
control = new FormControl();
469+
isRequired: boolean;
361470

362471
@ViewChild(MdSelect) select: MdSelect;
363472
@ViewChildren(MdOption) options: QueryList<MdOption>;
473+
}
364474

475+
/**
476+
* TODO: Move this to core testing utility until Angular has event faking
477+
* support.
478+
*
479+
* Dispatches an event from an element.
480+
* @param eventName Name of the event
481+
* @param element The element from which the event will be dispatched.
482+
*/
483+
function dispatchEvent(eventName: string, element: HTMLElement): void {
484+
let event = document.createEvent('Event');
485+
event.initEvent(eventName, true, true);
486+
element.dispatchEvent(event);
365487
}

0 commit comments

Comments
 (0)