mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(autocomplete): add value support (#2516)
This commit is contained in:
@@ -1,9 +1,52 @@
|
||||
<div class="demo-autocomplete">
|
||||
<md-input-container>
|
||||
<input mdInput placeholder="State" [mdAutocomplete]="auto">
|
||||
</md-input-container>
|
||||
<md-card>
|
||||
<div>Reactive value: {{ stateCtrl.value }}</div>
|
||||
<div>Reactive dirty: {{ stateCtrl.dirty }}</div>
|
||||
|
||||
<md-autocomplete #auto="mdAutocomplete">
|
||||
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
|
||||
</md-autocomplete>
|
||||
<md-input-container>
|
||||
<input mdInput placeholder="State" [mdAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
|
||||
</md-input-container>
|
||||
|
||||
<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.enabled ? stateCtrl.disable() : stateCtrl.enable()">
|
||||
TOGGLE DISABLED
|
||||
</button>
|
||||
</md-card-actions>
|
||||
|
||||
</md-card>
|
||||
|
||||
<md-card>
|
||||
<div>Template-driven value (currentState): {{ currentState }}</div>
|
||||
<div>Template-driven dirty: {{ modelDir.dirty }}</div>
|
||||
|
||||
<md-input-container>
|
||||
<input mdInput placeholder="State" [mdAutocomplete]="tdAuto" [(ngModel)]="currentState" #modelDir="ngModel"
|
||||
(ngModelChange)="this.tdStates = filterStates(currentState)" [disabled]="tdDisabled">
|
||||
</md-input-container>
|
||||
|
||||
<md-card-actions>
|
||||
<button md-button (click)="modelDir.reset()">RESET</button>
|
||||
<button md-button (click)="currentState='California'">SET VALUE</button>
|
||||
<button md-button (click)="tdDisabled=!tdDisabled">
|
||||
TOGGLE DISABLED
|
||||
</button>
|
||||
</md-card-actions>
|
||||
|
||||
</md-card>
|
||||
</div>
|
||||
|
||||
<md-autocomplete #reactiveAuto="mdAutocomplete">
|
||||
<md-option *ngFor="let state of reactiveStates" [value]="state.name">
|
||||
<span>{{ state.name }}</span>
|
||||
<span class="demo-secondary-text"> ({{state.code}}) </span>
|
||||
</md-option>
|
||||
</md-autocomplete>
|
||||
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<SimpleAutocomplete>;
|
||||
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<HTMLElement>;
|
||||
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<HTMLElement>;
|
||||
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<SimpleAutocomplete>;
|
||||
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<HTMLElement>;
|
||||
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<HTMLElement>;
|
||||
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: `
|
||||
<md-input-container>
|
||||
<input mdInput placeholder="State" [mdAutocomplete]="auto">
|
||||
<input mdInput placeholder="State" [mdAutocomplete]="auto" [formControl]="stateCtrl">
|
||||
</md-input-container>
|
||||
|
||||
<md-autocomplete #auto="mdAutocomplete">
|
||||
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
|
||||
<md-option *ngFor="let state of filteredStates" [value]="state.name">
|
||||
{{ state.name }} ({{ state.code }})
|
||||
</md-option>
|
||||
</md-autocomplete>
|
||||
`
|
||||
})
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 `<md-select>` 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<MdOptionSelectEvent>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user