Skip to content

Commit 94c1a38

Browse files
committed
feat(menu): add support for angular forms
1 parent b14bb72 commit 94c1a38

File tree

8 files changed

+284
-32
lines changed

8 files changed

+284
-32
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: 15 additions & 2 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)': '_selectViaInteraction()',
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

@@ -64,10 +68,19 @@ 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._selectViaInteraction();
6872
}
6973
}
7074

75+
/**
76+
* Selects the option while indicating the selection came from the user. Used to
77+
* determine if the select's view -> model callback should be invoked.
78+
*/
79+
_selectViaInteraction() {
80+
this._selected = true;
81+
this.onSelect.emit(true);
82+
}
83+
7184
_getHostElement(): HTMLElement {
7285
return this._element.nativeElement;
7386
}

src/lib/select/select.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ md-select {
2525
[dir='rtl'] & {
2626
transform-origin: right top;
2727
}
28+
29+
// TODO: Double-check accessibility of this style
30+
[aria-required=true] &::after {
31+
content: '*';
32+
}
2833
}
2934

3035
.md-select-value {

src/lib/select/select.spec.ts

Lines changed: 159 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,114 @@ 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('Expected trigger to start with empty value.');
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+
`Expected trigger to be populated by the control's new value.`);
231+
232+
trigger.click();
233+
fixture.detectChanges();
234+
235+
const options =
236+
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;
237+
expect(options[1].classList).toContain('md-selected',
238+
`Expected the option with the control's new value to be selected.`);
239+
});
240+
241+
it('should update the form value when the view changes', () => {
242+
expect(fixture.componentInstance.control.value).toEqual(null,
243+
`Expected the control's value to be null initially.`);
244+
245+
trigger.click();
246+
fixture.detectChanges();
247+
248+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
249+
option.click();
250+
fixture.detectChanges();
251+
252+
expect(fixture.componentInstance.control.value).toEqual('steak-0',
253+
`Expected the control's value to be set to the new option the user selected.`);
254+
});
255+
256+
it('should set the control to touched when the select is touched', () => {
257+
expect(fixture.componentInstance.control.touched).toEqual(false,
258+
`Expected the control to start off as untouched.`);
259+
260+
trigger.click();
261+
dispatchEvent('blur', trigger);
262+
fixture.detectChanges();
263+
expect(fixture.componentInstance.control.touched).toEqual(false,
264+
`Expected the control to stay untouched when menu opened.`);
265+
266+
const backdrop =
267+
overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
268+
backdrop.click();
269+
dispatchEvent('blur', trigger);
270+
fixture.detectChanges();
271+
expect(fixture.componentInstance.control.touched).toEqual(true,
272+
`Expected the control to be touched as soon as focus left the select.`);
273+
});
274+
275+
it('should set the control to dirty when the select\'s value changes in the DOM', () => {
276+
expect(fixture.componentInstance.control.dirty).toEqual(false,
277+
`Expected control to start out pristine.`);
278+
279+
trigger.click();
280+
fixture.detectChanges();
281+
282+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
283+
option.click();
284+
fixture.detectChanges();
285+
286+
expect(fixture.componentInstance.control.dirty).toEqual(true,
287+
`Expected control to be dirty after value was changed by user.`);
288+
});
289+
290+
it('should not set the control to dirty when the value changes programmatically', () => {
291+
expect(fixture.componentInstance.control.dirty).toEqual(false,
292+
`Expected control to start out pristine.`);
293+
294+
fixture.componentInstance.control.setValue('pizza-1');
295+
296+
expect(fixture.componentInstance.control.dirty).toEqual(false,
297+
`Expected control to stay pristine after value was changed programmatically.`);
298+
});
299+
300+
301+
it('should set an asterisk after the placeholder if the control is required', () => {
302+
const placeholder =
303+
fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement;
304+
const initialContent = getComputedStyle(placeholder, '::after').getPropertyValue('content');
305+
306+
// must support both default cases to work in all browsers in Saucelabs
307+
expect(initialContent === 'none' || initialContent === '').toBe(true,
308+
`Expected placeholder not to have an asterisk, as the control was not required.`);
309+
310+
fixture.componentInstance.isRequired = true;
311+
fixture.detectChanges();
312+
expect(getComputedStyle(placeholder, '::after').getPropertyValue('content')).toContain('*',
313+
`Expected placeholder to have an asterisk, as control was required.`);
314+
});
315+
316+
});
317+
209318
describe('animations', () => {
210319
let fixture: ComponentFixture<BasicSelect>;
211320
let trigger: HTMLElement;
@@ -278,22 +387,44 @@ describe('MdSelect', () => {
278387
});
279388

280389
describe('for select', () => {
281-
let select: DebugElement;
390+
let select: HTMLElement;
282391

283392
beforeEach(() => {
284-
select = fixture.debugElement.query(By.css('md-select'));
393+
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
285394
});
286395

287396
it('should set the role of the select to listbox', () => {
288-
expect(select.nativeElement.getAttribute('role')).toEqual('listbox');
397+
expect(select.getAttribute('role')).toEqual('listbox');
289398
});
290399

291400
it('should set the aria label of the select to the placeholder', () => {
292-
expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food');
401+
expect(select.getAttribute('aria-label')).toEqual('Food');
293402
});
294403

295404
it('should set the tabindex of the select to 0', () => {
296-
expect(select.nativeElement.getAttribute('tabindex')).toEqual('0');
405+
expect(select.getAttribute('tabindex')).toEqual('0');
406+
});
407+
408+
it('should set aria-required for required selects', () => {
409+
expect(select.getAttribute('aria-required')).toEqual('false',
410+
`Expected aria-required attr to be false for selects that aren't required.`);
411+
412+
fixture.componentInstance.isRequired = true;
413+
fixture.detectChanges();
414+
415+
expect(select.getAttribute('aria-required')).toEqual('true',
416+
`Expected aria-required attr to be true for selects that are required.`);
417+
});
418+
419+
it('should set aria-invalid for selects that are invalid', () => {
420+
expect(select.getAttribute('aria-invalid')).toEqual('false',
421+
`Expected aria-invalid attr to be false for valid selects.`);
422+
423+
fixture.componentInstance.isRequired = true;
424+
fixture.detectChanges();
425+
426+
expect(select.getAttribute('aria-invalid')).toEqual('true',
427+
`Expected aria-invalid attr to be true for invalid selects.`);
297428
});
298429

299430
});
@@ -347,19 +478,34 @@ describe('MdSelect', () => {
347478
@Component({
348479
selector: 'basic-select',
349480
template: `
350-
<md-select placeholder="Food">
351-
<md-option *ngFor="let food of foods">{{ food.viewValue }}</md-option>
481+
<md-select placeholder="Food" [formControl]="control" [required]="isRequired">
482+
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
352483
</md-select>
353484
`
354485
})
355486
class BasicSelect {
356487
foods = [
357-
{ viewValue: 'Steak' },
358-
{ viewValue: 'Pizza' },
359-
{ viewValue: 'Tacos' },
488+
{ value: 'steak-0', viewValue: 'Steak' },
489+
{ value: 'pizza-1', viewValue: 'Pizza' },
490+
{ value: 'tacos-2', viewValue: 'Tacos' },
360491
];
492+
control = new FormControl();
493+
isRequired: boolean;
361494

362495
@ViewChild(MdSelect) select: MdSelect;
363496
@ViewChildren(MdOption) options: QueryList<MdOption>;
497+
}
364498

499+
/**
500+
* TODO: Move this to core testing utility until Angular has event faking
501+
* support.
502+
*
503+
* Dispatches an event from an element.
504+
* @param eventName Name of the event
505+
* @param element The element from which the event will be dispatched.
506+
*/
507+
function dispatchEvent(eventName: string, element: HTMLElement): void {
508+
let event = document.createEvent('Event');
509+
event.initEvent(eventName, true, true);
510+
element.dispatchEvent(event);
365511
}

0 commit comments

Comments
 (0)