diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html
index 3ab4a1eaabd5..95e6a345b179 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.html
+++ b/src/demo-app/autocomplete/autocomplete-demo.html
@@ -1,9 +1,52 @@
+
+
+
+ {{ state.name }}
+ ({{state.code}})
+
+
+
+
+
+ {{ 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 94c86ec8589d..5789ae0ee434 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.scss
+++ b/src/demo-app/autocomplete/autocomplete-demo.scss
@@ -1 +1,18 @@
-.demo-autocomplete {}
+.demo-autocomplete {
+ display: flex;
+ flex-flow: row wrap;
+
+ md-card {
+ width: 350px;
+ margin: 24px;
+ }
+
+ md-input-container {
+ margin-top: 16px;
+ }
+}
+
+.demo-secondary-text {
+ color: rgba(0, 0, 0, 0.54);
+ margin-left: 8px;
+}
diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts
index c06a099fd343..50ae3077dc4d 100644
--- a/src/demo-app/autocomplete/autocomplete-demo.ts
+++ b/src/demo-app/autocomplete/autocomplete-demo.ts
@@ -1,12 +1,24 @@
-import {Component} from '@angular/core';
+import {Component, OnDestroy, ViewEncapsulation} from '@angular/core';
+import {FormControl} from '@angular/forms';
+import {Subscription} from 'rxjs/Subscription';
@Component({
moduleId: module.id,
selector: 'autocomplete-demo',
templateUrl: 'autocomplete-demo.html',
styleUrls: ['autocomplete-demo.css'],
+ encapsulation: ViewEncapsulation.None
})
-export class AutocompleteDemo {
+export class AutocompleteDemo implements OnDestroy {
+ stateCtrl = new FormControl();
+ currentState = '';
+
+ reactiveStates: any[];
+ tdStates: any[];
+
+ reactiveValueSub: Subscription;
+ tdDisabled = false;
+
states = [
{code: 'AL', name: 'Alabama'},
{code: 'AZ', name: 'Arizona'},
@@ -35,4 +47,21 @@ export class AutocompleteDemo {
{code: 'WI', name: 'Wisconsin'},
{code: 'WY', name: 'Wyoming'},
];
+
+ constructor() {
+ this.reactiveStates = this.states;
+ this.tdStates = this.states;
+ this.reactiveValueSub =
+ this.stateCtrl.valueChanges.subscribe(val => this.reactiveStates = this.filterStates(val));
+
+ }
+
+ 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 a7396e6bc9d7..42d623b88da1 100644
--- a/src/lib/autocomplete/autocomplete-trigger.ts
+++ b/src/lib/autocomplete/autocomplete-trigger.ts
@@ -1,13 +1,17 @@
import {
Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
} from '@angular/core';
+import {NgControl} from '@angular/forms';
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
import {MdAutocomplete} from './autocomplete';
import {PositionStrategy} from '../core/overlay/position/position-strategy';
import {Observable} from 'rxjs/Observable';
-import {Subscription} from 'rxjs/Subscription';
+import {MdOptionSelectEvent} from '../core/option/option';
import 'rxjs/add/observable/merge';
import {Dir} from '../core/rtl/dir';
+import 'rxjs/add/operator/startWith';
+import 'rxjs/add/operator/switchMap';
+
/** The panel needs a slight y-offset to ensure the input underline displays. */
export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
@@ -23,14 +27,12 @@ export class MdAutocompleteTrigger implements OnDestroy {
private _portal: TemplatePortal;
private _panelOpen: boolean = false;
- /** The subscription to events that close the autocomplete panel. */
- private _closingActionsSubscription: Subscription;
-
/* 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 _dir: Dir) {}
+ private _viewContainerRef: ViewContainerRef,
+ @Optional() private _controlDir: NgControl, @Optional() private _dir: Dir) {}
ngOnDestroy() { this._destroyPanel(); }
@@ -47,8 +49,7 @@ export class MdAutocompleteTrigger implements OnDestroy {
if (!this._overlayRef.hasAttached()) {
this._overlayRef.attach(this._portal);
- this._closingActionsSubscription =
- this.panelClosingActions.subscribe(() => this.closePanel());
+ this._subscribeToClosingActions();
}
this._panelOpen = true;
@@ -60,7 +61,6 @@ export class MdAutocompleteTrigger implements OnDestroy {
this._overlayRef.detach();
}
- this._closingActionsSubscription.unsubscribe();
this._panelOpen = false;
}
@@ -78,6 +78,25 @@ export class MdAutocompleteTrigger implements OnDestroy {
return this.autocomplete.options.map(option => option.onSelect);
}
+
+ /**
+ * This method listens to a stream of panel closing actions and resets the
+ * stream every time the option list changes.
+ */
+ private _subscribeToClosingActions(): void {
+ // Every time the option list changes...
+ this.autocomplete.options.changes
+ // and also at initialization, before there are any option changes...
+ .startWith(null)
+ // create a new stream of panelClosingActions, replacing any previous streams
+ // that were created, and flatten it so our stream only emits closing events...
+ .switchMap(() => this.panelClosingActions)
+ // when the first closing event occurs...
+ .first()
+ // set the value, close the panel, and complete.
+ .subscribe(event => this._setValueAndClose(event));
+ }
+
/** Destroys the autocomplete suggestion panel. */
private _destroyPanel(): void {
if (this._overlayRef) {
@@ -87,6 +106,22 @@ export class MdAutocompleteTrigger implements OnDestroy {
}
}
+ /**
+ * 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
+ * stemmed from the user.
+ */
+ private _setValueAndClose(event: MdOptionSelectEvent | null): void {
+ if (event) {
+ this._controlDir.control.setValue(event.source.value);
+ if (event.isUserInput) {
+ this._controlDir.control.markAsDirty();
+ }
+ }
+
+ this.closePanel();
+ }
+
private _createOverlay(): void {
this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef);
this._overlayRef = this._overlay.create(this._getOverlayConfig());
diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts
index 9b4b6e40b7cb..bc02469a4d43 100644
--- a/src/lib/autocomplete/autocomplete.spec.ts
+++ b/src/lib/autocomplete/autocomplete.spec.ts
@@ -1,10 +1,12 @@
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
-import {Component, ViewChild} from '@angular/core';
+import {Component, OnDestroy, ViewChild} from '@angular/core';
import {By} from '@angular/platform-browser';
import {MdAutocompleteModule, MdAutocompleteTrigger} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdInputModule} from '../input/index';
import {Dir, LayoutDirection} from '../core/rtl/dir';
+import {FormControl, ReactiveFormsModule} from '@angular/forms';
+import {Subscription} from 'rxjs/Subscription';
describe('MdAutocomplete', () => {
let overlayContainerElement: HTMLElement;
@@ -13,7 +15,9 @@ describe('MdAutocomplete', () => {
beforeEach(async(() => {
dir = 'ltr';
TestBed.configureTestingModule({
- imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()],
+ imports: [
+ MdAutocompleteModule.forRoot(), MdInputModule.forRoot(), ReactiveFormsModule
+ ],
declarations: [SimpleAutocomplete],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -37,18 +41,18 @@ describe('MdAutocomplete', () => {
describe('panel toggling', () => {
let fixture: ComponentFixture;
- let trigger: HTMLElement;
+ let input: HTMLInputElement;
beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
fixture.detectChanges();
- trigger = fixture.debugElement.query(By.css('input')).nativeElement;
+ input = fixture.debugElement.query(By.css('input')).nativeElement;
});
it('should open the panel when the input is focused', () => {
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
- dispatchEvent('focus', trigger);
+ dispatchEvent('focus', input);
fixture.detectChanges();
expect(fixture.componentInstance.trigger.panelOpen)
@@ -73,7 +77,7 @@ describe('MdAutocomplete', () => {
});
it('should close the panel when a click occurs outside it', async(() => {
- dispatchEvent('focus', trigger);
+ dispatchEvent('focus', input);
fixture.detectChanges();
const backdrop =
@@ -90,7 +94,7 @@ describe('MdAutocomplete', () => {
}));
it('should close the panel when an option is clicked', async(() => {
- dispatchEvent('focus', trigger);
+ dispatchEvent('focus', input);
fixture.detectChanges();
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
@@ -105,22 +109,47 @@ describe('MdAutocomplete', () => {
});
}));
- it('should close the panel when a newly created option is clicked', async(() => {
- fixture.componentInstance.states.unshift({code: 'TEST', name: 'test'});
+ it('should close the panel when a newly filtered option is clicked', async(() => {
+ dispatchEvent('focus', input);
fixture.detectChanges();
- dispatchEvent('focus', trigger);
+ // Filter down the option list to a subset of original options ('Alabama', 'California')
+ input.value = 'al';
+ dispatchEvent('input', input);
fixture.detectChanges();
- const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
- option.click();
+ let options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[0].click();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
- .toBe(false, `Expected clicking a new option to set the panel state to closed.`);
+ .toBe(false, `Expected clicking a filtered option to set the panel state to closed.`);
expect(overlayContainerElement.textContent)
- .toEqual('', `Expected clicking a new option to close the panel.`);
+ .toEqual('', `Expected clicking a filtered option to close the panel.`);
+
+ dispatchEvent('focus', input);
+ fixture.detectChanges();
+
+ // Changing value from 'Alabama' to 'al' to re-populate the option list,
+ // ensuring that 'California' is created new.
+ input.value = 'al';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+ fixture.detectChanges();
+
+ fixture.whenStable().then(() => {
+ expect(fixture.componentInstance.trigger.panelOpen)
+ .toBe(false, `Expected clicking a new option to set the panel state to closed.`);
+ expect(overlayContainerElement.textContent)
+ .toEqual('', `Expected clicking a new option to close the panel.`);
+ });
+
});
}));
@@ -152,6 +181,59 @@ describe('MdAutocomplete', () => {
const overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(overlayPane.getAttribute('dir')).toEqual('rtl');
+
+ });
+
+ describe('forms integration', () => {
+ let fixture: ComponentFixture;
+ let input: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SimpleAutocomplete);
+ fixture.detectChanges();
+
+ input = fixture.debugElement.query(By.css('input')).nativeElement;
+ });
+
+ it('should fill the text field when an option is selected', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ const options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+ fixture.detectChanges();
+
+ expect(input.value)
+ .toContain('California', `Expected text field to be filled with selected value.`);
+ });
+
+ it('should mark the autocomplete control as dirty when an option is selected', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(false, `Expected control to start out pristine.`);
+
+ const options =
+ overlayContainerElement.querySelectorAll('md-option') as NodeListOf;
+ options[1].click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(true, `Expected control to become dirty when an option was selected.`);
+ });
+
+ it('should not mark the control dirty when the value is set programmatically', () => {
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(false, `Expected control to start out pristine.`);
+
+ fixture.componentInstance.stateCtrl.setValue('AL');
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.stateCtrl.dirty)
+ .toBe(false, `Expected control to stay pristine if value is set programmatically.`);
+ });
+
});
});
@@ -159,15 +241,21 @@ describe('MdAutocomplete', () => {
@Component({
template: `
-
+
- {{ state.name }}
+
+ {{ state.name }} ({{ state.code }})
+
`
})
-class SimpleAutocomplete {
+class SimpleAutocomplete implements OnDestroy {
+ stateCtrl = new FormControl();
+ filteredStates: any[];
+ valueSub: Subscription;
+
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
states = [
@@ -183,6 +271,19 @@ class SimpleAutocomplete {
{code: 'VA', name: 'Virginia'},
{code: 'WY', name: 'Wyoming'},
];
+
+ constructor() {
+ this.filteredStates = this.states;
+ this.valueSub = this.stateCtrl.valueChanges.subscribe(val => {
+ this.filteredStates = val ? this.states.filter((s) => s.name.match(new RegExp(val, 'gi')))
+ : this.states;
+ });
+ }
+
+ ngOnDestroy() {
+ this.valueSub.unsubscribe();
+ }
+
}
diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts
index 140e6daee33e..2c7e0f9e85c7 100644
--- a/src/lib/core/option/option.ts
+++ b/src/lib/core/option/option.ts
@@ -20,6 +20,12 @@ import {MdRippleModule} from '../ripple/ripple';
*/
let _uniqueIdCounter = 0;
+/** Event object emitted by MdOption when selected. */
+export class MdOptionSelectEvent {
+ constructor(public source: MdOption, public isUserInput = false) {}
+}
+
+
/**
* Single option inside of a `` element.
*/
@@ -60,7 +66,7 @@ export class MdOption {
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
/** Event emitted when the option is selected. */
- @Output() onSelect = new EventEmitter();
+ @Output() onSelect = new EventEmitter();
constructor(private _element: ElementRef, private _renderer: Renderer) {}
@@ -81,7 +87,7 @@ export class MdOption {
/** Selects the option. */
select(): void {
this._selected = true;
- this.onSelect.emit();
+ this.onSelect.emit(new MdOptionSelectEvent(this, false));
}
/** Deselects the option. */
@@ -108,7 +114,7 @@ export class MdOption {
_selectViaInteraction() {
if (!this.disabled) {
this._selected = true;
- this.onSelect.emit(true);
+ this.onSelect.emit(new MdOptionSelectEvent(this, true));
}
}
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index 92b7d2efb366..1a3dabfd5fc6 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -14,7 +14,7 @@ import {
ViewEncapsulation,
ViewChild,
} from '@angular/core';
-import {MdOption} from '../core/option/option';
+import {MdOption, MdOptionSelectEvent} from '../core/option/option';
import {ENTER, SPACE} from '../core/keyboard/keycodes';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {Dir} from '../core/rtl/dir';
@@ -456,8 +456,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** Listens to selection events on each option. */
private _listenToOptions(): void {
this.options.forEach((option: MdOption) => {
- const sub = option.onSelect.subscribe((isUserInput: boolean) => {
- if (isUserInput && this._selected !== option) {
+ const sub = option.onSelect.subscribe((event: MdOptionSelectEvent) => {
+ if (event.isUserInput && this._selected !== option) {
this._emitChangeEvent(option);
}
this._onSelect(option);
diff --git a/tools/gulp/tasks/components.ts b/tools/gulp/tasks/components.ts
index 64102458d0d1..44c41d743260 100644
--- a/tools/gulp/tasks/components.ts
+++ b/tools/gulp/tasks/components.ts
@@ -85,6 +85,8 @@ task(':build:components:rollup', () => {
'rxjs/add/operator/finally': 'Rx.Observable.prototype',
'rxjs/add/operator/catch': 'Rx.Observable.prototype',
'rxjs/add/operator/first': 'Rx.Observable.prototype',
+ 'rxjs/add/operator/startWith': 'Rx.Observable.prototype',
+ 'rxjs/add/operator/switchMap': 'Rx.Observable.prototype',
'rxjs/Observable': 'Rx'
};