From 902d72b725f4c0ae12e8d07b90bda70b63506ef3 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Tue, 3 Jan 2017 18:52:20 -0800 Subject: [PATCH] feat(autocomplete): add value support --- .../autocomplete/autocomplete-demo.html | 26 +++- .../autocomplete/autocomplete-demo.scss | 10 +- .../autocomplete/autocomplete-demo.ts | 24 +++- src/lib/autocomplete/_autocomplete-theme.scss | 4 +- src/lib/autocomplete/autocomplete-trigger.ts | 36 ++++- 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 | 1 + 9 files changed, 217 insertions(+), 37 deletions(-) diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html index 16eba9126aac..bb6d9bfe7cb8 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.html +++ b/src/demo-app/autocomplete/autocomplete-demo.html @@ -1,9 +1,23 @@
- - - + + + + - - {{ state.name }} - + + {{ state.name }} + + + +
Value: {{ stateCtrl.value }}
+
Status: {{ stateCtrl.status }}
+
Dirty: {{ stateCtrl.dirty }}
+ + + + + +
diff --git a/src/demo-app/autocomplete/autocomplete-demo.scss b/src/demo-app/autocomplete/autocomplete-demo.scss index 94c86ec8589d..1136cf4d7233 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.scss +++ b/src/demo-app/autocomplete/autocomplete-demo.scss @@ -1 +1,9 @@ -.demo-autocomplete {} +.demo-autocomplete { + display: flex; + flex-flow: row wrap; + + md-card { + width: 350px; + margin: 24px; + } +} diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts index c06a099fd343..6b79df59af56 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.ts +++ b/src/demo-app/autocomplete/autocomplete-demo.ts @@ -1,4 +1,7 @@ -import {Component} from '@angular/core'; +import {Component, OnDestroy} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {Subscription} from 'rxjs/Subscription'; +import 'rxjs/add/operator/debounceTime'; @Component({ moduleId: module.id, @@ -6,7 +9,10 @@ import {Component} from '@angular/core'; templateUrl: 'autocomplete-demo.html', styleUrls: ['autocomplete-demo.css'], }) -export class AutocompleteDemo { +export class AutocompleteDemo implements OnDestroy { + stateCtrl = new FormControl(); + filteredStates: any[]; + valueSub: Subscription; states = [ {code: 'AL', name: 'Alabama'}, {code: 'AZ', name: 'Arizona'}, @@ -35,4 +41,18 @@ export class AutocompleteDemo { {code: 'WI', name: 'Wisconsin'}, {code: 'WY', name: 'Wyoming'}, ]; + + constructor() { + this.filteredStates = this.states; + this.valueSub = this.stateCtrl.valueChanges.debounceTime(100).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/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 c037b403a096..bd01b35f0c24 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -1,10 +1,14 @@ -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 'rxjs/add/observable/merge'; +import {MdOptionSelectEvent} from '../core/option/option'; /** The panel needs a slight y-offset to ensure the input underline displays. */ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6; @@ -20,12 +24,13 @@ export class MdAutocompleteTrigger implements OnDestroy { private _portal: TemplatePortal; private _panelOpen: boolean = false; private _closeWatcher: Subscription; + private _optionWatcher: Subscription; /* The autocomplete panel to be attached to this trigger. */ @Input('mdAutocomplete') autocomplete: MdAutocomplete; constructor(private _element: ElementRef, private _overlay: Overlay, - private _vcr: ViewContainerRef) {} + private _vcr: ViewContainerRef, @Optional() private _controlDir: NgControl) {} ngOnDestroy() { this.destroyPanel(); } @@ -43,6 +48,8 @@ export class MdAutocompleteTrigger implements OnDestroy { if (!this._overlayRef.hasAttached()) { this._overlayRef.attach(this._portal); this._watchForClose(); + this._optionWatcher = + this.autocomplete.options.changes.subscribe(() => this._watchForClose()); } this._panelOpen = true; @@ -54,6 +61,7 @@ export class MdAutocompleteTrigger implements OnDestroy { this._overlayRef.detach(); } + this._optionWatcher.unsubscribe(); this._closeWatcher.unsubscribe(); this._panelOpen = false; } @@ -71,10 +79,13 @@ export class MdAutocompleteTrigger implements OnDestroy { * This method will close the panel if it receives a selection event from any of the options * or a click on the backdrop. */ - private _watchForClose() { + private _watchForClose(): void { // TODO(kara): add tab event watcher when adding keyboard events + if (this._closeWatcher) { + this._closeWatcher.unsubscribe(); + } this._closeWatcher = Observable.merge(...this._getOptionObs(), this._overlayRef.backdropClick()) - .subscribe(() => this.closePanel()); + .subscribe((event) => this._setValueAndClose(event)); } /** @@ -86,6 +97,23 @@ export class MdAutocompleteTrigger implements OnDestroy { return this.autocomplete.options.map((option) => option.onSelect); } + /** + * 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) { + // TODO(kara): revisit animation once floating placeholder is toggle-able + this._controlDir.control.setValue(event.source.viewValue); + if (event.isUserInput) { + this._controlDir.control.markAsDirty(); + } + } + + this.closePanel(); + } + private _createOverlay(): void { this._portal = new TemplatePortal(this.autocomplete.template, this._vcr); this._overlayRef = this._overlay.create(this._getOverlayConfig()); diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index a06b8727bcbc..9446f2a36ab8 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: () => { @@ -36,18 +40,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) @@ -72,7 +76,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 = overlayContainerElement.querySelector('.cdk-overlay-backdrop'); @@ -88,7 +92,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'); @@ -103,22 +107,48 @@ 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'); - option.click(); + // Click on 'Alabama' + 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.`); + }); + }); })); @@ -139,20 +169,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 }} + ` }) -class SimpleAutocomplete { +class SimpleAutocomplete implements OnDestroy { + stateCtrl = new FormControl(); + filteredStates: any[]; + valueSub: Subscription; + @ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger; states = [ @@ -168,6 +256,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 5eaae88e4ced..7bde05a66723 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. */ @@ -61,7 +67,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) {} @@ -82,7 +88,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. */ @@ -109,7 +115,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..4adfcc9e788a 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((result: MdOptionSelectEvent) => { + if (result.isUserInput) { this._onChange(option.value); } this._onSelect(option); diff --git a/tools/gulp/tasks/components.ts b/tools/gulp/tasks/components.ts index 64102458d0d1..990e753c6704 100644 --- a/tools/gulp/tasks/components.ts +++ b/tools/gulp/tasks/components.ts @@ -77,6 +77,7 @@ task(':build:components:rollup', () => { 'rxjs/add/observable/of': 'Rx.Observable', 'rxjs/add/observable/merge': 'Rx.Observable', 'rxjs/add/observable/throw': 'Rx.Observable', + 'rxjs/add/operator/debounceTime': 'Rx.Observable.prototype', 'rxjs/add/operator/toPromise': 'Rx.Observable.prototype', 'rxjs/add/operator/map': 'Rx.Observable.prototype', 'rxjs/add/operator/filter': 'Rx.Observable.prototype',