feat(autocomplete): allow use of obj values (#2792)

This commit is contained in:
Kara
2017-01-27 17:10:04 -08:00
parent fd5e4d9b9f
commit 55e1847455
6 changed files with 274 additions and 44 deletions
@@ -2,7 +2,8 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
<div [style.height.px]="topHeightCtrl.value"></div>
<div class="demo-autocomplete">
<md-card>
<div>Reactive value: {{ stateCtrl.value }}</div>
Reactive length: {{ reactiveStates.length }}
<div>Reactive value: {{ stateCtrl.value | json }}</div>
<div>Reactive dirty: {{ stateCtrl.dirty }}</div>
<md-input-container>
@@ -11,7 +12,7 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
<md-card-actions>
<button md-button (click)="stateCtrl.reset()">RESET</button>
<button md-button (click)="stateCtrl.setValue('California')">SET VALUE</button>
<button md-button (click)="stateCtrl.setValue(states[10])">SET VALUE</button>
<button md-button (click)="stateCtrl.enabled ? stateCtrl.disable() : stateCtrl.enable()">
TOGGLE DISABLED
</button>
@@ -39,8 +40,8 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
</md-card>
</div>
<md-autocomplete #reactiveAuto="mdAutocomplete">
<md-option *ngFor="let state of reactiveStates" [value]="state.name">
<md-autocomplete #reactiveAuto="mdAutocomplete" [displayWith]="displayFn">
<md-option *ngFor="let state of reactiveStates | async" [value]="state">
<span>{{ state.name }}</span>
<span class="demo-secondary-text"> ({{state.code}}) </span>
</md-option>
@@ -49,6 +50,5 @@ Space above cards: <input type="number" [formControl]="topHeightCtrl">
<md-autocomplete #tdAuto="mdAutocomplete">
<md-option *ngFor="let state of tdStates" [value]="state.name">
<span>{{ state.name }}</span>
<span class="demo-secondary-text"> ({{state.code}}) </span>
</md-option>
</md-autocomplete>
@@ -3,7 +3,7 @@
flex-flow: row wrap;
md-card {
width: 350px;
width: 400px;
margin: 24px;
}
+14 -13
View File
@@ -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<any>;
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();
}
}
+70 -9
View File
@@ -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();
+177 -15
View File
@@ -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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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', () => {
<input mdInput placeholder="State" [mdAutocomplete]="auto" [formControl]="stateCtrl">
</md-input-container>
<md-autocomplete #auto="mdAutocomplete">
<md-option *ngFor="let state of filteredStates" [value]="state.name">
{{ state.name }} ({{ state.code }})
<md-autocomplete #auto="mdAutocomplete" [displayWith]="displayFn">
<md-option *ngFor="let state of filteredStates" [value]="state">
<span> {{ state.code }}: {{ state.name }} </span>
</md-option>
</md-autocomplete>
`
@@ -627,6 +782,7 @@ class SimpleAutocomplete implements OnDestroy {
valueSub: Subscription;
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
@ViewChild(MdAutocomplete) panel: MdAutocomplete;
@ViewChildren(MdOption) options: QueryList<MdOption>;
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.
+7 -1
View File
@@ -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<MdOption>;
/** 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). */