diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html
index d18b8c79f11c..1124b22a5e2f 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.html
+++ b/src/demo-app/autocomplete/autocomplete-demo.html
@@ -2,7 +2,8 @@
- Reactive value: {{ stateCtrl.value }}
+ Reactive length: {{ reactiveStates.length }}
+ Reactive value: {{ stateCtrl.value | json }}
Reactive dirty: {{ stateCtrl.dirty }}
@@ -11,7 +12,7 @@
-
+
@@ -39,8 +40,8 @@
-
-
+
+
{{ state.name }}
({{state.code}})
@@ -49,6 +50,5 @@
{{ state.name }}
- ({{state.code}})
\ No newline at end of file
diff --git a/src/demo-app/autocomplete/autocomplete-demo.scss b/src/demo-app/autocomplete/autocomplete-demo.scss
index 5789ae0ee434..94821e89494d 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.scss
+++ b/src/demo-app/autocomplete/autocomplete-demo.scss
@@ -3,7 +3,7 @@
flex-flow: row wrap;
md-card {
- width: 350px;
+ width: 400px;
margin: 24px;
}
diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts
index a67bf1b65462..ab608087de19 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.ts
+++ b/src/demo-app/autocomplete/autocomplete-demo.ts
@@ -1,6 +1,7 @@
-import {Component, OnDestroy, ViewEncapsulation} from '@angular/core';
+import {Component, ViewEncapsulation} from '@angular/core';
import {FormControl} from '@angular/forms';
-import {Subscription} from 'rxjs/Subscription';
+import {Observable} from 'rxjs/Observable';
+import 'rxjs/add/operator/startWith';
@Component({
moduleId: module.id,
@@ -9,15 +10,14 @@ import {Subscription} from 'rxjs/Subscription';
styleUrls: ['autocomplete-demo.css'],
encapsulation: ViewEncapsulation.None
})
-export class AutocompleteDemo implements OnDestroy {
- stateCtrl = new FormControl();
+export class AutocompleteDemo {
+ stateCtrl: FormControl;
currentState = '';
topHeightCtrl = new FormControl(0);
- reactiveStates: any[];
+ reactiveStates: Observable;
tdStates: any[];
- reactiveValueSub: Subscription;
tdDisabled = false;
states = [
@@ -74,19 +74,20 @@ export class AutocompleteDemo implements OnDestroy {
];
constructor() {
- this.reactiveStates = this.states;
this.tdStates = this.states;
- this.reactiveValueSub =
- this.stateCtrl.valueChanges.subscribe(val => this.reactiveStates = this.filterStates(val));
+ this.stateCtrl = new FormControl({code: 'CA', name: 'California'});
+ this.reactiveStates = this.stateCtrl.valueChanges
+ .startWith(this.stateCtrl.value)
+ .map(val => this.displayFn(val))
+ .map(name => this.filterStates(name));
+ }
+ displayFn(value: any): string {
+ return value && typeof value === 'object' ? value.name : value;
}
filterStates(val: string) {
return val ? this.states.filter((s) => s.name.match(new RegExp(val, 'gi'))) : this.states;
}
- ngOnDestroy() {
- this.reactiveValueSub.unsubscribe();
- }
-
}
diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts
index e6e74ef328fb..bcf3384e1210 100644
--- a/src/lib/autocomplete/autocomplete-trigger.ts
+++ b/src/lib/autocomplete/autocomplete-trigger.ts
@@ -1,7 +1,14 @@
import {
- AfterContentInit, Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
+ AfterContentInit,
+ Directive,
+ ElementRef,
+ forwardRef,
+ Input,
+ Optional,
+ OnDestroy,
+ ViewContainerRef,
} from '@angular/core';
-import {NgControl} from '@angular/forms';
+import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
import {MdAutocomplete} from './autocomplete';
import {PositionStrategy} from '../core/overlay/position/position-strategy';
@@ -28,6 +35,16 @@ export const AUTOCOMPLETE_OPTION_HEIGHT = 48;
/** The total height of the autocomplete panel. */
export const AUTOCOMPLETE_PANEL_HEIGHT = 256;
+/**
+ * Provider that allows the autocomplete to register as a ControlValueAccessor.
+ * @docs-private
+ */
+export const MD_AUTOCOMPLETE_VALUE_ACCESSOR: any = {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => MdAutocompleteTrigger),
+ multi: true
+};
+
@Directive({
selector: 'input[mdAutocomplete], input[matAutocomplete]',
host: {
@@ -39,10 +56,13 @@ export const AUTOCOMPLETE_PANEL_HEIGHT = 256;
'[attr.aria-expanded]': 'panelOpen.toString()',
'[attr.aria-owns]': 'autocomplete?.id',
'(focus)': 'openPanel()',
+ '(blur)': '_onTouched()',
+ '(input)': '_onChange($event.target.value)',
'(keydown)': '_handleKeydown($event)',
- }
+ },
+ providers: [MD_AUTOCOMPLETE_VALUE_ACCESSOR]
})
-export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
+export class MdAutocompleteTrigger implements AfterContentInit, ControlValueAccessor, OnDestroy {
private _overlayRef: OverlayRef;
private _portal: TemplatePortal;
private _panelOpen: boolean = false;
@@ -54,12 +74,18 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
private _keyManager: ActiveDescendantKeyManager;
private _positionStrategy: ConnectedPositionStrategy;
+ /** View -> model callback called when value changes */
+ _onChange: (value: any) => {};
+
+ /** View -> model callback called when autocomplete has been touched */
+ _onTouched = () => {};
+
/* The autocomplete panel to be attached to this trigger. */
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
constructor(private _element: ElementRef, private _overlay: Overlay,
private _viewContainerRef: ViewContainerRef,
- @Optional() private _controlDir: NgControl, @Optional() private _dir: Dir) {}
+ @Optional() private _dir: Dir) {}
ngAfterContentInit() {
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options).withWrap();
@@ -123,6 +149,38 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
return this._keyManager.activeItem as MdOption;
}
+ /**
+ * Sets the autocomplete's value. Part of the ControlValueAccessor interface
+ * required to integrate with Angular's core forms API.
+ *
+ * @param value New value to be written to the model.
+ */
+ writeValue(value: any): void {
+ Promise.resolve(null).then(() => this._setTriggerValue(value));
+ }
+
+ /**
+ * Saves a callback function to be invoked when the autocomplete's value
+ * changes from user input. Part of the ControlValueAccessor interface
+ * required to integrate with Angular's core forms API.
+ *
+ * @param fn Callback to be triggered when the value changes.
+ */
+ registerOnChange(fn: (value: any) => {}): void {
+ this._onChange = fn;
+ }
+
+ /**
+ * Saves a callback function to be invoked when the autocomplete is blurred
+ * by the user. Part of the ControlValueAccessor interface required
+ * to integrate with Angular's core forms API.
+ *
+ * @param fn Callback to be triggered when the component has been touched.
+ */
+ registerOnTouched(fn: () => {}) {
+ this._onTouched = fn;
+ }
+
_handleKeydown(event: KeyboardEvent): void {
if (this.activeOption && event.keyCode === ENTER) {
this.activeOption._selectViaInteraction();
@@ -178,6 +236,11 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
}
}
+ private _setTriggerValue(value: any): void {
+ this._element.nativeElement.value =
+ this.autocomplete.displayWith ? this.autocomplete.displayWith(value) : value;
+ }
+
/**
* This method closes the panel, and if a value is specified, also sets the associated
* control to that value. It will also mark the control as dirty if this interaction
@@ -185,10 +248,8 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
*/
private _setValueAndClose(event: MdOptionSelectEvent | null): void {
if (event) {
- this._controlDir.control.setValue(event.source.value);
- if (event.isUserInput) {
- this._controlDir.control.markAsDirty();
- }
+ this._setTriggerValue(event.source.value);
+ this._onChange(event.source.value);
}
this.closePanel();
diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts
index 9b62e2d80987..5a5671c5a70e 100644
--- a/src/lib/autocomplete/autocomplete.spec.ts
+++ b/src/lib/autocomplete/autocomplete.spec.ts
@@ -11,6 +11,7 @@ import {ENTER, DOWN_ARROW, SPACE, UP_ARROW} from '../core/keyboard/keycodes';
import {MdOption} from '../core/option/option';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import {FakeViewportRuler} from '../core/overlay/position/fake-viewport-ruler';
+import {MdAutocomplete} from './autocomplete';
describe('MdAutocomplete', () => {
@@ -201,7 +202,26 @@ describe('MdAutocomplete', () => {
input = fixture.debugElement.query(By.css('input')).nativeElement;
});
- it('should fill the text field when an option is selected', () => {
+ it('should update control value as user types with input value', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ input.value = 'a';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.value)
+ .toEqual('a', 'Expected control value to be updated as user types.');
+
+ input.value = 'al';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.value)
+ .toEqual('al', 'Expected control value to be updated as user types.');
+ });
+
+ it('should update control value when option is selected with option value', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
@@ -210,8 +230,126 @@ describe('MdAutocomplete', () => {
options[1].click();
fixture.detectChanges();
- expect(input.value)
- .toContain('California', `Expected text field to fill with selected value.`);
+ expect(fixture.componentInstance.stateCtrl.value)
+ .toEqual({code: 'CA', name: 'California'}, 'Expected control value to be option value.');
+ });
+
+ it('should update control back to string if user types after option is selected', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ const options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+ fixture.detectChanges();
+
+ input.value = 'Californi';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.value)
+ .toEqual('Californi', 'Expected control value to revert back to string.');
+ });
+
+ it('should fill the text field with display value when an option is selected', async(() => {
+ fixture.whenStable().then(() => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ const options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+ fixture.detectChanges();
+
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ expect(input.value)
+ .toContain('California', `Expected text field to fill with selected value.`);
+ });
+ });
+ }));
+
+ it('should fill the text field with value if displayWith is not set', async(() => {
+ fixture.whenStable().then(() => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ fixture.componentInstance.panel.displayWith = null;
+ fixture.componentInstance.options.toArray()[1].value = 'test value';
+ fixture.detectChanges();
+
+ const options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ expect(input.value)
+ .toContain('test value', `Expected input to fall back to selected option's value.`);
+ });
+ });
+ }));
+
+ it('should fill the text field correctly if value is set to obj programmatically', async(() => {
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ fixture.componentInstance.stateCtrl.setValue({code: 'AL', name: 'Alabama'});
+
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ expect(input.value)
+ .toContain('Alabama', `Expected input to fill with matching option's viewValue.`);
+ });
+ });
+ }));
+
+ it('should clear the text field if value is reset programmatically', async(() => {
+ fixture.whenStable().then(() => {
+ input.value = 'Alabama';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ expect(input.value).toEqual('Alabama', `Expected input to start out with a value.`);
+ fixture.componentInstance.stateCtrl.reset();
+
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ expect(input.value).toEqual('', `Expected input value to be empty after reset.`);
+ });
+ });
+ }));
+
+ it('should disable input in view when disabled programmatically', () => {
+ const inputUnderline =
+ fixture.debugElement.query(By.css('.md-input-underline')).nativeElement;
+
+ expect(input.disabled)
+ .toBe(false, `Expected input to start out enabled in view.`);
+ expect(inputUnderline.classList.contains('md-disabled'))
+ .toBe(false, `Expected input underline to start out with normal styles.`);
+
+ fixture.componentInstance.stateCtrl.disable();
+ fixture.detectChanges();
+
+ expect(input.disabled)
+ .toBe(true, `Expected input to be disabled in view when disabled programmatically.`);
+ expect(inputUnderline.classList.contains('md-disabled'))
+ .toBe(true, `Expected input underline to display disabled styles.`);
+ });
+
+
+ it('should mark the autocomplete control as dirty as user types', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(false, `Expected control to start out pristine.`);
+
+ input.value = 'a';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(true, `Expected control to become dirty when the user types into the input.`);
});
it('should mark the autocomplete control as dirty when an option is selected', () => {
@@ -240,6 +378,19 @@ describe('MdAutocomplete', () => {
.toBe(false, `Expected control to stay pristine if value is set programmatically.`);
});
+ it('should mark the autocomplete control as touched on blur', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.stateCtrl.touched)
+ .toBe(false, `Expected control to start out untouched.`);
+
+ dispatchEvent('blur', input);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.touched)
+ .toBe(true, `Expected control to become touched on blur.`);
+ });
+
});
describe('keyboard events', () => {
@@ -353,17 +504,21 @@ describe('MdAutocomplete', () => {
});
}));
- it('should fill the text field when an option is selected with ENTER', () => {
- fixture.componentInstance.trigger.openPanel();
- fixture.detectChanges();
+ it('should fill the text field when an option is selected with ENTER', async(() => {
+ fixture.whenStable().then(() => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
- fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
- fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
- fixture.detectChanges();
+ fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
+ fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
- expect(input.value)
- .toContain('Alabama', `Expected text field to fill with selected value on ENTER.`);
- });
+ fixture.whenStable().then(() => {
+ fixture.detectChanges();
+ expect(input.value)
+ .toContain('Alabama', `Expected text field to fill with selected value on ENTER.`);
+ });
+ });
+ }));
it('should fill the text field, not select an option, when SPACE is entered', () => {
fixture.componentInstance.trigger.openPanel();
@@ -614,9 +769,9 @@ describe('MdAutocomplete', () => {
-
-
- {{ state.name }} ({{ state.code }})
+
+
+ {{ state.code }}: {{ state.name }}
`
@@ -627,6 +782,7 @@ class SimpleAutocomplete implements OnDestroy {
valueSub: Subscription;
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
+ @ViewChild(MdAutocomplete) panel: MdAutocomplete;
@ViewChildren(MdOption) options: QueryList;
states = [
@@ -643,6 +799,7 @@ class SimpleAutocomplete implements OnDestroy {
{code: 'WY', name: 'Wyoming'},
];
+
constructor() {
this.filteredStates = this.states;
this.valueSub = this.stateCtrl.valueChanges.subscribe(val => {
@@ -651,6 +808,10 @@ class SimpleAutocomplete implements OnDestroy {
});
}
+ displayFn(value: any): string {
+ return value ? value.name : value;
+ }
+
ngOnDestroy() {
this.valueSub.unsubscribe();
}
@@ -658,6 +819,7 @@ class SimpleAutocomplete implements OnDestroy {
}
+
/**
* TODO: Move this to core testing utility until Angular has event faking
* support.
diff --git a/src/lib/autocomplete/autocomplete.ts b/src/lib/autocomplete/autocomplete.ts
index 792478fbbbe3..2495882dfbb4 100644
--- a/src/lib/autocomplete/autocomplete.ts
+++ b/src/lib/autocomplete/autocomplete.ts
@@ -2,6 +2,7 @@ import {
Component,
ContentChildren,
ElementRef,
+ Input,
QueryList,
TemplateRef,
ViewChild,
@@ -33,6 +34,9 @@ export class MdAutocomplete {
@ViewChild('panel') panel: ElementRef;
@ContentChildren(MdOption) options: QueryList;
+ /** Function that maps an option's control value to its display value in the trigger. */
+ @Input() displayWith: (value: any) => string;
+
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`;
@@ -41,7 +45,9 @@ export class MdAutocomplete {
* options below the fold, as they are not actually being focused when active.
*/
_setScrollTop(scrollTop: number): void {
- this.panel.nativeElement.scrollTop = scrollTop;
+ if (this.panel) {
+ this.panel.nativeElement.scrollTop = scrollTop;
+ }
}
/** Sets a class on the panel based on its position (used to set y-offset). */