diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts
index 9dcd619629de..048530e046fe 100644
--- a/src/demo-app/demo-app-module.ts
+++ b/src/demo-app/demo-app-module.ts
@@ -1,7 +1,7 @@
import {NgModule, ApplicationRef} from '@angular/core';
import {BrowserModule} from '@angular/platform-browser';
import {HttpModule} from '@angular/http';
-import {FormsModule} from '@angular/forms';
+import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {DemoApp, Home} from './demo-app/demo-app';
import {RouterModule} from '@angular/router';
import {MaterialModule} from '@angular/material';
@@ -39,6 +39,7 @@ import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tab
BrowserModule,
FormsModule,
HttpModule,
+ ReactiveFormsModule,
RouterModule.forRoot(DEMO_APP_ROUTES),
MaterialModule.forRoot(),
],
diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html
index 67390a5987cd..bb6476d0f200 100644
--- a/src/demo-app/select/select-demo.html
+++ b/src/demo-app/select/select-demo.html
@@ -1,5 +1,11 @@
-
- {{ food.viewValue }}
+
+ {{ food.viewValue }}
+ Value: {{ control.value }}
+ Touched: {{ control.touched }}
+ Dirty: {{ control.dirty }}
+ Status: {{ control.status }}
+
+
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts
index d8d9ec1615be..9865c640173a 100644
--- a/src/demo-app/select/select-demo.ts
+++ b/src/demo-app/select/select-demo.ts
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
-
+import {FormControl} from '@angular/forms';
@Component({
moduleId: module.id,
@@ -9,8 +9,11 @@ import {Component} from '@angular/core';
})
export class SelectDemo {
foods = [
- {value: 'steak', viewValue: 'Steak'},
- {value: 'pizza', viewValue: 'Pizza'},
- {value: 'tacos', viewValue: 'Tacos'}
+ {value: 'steak-0', viewValue: 'Steak'},
+ {value: 'pizza-1', viewValue: 'Pizza'},
+ {value: 'tacos-2', viewValue: 'Tacos'}
];
+
+ control = new FormControl('');
+
}
diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss
index cdb52334dab4..7e692683d7ff 100644
--- a/src/lib/select/_select-theme.scss
+++ b/src/lib/select/_select-theme.scss
@@ -5,19 +5,20 @@
$foreground: map-get($theme, foreground);
$background: map-get($theme, background);
$primary: map-get($theme, primary);
+ $warn: map-get($theme, warn);
.md-select-trigger {
color: md-color($foreground, hint-text);
border-bottom: 1px solid md-color($foreground, divider);
md-select:focus & {
+ color: md-color($primary);
border-bottom: 1px solid md-color($primary);
}
- }
- .md-select-placeholder {
- md-select:focus & {
- color: md-color($primary);
+ .ng-invalid.ng-touched & {
+ color: md-color($warn);
+ border-bottom: 1px solid md-color($warn);
}
}
@@ -27,6 +28,10 @@
md-select:focus & {
color: md-color($primary);
}
+
+ .ng-invalid.ng-touched & {
+ color: md-color($warn);
+ }
}
.md-select-content {
diff --git a/src/lib/select/option.ts b/src/lib/select/option.ts
index 791e683a50ee..f1f615ee1611 100644
--- a/src/lib/select/option.ts
+++ b/src/lib/select/option.ts
@@ -2,6 +2,7 @@ import {
Component,
ElementRef,
EventEmitter,
+ Input,
Output,
Renderer,
ViewEncapsulation
@@ -16,7 +17,7 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
'tabindex': '0',
'[class.md-selected]': 'selected',
'[attr.aria-selected]': 'selected.toString()',
- '(click)': 'select()',
+ '(click)': 'select(true)',
'(keydown)': '_handleKeydown($event)'
},
templateUrl: 'option.html',
@@ -26,6 +27,9 @@ import {ENTER, SPACE} from '../core/keyboard/keycodes';
export class MdOption {
private _selected = false;
+ /** The form value of the option. */
+ @Input() value: any;
+
/** Event emitted when the option is selected. */
@Output() onSelect = new EventEmitter();
@@ -46,9 +50,9 @@ export class MdOption {
}
/** Selects the option. */
- select(): void {
+ select(isUserInput = false): void {
this._selected = true;
- this.onSelect.emit();
+ this.onSelect.emit(isUserInput);
}
/** Deselects the option. */
@@ -64,7 +68,7 @@ export class MdOption {
/** Ensures the option is selected when activated from the keyboard. */
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
- this.select();
+ this.select(true);
}
}
diff --git a/src/lib/select/select.scss b/src/lib/select/select.scss
index 243f8e4e29e3..ad43ed196867 100644
--- a/src/lib/select/select.scss
+++ b/src/lib/select/select.scss
@@ -25,6 +25,10 @@ md-select {
[dir='rtl'] & {
transform-origin: right top;
}
+
+ [aria-required=true] &::after {
+ content: '*';
+ }
}
.md-select-value {
diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts
index fb2c5c6e67eb..7300304778e0 100644
--- a/src/lib/select/select.spec.ts
+++ b/src/lib/select/select.spec.ts
@@ -1,11 +1,12 @@
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
-import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
+import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {MdSelectModule} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdSelect} from './select';
import {MdOption} from './option';
import {Dir} from '../core/rtl/dir';
+import {FormControl, ReactiveFormsModule} from '@angular/forms';
describe('MdSelect', () => {
let overlayContainerElement: HTMLElement;
@@ -13,7 +14,7 @@ describe('MdSelect', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
- imports: [MdSelectModule.forRoot()],
+ imports: [MdSelectModule.forRoot(), ReactiveFormsModule],
declarations: [BasicSelect],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -190,7 +191,7 @@ describe('MdSelect', () => {
}));
it('should select an option that was added after initialization', () => {
- fixture.componentInstance.foods.push({viewValue: 'Pasta'});
+ fixture.componentInstance.foods.push({viewValue: 'Pasta', value: 'pasta-3'});
trigger.click();
fixture.detectChanges();
@@ -206,6 +207,94 @@ describe('MdSelect', () => {
});
+ describe('forms integration', () => {
+ let fixture: ComponentFixture;
+ let trigger: HTMLElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(BasicSelect);
+ fixture.detectChanges();
+
+ trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
+ });
+
+ it('should set the view value from the form', () => {
+ let value = fixture.debugElement.query(By.css('.md-select-value'));
+ expect(value).toBeNull();
+
+ fixture.componentInstance.control.setValue('pizza-1');
+ fixture.detectChanges();
+
+ value = fixture.debugElement.query(By.css('.md-select-value'));
+ expect(value.nativeElement.textContent).toContain('Pizza');
+ });
+
+ it('should update the form value when the view changes', () => {
+ expect(fixture.componentInstance.control.value).toEqual(null);
+
+ trigger.click();
+ fixture.detectChanges();
+
+ const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
+ option.click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.value).toEqual('steak-0');
+ });
+
+ it('should set the control to touched when the select is touched', () => {
+ expect(fixture.componentInstance.control.touched).toEqual(false);
+
+ trigger.click();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.control.touched).toEqual(false);
+
+ const backdrop =
+ overlayContainerElement.querySelector('.md-overlay-backdrop') as HTMLElement;
+ backdrop.click();
+ dispatchEvent('blur', trigger);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.touched).toEqual(true);
+ });
+
+ it('should set the control to dirty when the select\'s value changes in the DOM', () => {
+ expect(fixture.componentInstance.control.dirty).toEqual(false);
+
+ trigger.click();
+ fixture.detectChanges();
+
+ const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
+ option.click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.dirty).toEqual(true);
+ });
+
+ it('should not set the control to dirty when the value changes programmatically', () => {
+ expect(fixture.componentInstance.control.dirty).toEqual(false);
+
+ fixture.componentInstance.control.setValue('pizza-1');
+
+ expect(fixture.componentInstance.control.dirty).toEqual(false);
+ });
+
+
+ it('should set an asterisk after the placeholder if the control is required', () => {
+ const placeholder =
+ fixture.debugElement.query(By.css('.md-select-placeholder')).nativeElement;
+ const initialContent = getComputedStyle(placeholder, '::after').getPropertyValue('content');
+
+ // must support both default cases to work in all browsers in Saucelabs
+ expect(initialContent === 'none' || initialContent === '').toBe(true);
+
+ fixture.componentInstance.isRequired = true;
+ fixture.detectChanges();
+ expect(getComputedStyle(placeholder, '::after').getPropertyValue('content')).toContain('*');
+ });
+
+ });
+
describe('animations', () => {
let fixture: ComponentFixture;
let trigger: HTMLElement;
@@ -278,22 +367,40 @@ describe('MdSelect', () => {
});
describe('for select', () => {
- let select: DebugElement;
+ let select: HTMLElement;
beforeEach(() => {
- select = fixture.debugElement.query(By.css('md-select'));
+ select = fixture.debugElement.query(By.css('md-select')).nativeElement;
});
it('should set the role of the select to listbox', () => {
- expect(select.nativeElement.getAttribute('role')).toEqual('listbox');
+ expect(select.getAttribute('role')).toEqual('listbox');
});
it('should set the aria label of the select to the placeholder', () => {
- expect(select.nativeElement.getAttribute('aria-label')).toEqual('Food');
+ expect(select.getAttribute('aria-label')).toEqual('Food');
});
it('should set the tabindex of the select to 0', () => {
- expect(select.nativeElement.getAttribute('tabindex')).toEqual('0');
+ expect(select.getAttribute('tabindex')).toEqual('0');
+ });
+
+ it('should set aria-required for required selects', () => {
+ expect(select.getAttribute('aria-required')).toEqual('false');
+
+ fixture.componentInstance.isRequired = true;
+ fixture.detectChanges();
+
+ expect(select.getAttribute('aria-required')).toEqual('true');
+ });
+
+ it('should set aria-invalid for selects that are invalid', () => {
+ expect(select.getAttribute('aria-invalid')).toEqual('false');
+
+ fixture.componentInstance.isRequired = true;
+ fixture.detectChanges();
+
+ expect(select.getAttribute('aria-invalid')).toEqual('true');
});
});
@@ -347,19 +454,34 @@ describe('MdSelect', () => {
@Component({
selector: 'basic-select',
template: `
-
- {{ food.viewValue }}
+
+ {{ food.viewValue }}
`
})
class BasicSelect {
foods = [
- { viewValue: 'Steak' },
- { viewValue: 'Pizza' },
- { viewValue: 'Tacos' },
+ { value: 'steak-0', viewValue: 'Steak' },
+ { value: 'pizza-1', viewValue: 'Pizza' },
+ { value: 'tacos-2', viewValue: 'Tacos' },
];
+ control = new FormControl();
+ isRequired: boolean;
@ViewChild(MdSelect) select: MdSelect;
@ViewChildren(MdOption) options: QueryList;
+}
+/**
+ * TODO: Move this to core testing utility until Angular has event faking
+ * support.
+ *
+ * Dispatches an event from an element.
+ * @param eventName Name of the event
+ * @param element The element from which the event will be dispatched.
+ */
+function dispatchEvent(eventName: string, element: HTMLElement): void {
+ let event = document.createEvent('Event');
+ event.initEvent(eventName, true, true);
+ element.dispatchEvent(event);
}
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index 3d647f271583..7ffc657562e6 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -19,6 +19,8 @@ import {ListKeyManager} from '../core/a11y/list-key-manager';
import {Dir} from '../core/rtl/dir';
import {Subscription} from 'rxjs/Subscription';
import {SelectAnimations} from './select-animations';
+import {ControlValueAccessor, NgControl} from '@angular/forms';
+import {coerceBooleanProperty} from '../core/coersion/boolean-property';
@Component({
moduleId: module.id,
@@ -30,16 +32,19 @@ import {SelectAnimations} from './select-animations';
'role': 'listbox',
'tabindex': '0',
'[attr.aria-label]': 'placeholder',
- '(keydown)': '_openOnActivate($event)'
+ '[attr.aria-required]': '_required.toString()',
+ '[attr.aria-invalid]': '_control?.invalid || "false"',
+ '(keydown)': '_openOnActivate($event)',
+ '(blur)': '_onBlur()'
},
animations: [
SelectAnimations.transformPlaceholder(),
SelectAnimations.transformPanel(),
SelectAnimations.fadeInContent()
],
- exportAs: 'mdSelect'
+ exportAs: 'mdSelect',
})
-export class MdSelect implements AfterContentInit, OnDestroy {
+export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestroy {
/** Whether or not the overlay panel is open. */
private _panelOpen = false;
@@ -55,9 +60,18 @@ export class MdSelect implements AfterContentInit, OnDestroy {
/** Subscription to tab events while overlay is focused. */
private _tabSubscription: Subscription;
+ /** Whether filling out the select is required in the form. */
+ private _required: boolean = false;
+
/** Manages keyboard events for options in the panel. */
_keyManager: ListKeyManager;
+ /** View -> model callback called when value changes */
+ _onChange: (value: any) => void;
+
+ /** View -> model callback called when select has been touched */
+ _onTouched: Function;
+
/** This position config ensures that the top left corner of the overlay
* is aligned with with the top left of the origin (overlapping the trigger
* completely). In RTL mode, the top right corners are aligned instead.
@@ -73,11 +87,23 @@ export class MdSelect implements AfterContentInit, OnDestroy {
@ContentChildren(MdOption) options: QueryList;
@Input() placeholder: string;
+
+ @Input()
+ get required() {
+ return this._required;
+ }
+
+ set required(value: any) {
+ this._required = coerceBooleanProperty(value);
+ }
+
@Output() onOpen = new EventEmitter();
@Output() onClose = new EventEmitter();
constructor(private _element: ElementRef, private _renderer: Renderer,
- @Optional() private _dir: Dir) {}
+ @Optional() private _dir: Dir, @Optional() private _control: NgControl) {
+ this._control.valueAccessor = this;
+ }
ngAfterContentInit() {
this._initKeyManager();
@@ -110,6 +136,38 @@ export class MdSelect implements AfterContentInit, OnDestroy {
this._panelOpen = false;
}
+ /**
+ * Sets the select's value. Part of the ControlValueAccessor interface
+ * required to integrate with Angular's core forms API.
+ */
+ writeValue(value: any): void {
+ if (!this.options) { return; }
+
+ this.options.forEach((option: MdOption) => {
+ if (option.value === value) {
+ option.select();
+ }
+ });
+ }
+
+ /**
+ * Saves a callback function to be invoked when the select's value
+ * changes from user input. Part of the ControlValueAccessor interface
+ * required to integrate with Angular's core forms API.
+ */
+ registerOnChange(fn: (value: any) => void): void {
+ this._onChange = fn;
+ }
+
+ /**
+ * Saves a callback function to be invoked when the select is blurred
+ * by the user. Part of the ControlValueAccessor interface required
+ * to integrate with Angular's core forms API.
+ */
+ registerOnTouched(fn: Function): void {
+ this._onTouched = fn;
+ }
+
/** Whether or not the overlay panel is open. */
get panelOpen(): boolean {
return this._panelOpen;
@@ -163,6 +221,16 @@ export class MdSelect implements AfterContentInit, OnDestroy {
}
}
+ /**
+ * Calls the touched callback only if the panel is closed. Otherwise, the trigger will
+ * "blur" to the panel when it opens, causing a false positive.
+ */
+ _onBlur() {
+ if (!this.panelOpen) {
+ this._onTouched();
+ }
+ }
+
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
private _initKeyManager() {
this._keyManager = new ListKeyManager(this.options);
@@ -174,7 +242,12 @@ export class MdSelect implements AfterContentInit, OnDestroy {
/** Listens to selection events on each option. */
private _listenToOptions(): void {
this.options.forEach((option: MdOption) => {
- const sub = option.onSelect.subscribe(() => this._onSelect(option));
+ const sub = option.onSelect.subscribe((isUserInput: boolean) => {
+ if (isUserInput) {
+ this._onChange(option.value);
+ }
+ this._onSelect(option);
+ });
this._subscriptions.push(sub);
});
}