From 5def001e595384672dc974beb39f9b5f38180666 Mon Sep 17 00:00:00 2001 From: Kara Date: Thu, 19 Jan 2017 14:07:40 -0800 Subject: [PATCH] feat(autocomplete): add value support (#2516) --- .../autocomplete/autocomplete-demo.html | 55 ++++++- .../autocomplete/autocomplete-demo.scss | 19 ++- .../autocomplete/autocomplete-demo.ts | 33 ++++- src/lib/autocomplete/autocomplete-trigger.ts | 51 +++++-- src/lib/autocomplete/autocomplete.spec.ts | 135 +++++++++++++++--- src/lib/core/option/option.ts | 12 +- src/lib/select/select.ts | 6 +- tools/gulp/tasks/components.ts | 2 + 8 files changed, 273 insertions(+), 40 deletions(-) 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 @@
- - - + +
Reactive value: {{ stateCtrl.value }}
+
Reactive dirty: {{ stateCtrl.dirty }}
- - {{ state.name }} - + + + + + + + + + + +
+ + +
Template-driven value (currentState): {{ currentState }}
+
Template-driven dirty: {{ modelDir.dirty }}
+ + + + + + + + + + + +
+ + + + {{ 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' };