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). */