From 78a5be902708c9f8d08f3ed54ca3121a6ef23114 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Fri, 13 Jan 2017 11:04:11 -0800 Subject: [PATCH] feat(autocomplete): add value support --- .../autocomplete/autocomplete-demo.html | 55 ++++++- .../autocomplete/autocomplete-demo.scss | 19 ++- .../autocomplete/autocomplete-demo.ts | 33 ++++- src/lib/autocomplete/_autocomplete-theme.scss | 4 +- src/lib/autocomplete/autocomplete-trigger.ts | 54 +++++-- src/lib/autocomplete/autocomplete.spec.ts | 134 +++++++++++++++--- src/lib/core/option/option.ts | 12 +- src/lib/select/select.ts | 6 +- tools/gulp/tasks/components.ts | 2 + 9 files changed, 277 insertions(+), 42 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-theme.scss b/src/lib/autocomplete/_autocomplete-theme.scss index 5d0493df039c..e29b8bf66af2 100644 --- a/src/lib/autocomplete/_autocomplete-theme.scss +++ b/src/lib/autocomplete/_autocomplete-theme.scss @@ -4,10 +4,12 @@ $foreground: map-get($theme, foreground); $background: map-get($theme, background); - md-option { + .md-autocomplete-panel { background: md-color($background, card); color: md-color($foreground, text); + } + md-option { &.md-selected { background: md-color($background, card); color: md-color($foreground, text); diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index dfe1967149ab..e616cd72a749 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -1,10 +1,15 @@ -import {Directive, ElementRef, Input, ViewContainerRef, OnDestroy} from '@angular/core'; +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 '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; @@ -20,14 +25,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) {} + private _viewContainerRef: ViewContainerRef, + @Optional() private _controlDir: NgControl) {} ngOnDestroy() { this._destroyPanel(); } @@ -44,8 +47,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; @@ -57,7 +59,6 @@ export class MdAutocompleteTrigger implements OnDestroy { this._overlayRef.detach(); } - this._closingActionsSubscription.unsubscribe(); this._panelOpen = false; } @@ -75,6 +76,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) { @@ -84,6 +104,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 e7db384a3fbf..6ef866f5937f 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -1,16 +1,20 @@ 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 {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {Subscription} from 'rxjs/Subscription'; describe('MdAutocomplete', () => { let overlayContainerElement: HTMLElement; beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()], + imports: [ + MdAutocompleteModule.forRoot(), MdInputModule.forRoot(), ReactiveFormsModule + ], declarations: [SimpleAutocomplete], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -31,18 +35,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) @@ -67,7 +71,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 = @@ -84,7 +88,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; @@ -99,22 +103,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.`); + }); + }); })); @@ -135,20 +164,78 @@ describe('MdAutocomplete', () => { }); + 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.`); + }); + + }); + }); @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 = [ @@ -164,6 +251,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 8f408c1f15fa..802953f3b973 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -13,7 +13,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 {ListKeyManager} from '../core/a11y/list-key-manager'; import {Dir} from '../core/rtl/dir'; @@ -428,8 +428,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) { + const sub = option.onSelect.subscribe((event: MdOptionSelectEvent) => { + if (event.isUserInput) { this._onChange(option.value); } 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' };