diff --git a/src/demo-app/select/select-demo.html b/src/demo-app/select/select-demo.html
index f1e903f4824b..c36a09f0dd44 100644
--- a/src/demo-app/select/select-demo.html
+++ b/src/demo-app/select/select-demo.html
@@ -1,45 +1,78 @@
This div is for testing scrolled selects.
-
-
-
- {{ food.viewValue }}
+
+ ngModel
+
+
+
+
+ {{ drink.viewValue }}
+
- Value: {{ foodControl.value }}
- Touched: {{ foodControl.touched }}
- Dirty: {{ foodControl.dirty }}
- Status: {{ foodControl.status }}
-
-
-
-
-
+
Value: {{ currentDrink }}
+
Touched: {{ drinkControl.touched }}
+
Dirty: {{ drinkControl.dirty }}
+
Status: {{ drinkControl.control?.status }}
+
+
+
+
+
+
-
-
- {{ drink.viewValue }}
-
-
- Value: {{ currentDrink }}
- Touched: {{ drinkControl.touched }}
- Dirty: {{ drinkControl.dirty }}
- Status: {{ drinkControl.control?.status }}
-
-
-
-
+ Multiple selection
+
+
+
+
+ {{ creature.viewValue }}
+
+
+ Value: {{ currentPokemon }}
+ Touched: {{ pokemonControl.touched }}
+ Dirty: {{ pokemonControl.dirty }}
+ Status: {{ pokemonControl.control?.status }}
+
+
+
+
+
-
- {{ starter.viewValue }}
-
+ formControl
+
+
+
+ {{ food.viewValue }}
+
+ Value: {{ foodControl.value }}
+ Touched: {{ foodControl.touched }}
+ Dirty: {{ foodControl.dirty }}
+ Status: {{ foodControl.status }}
+
+
+
+
+
+
+
+
+
+ Change event
+
+
+
+ {{ creature.viewValue }}
+
- Change event value: {{ latestChangeEvent?.value }}
+ Change event value: {{ latestChangeEvent?.value }}
+
diff --git a/src/demo-app/select/select-demo.ts b/src/demo-app/select/select-demo.ts
index 0b369e0c137b..d81bdba68e88 100644
--- a/src/demo-app/select/select-demo.ts
+++ b/src/demo-app/select/select-demo.ts
@@ -9,10 +9,13 @@ import {MdSelectChange} from '@angular/material';
styleUrls: ['select-demo.css'],
})
export class SelectDemo {
- isRequired = false;
- isDisabled = false;
+ drinksRequired = false;
+ pokemonRequired = false;
+ drinksDisabled = false;
+ pokemonDisabled = false;
showSelect = false;
currentDrink: string;
+ currentPokemon: string[];
latestChangeEvent: MdSelectChange;
foodControl = new FormControl('pizza-1');
@@ -37,7 +40,11 @@ export class SelectDemo {
pokemon = [
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
{value: 'charizard-1', viewValue: 'Charizard'},
- {value: 'squirtle-2', viewValue: 'Squirtle'}
+ {value: 'squirtle-2', viewValue: 'Squirtle'},
+ {value: 'pikachu-3', viewValue: 'Pikachu'},
+ {value: 'eevee-4', viewValue: 'Eevee'},
+ {value: 'ditto-5', viewValue: 'Ditto'},
+ {value: 'psyduck-6', viewValue: 'Psyduck'},
];
toggleDisabled() {
diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts
index 42d623b88da1..eeadb2b33f8c 100644
--- a/src/lib/autocomplete/autocomplete-trigger.ts
+++ b/src/lib/autocomplete/autocomplete-trigger.ts
@@ -6,7 +6,7 @@ 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 {MdOptionSelectEvent} from '../core/option/option';
+import {MdOptionSelectionChange} from '../core/option/option';
import 'rxjs/add/observable/merge';
import {Dir} from '../core/rtl/dir';
import 'rxjs/add/operator/startWith';
@@ -75,7 +75,7 @@ export class MdAutocompleteTrigger implements OnDestroy {
/** Stream of autocomplete option selections. */
get optionSelections(): Observable
[] {
- return this.autocomplete.options.map(option => option.onSelect);
+ return this.autocomplete.options.map(option => option.onSelectionChange);
}
@@ -111,7 +111,7 @@ export class MdAutocompleteTrigger implements OnDestroy {
* 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 {
+ private _setValueAndClose(event: MdOptionSelectionChange | null): void {
if (event) {
this._controlDir.control.setValue(event.source.value);
if (event.isUserInput) {
diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts
index 2c7e0f9e85c7..dbfcc26f3a61 100644
--- a/src/lib/core/option/option.ts
+++ b/src/lib/core/option/option.ts
@@ -20,9 +20,9 @@ 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) {}
+/** Event object emitted by MdOption when selected or deselected. */
+export class MdOptionSelectionChange {
+ constructor(public source: MdOption, public isUserInput = false) { }
}
@@ -54,9 +54,15 @@ export class MdOption {
private _id: string = `md-option-${_uniqueIdCounter++}`;
+ /** Whether the wrapping component is in multiple selection mode. */
+ multiple: boolean = false;
+
/** The unique ID of the option. */
get id() { return this._id; }
+ /** Whether or not the option is currently selected. */
+ get selected(): boolean { return this._selected; }
+
/** The form value of the option. */
@Input() value: any;
@@ -65,16 +71,11 @@ export class MdOption {
get disabled() { return this._disabled; }
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
- /** Event emitted when the option is selected. */
- @Output() onSelect = new EventEmitter();
+ /** Event emitted when the option is selected or deselected. */
+ @Output() onSelectionChange = new EventEmitter();
constructor(private _element: ElementRef, private _renderer: Renderer) {}
- /** Whether or not the option is currently selected. */
- get selected(): boolean {
- return this._selected;
- }
-
/**
* The displayed value of the option. It is necessary to show the selected option in the
* select's trigger.
@@ -87,12 +88,13 @@ export class MdOption {
/** Selects the option. */
select(): void {
this._selected = true;
- this.onSelect.emit(new MdOptionSelectEvent(this, false));
+ this._emitSelectionChangeEvent();
}
/** Deselects the option. */
deselect(): void {
this._selected = false;
+ this._emitSelectionChangeEvent();
}
/** Sets focus onto this option. */
@@ -113,8 +115,8 @@ export class MdOption {
*/
_selectViaInteraction() {
if (!this.disabled) {
- this._selected = true;
- this.onSelect.emit(new MdOptionSelectEvent(this, true));
+ this._selected = this.multiple ? !this._selected : true;
+ this._emitSelectionChangeEvent(true);
}
}
@@ -123,10 +125,16 @@ export class MdOption {
return this.disabled ? '-1' : '0';
}
+ /** Fetches the host DOM element. */
_getHostElement(): HTMLElement {
return this._element.nativeElement;
}
+ /** Emits the selection change event event. */
+ private _emitSelectionChangeEvent(isUserInput?: boolean) {
+ this.onSelectionChange.emit(new MdOptionSelectionChange(this, isUserInput));
+ };
+
}
@NgModule({
diff --git a/src/lib/core/selection/selection.spec.ts b/src/lib/core/selection/selection.spec.ts
index b05e5236d9fd..c9c5201dc640 100644
--- a/src/lib/core/selection/selection.spec.ts
+++ b/src/lib/core/selection/selection.spec.ts
@@ -156,6 +156,16 @@ describe('SelectionModel', () => {
expect(model.isEmpty()).toBe(false);
});
+ it('should be able to toggle an option', () => {
+ let model = new SelectionModel();
+
+ model.toggle(1);
+ expect(model.isSelected(1)).toBe(true);
+
+ model.toggle(1);
+ expect(model.isSelected(1)).toBe(false);
+ });
+
it('should be able to clear the selected options', () => {
let model = new SelectionModel(true);
diff --git a/src/lib/core/selection/selection.ts b/src/lib/core/selection/selection.ts
index 91a594b51e7a..2be9c273103e 100644
--- a/src/lib/core/selection/selection.ts
+++ b/src/lib/core/selection/selection.ts
@@ -59,6 +59,17 @@ export class SelectionModel {
this._emitChangeEvent();
}
+ /**
+ * Toggles a value between selected and deselected.
+ */
+ toggle(value: T): void {
+ if (this.isSelected(value)) {
+ this.deselect(value);
+ } else {
+ this.select(value);
+ }
+ }
+
/**
* Clears all of the selected values.
*/
diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts
index 5cc05dd3d70b..4f2891456d8f 100644
--- a/src/lib/dialog/dialog.spec.ts
+++ b/src/lib/dialog/dialog.spec.ts
@@ -322,6 +322,8 @@ describe('MdDialog', () => {
expect(document.activeElement.id)
.toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close');
+
+ document.body.removeChild(button);
}));
});
diff --git a/src/lib/select/select-errors.ts b/src/lib/select/select-errors.ts
new file mode 100644
index 000000000000..3adefd8069ff
--- /dev/null
+++ b/src/lib/select/select-errors.ts
@@ -0,0 +1,21 @@
+import {MdError} from '../core/errors/error';
+
+/**
+ * Exception thrown when attempting to change a select's `multiple` option after initialization.
+ * @docs-private
+ */
+export class MdSelectDynamicMultipleError extends MdError {
+ constructor() {
+ super('Cannot change `multiple` mode of select after initialization.');
+ }
+}
+
+/**
+ * Exception thrown when attempting to assign a non-array value to a select in `multiple` mode.
+ * @docs-private
+ */
+export class MdSelectNonArrayValueError extends MdError {
+ constructor() {
+ super('Cannot assign non-array value to select in `multiple` mode.');
+ }
+}
diff --git a/src/lib/select/select.html b/src/lib/select/select.html
index 61e55e51a4e9..121508a0f5e7 100644
--- a/src/lib/select/select.html
+++ b/src/lib/select/select.html
@@ -1,7 +1,7 @@
- {{ placeholder }}
- {{ selected?.viewValue }}
+ {{ selectedLabel }}
diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts
index 5f57794bdd80..a9a9dafcaf1a 100644
--- a/src/lib/select/select.spec.ts
+++ b/src/lib/select/select.spec.ts
@@ -4,6 +4,7 @@ import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angu
import {MdSelectModule} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdSelect} from './select';
+import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors';
import {MdOption} from '../core/option/option';
import {Dir} from '../core/rtl/dir';
import {
@@ -26,7 +27,8 @@ describe('MdSelect', () => {
SelectInitWithoutOptions,
SelectWithChangeEvent,
CustomSelectAccessor,
- CompWithCustomSelect
+ CompWithCustomSelect,
+ MultiSelect
],
providers: [
{provide: OverlayContainer, useFactory: () => {
@@ -55,6 +57,27 @@ describe('MdSelect', () => {
document.body.removeChild(overlayContainerElement);
});
+ it('should select the proper option when the list of options is initialized at a later point',
+ async(() => {
+ let fixture = TestBed.createComponent(SelectInitWithoutOptions);
+ let instance = fixture.componentInstance;
+
+ fixture.detectChanges();
+
+ // Wait for the initial writeValue promise.
+ fixture.whenStable().then(() => {
+ expect(instance.select.selected).toBeFalsy();
+
+ instance.addOptions();
+ fixture.detectChanges();
+
+ // Wait for the next writeValue promise.
+ fixture.whenStable().then(() => {
+ expect(instance.select.selected).toBe(instance.options.toArray()[1]);
+ });
+ });
+ }));
+
describe('overlay panel', () => {
let fixture: ComponentFixture;
let trigger: HTMLElement;
@@ -155,6 +178,7 @@ describe('MdSelect', () => {
fixture.detectChanges();
option = overlayContainerElement.querySelector('md-option') as HTMLElement;
+
expect(option.classList).toContain('md-selected');
expect(fixture.componentInstance.options.first.selected).toBe(true);
expect(fixture.componentInstance.select.selected)
@@ -202,7 +226,7 @@ describe('MdSelect', () => {
fixture.whenStable().then(() => {
expect(select.selected)
- .toBe(null, 'Expected selection to be removed when option no longer exists.');
+ .toBeUndefined('Expected selection to be removed when option no longer exists.');
});
}));
@@ -272,27 +296,6 @@ describe('MdSelect', () => {
});
- it('should select the proper option when the list of options is initialized at a later point',
- async(() => {
- let fixture = TestBed.createComponent(SelectInitWithoutOptions);
- let instance = fixture.componentInstance;
-
- fixture.detectChanges();
-
- // Wait for the initial writeValue promise.
- fixture.whenStable().then(() => {
- expect(instance.select.selected).toBeFalsy();
-
- instance.addOptions();
- fixture.detectChanges();
-
- // Wait for the next writeValue promise.
- fixture.whenStable().then(() => {
- expect(instance.select.selected).toBe(instance.options.toArray()[1]);
- });
- });
- }));
-
describe('forms integration', () => {
let fixture: ComponentFixture;
let trigger: HTMLElement;
@@ -1255,6 +1258,177 @@ describe('MdSelect', () => {
expect(fixture.componentInstance.changeListener).toHaveBeenCalledTimes(1);
});
});
+
+ describe('multiple selection', () => {
+ let fixture: ComponentFixture;
+ let testInstance: MultiSelect;
+ let trigger: HTMLElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MultiSelect);
+ testInstance = fixture.componentInstance;
+ fixture.detectChanges();
+
+ trigger = fixture.debugElement.query(By.css('.md-select-trigger')).nativeElement;
+ });
+
+ it('should be able to select multiple values', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ let options = overlayContainerElement.querySelectorAll('md-option') as
+ NodeListOf;
+
+ options[0].click();
+ options[2].click();
+ options[5].click();
+ fixture.detectChanges();
+
+ expect(testInstance.control.value).toEqual(['steak-0', 'tacos-2', 'eggs-5']);
+ });
+
+ it('should be able to toggle an option on and off', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ let option = overlayContainerElement.querySelector('md-option') as HTMLElement;
+
+ option.click();
+ fixture.detectChanges();
+
+ expect(testInstance.control.value).toEqual(['steak-0']);
+
+ option.click();
+ fixture.detectChanges();
+
+ expect(testInstance.control.value).toEqual([]);
+ });
+
+ it('should update the label', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ let options = overlayContainerElement.querySelectorAll('md-option') as
+ NodeListOf;
+
+ options[0].click();
+ options[2].click();
+ options[5].click();
+ fixture.detectChanges();
+
+ expect(trigger.textContent).toContain('Steak, Tacos, Eggs');
+
+ options[2].click();
+ fixture.detectChanges();
+
+ expect(trigger.textContent).toContain('Steak, Eggs');
+ });
+
+ it('should be able to set the selected value by taking an array', () => {
+ trigger.click();
+ testInstance.control.setValue(['steak-0', 'eggs-5']);
+ fixture.detectChanges();
+
+ let optionNodes = overlayContainerElement.querySelectorAll('md-option') as
+ NodeListOf;
+
+ let optionInstances = testInstance.options.toArray();
+
+ expect(optionNodes[0].classList).toContain('md-selected');
+ expect(optionNodes[5].classList).toContain('md-selected');
+
+ expect(optionInstances[0].selected).toBe(true);
+ expect(optionInstances[5].selected).toBe(true);
+ });
+
+ it('should override the previously-selected value when setting an array', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ let options = overlayContainerElement.querySelectorAll('md-option') as
+ NodeListOf;
+
+ options[0].click();
+ fixture.detectChanges();
+
+ expect(options[0].classList).toContain('md-selected');
+
+ testInstance.control.setValue(['eggs-5']);
+ fixture.detectChanges();
+
+ expect(options[0].classList).not.toContain('md-selected');
+ expect(options[5].classList).toContain('md-selected');
+ });
+
+ it('should not close the panel when clicking on options', () => {
+ trigger.click();
+ fixture.detectChanges();
+
+ expect(testInstance.select.panelOpen).toBe(true);
+
+ let options = overlayContainerElement.querySelectorAll('md-option') as
+ NodeListOf;
+
+ options[0].click();
+ options[1].click();
+ fixture.detectChanges();
+
+ expect(testInstance.select.panelOpen).toBe(true);
+ });
+
+ it('should throw an exception when trying to set a non-array value', () => {
+ expect(() => {
+ testInstance.control.setValue('not-an-array');
+ }).toThrowError(MdSelectNonArrayValueError);
+ });
+
+ it('should throw an exception when trying to change multiple mode after init', () => {
+ expect(() => {
+ testInstance.select.multiple = false;
+ }).toThrowError(MdSelectDynamicMultipleError);
+ });
+
+ it('should pass the `multiple` value to all of the option instances', async(() => {
+ trigger.click();
+ fixture.detectChanges();
+
+ fixture.whenStable().then(() => {
+ expect(testInstance.options.toArray().every(option => option.multiple)).toBe(true,
+ 'Expected `multiple` to have been added to initial set of options.');
+
+ testInstance.foods.push({ value: 'cake-8', viewValue: 'Cake' });
+ fixture.detectChanges();
+
+ fixture.whenStable().then(() => {
+ expect(testInstance.options.toArray().every(option => option.multiple)).toBe(true,
+ 'Expected `multiple` to have been set on dynamically-added option.');
+ });
+ });
+ }));
+
+ it('should be considered invalid when there are no selected options', () => {
+ testInstance.isRequired = true;
+ fixture.detectChanges();
+
+ expect(testInstance.control.valid).toBe(false, 'Should be invalid when the value is null.');
+
+ trigger.click();
+ fixture.detectChanges();
+
+ let option = overlayContainerElement.querySelector('md-option') as HTMLElement;
+
+ option.click();
+ fixture.detectChanges();
+
+ expect(testInstance.control.valid).toBe(true, 'Should be valid when the array has items.');
+
+ option.click();
+ fixture.detectChanges();
+
+ expect(testInstance.control.valid).toBe(false, 'Should be invalid when the array is empty.');
+ });
+
+ });
});
@Component({
@@ -1434,6 +1608,33 @@ class CompWithCustomSelect {
}
+@Component({
+ selector: 'multi-select',
+ template: `
+
+ {{ food.viewValue }}
+
+ `
+})
+class MultiSelect {
+ foods: any[] = [
+ { value: 'steak-0', viewValue: 'Steak' },
+ { value: 'pizza-1', viewValue: 'Pizza' },
+ { value: 'tacos-2', viewValue: 'Tacos' },
+ { value: 'sandwich-3', viewValue: 'Sandwich' },
+ { value: 'chips-4', viewValue: 'Chips' },
+ { value: 'eggs-5', viewValue: 'Eggs' },
+ { value: 'pasta-6', viewValue: 'Pasta' },
+ { value: 'sushi-7', viewValue: 'Sushi' },
+ ];
+ control = new FormControl();
+ isRequired: boolean;
+
+ @ViewChild(MdSelect) select: MdSelect;
+ @ViewChildren(MdOption) options: QueryList;
+}
+
+
/**
* TODO: Move this to core testing utility until Angular has event faking
* support.
diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts
index 1a3dabfd5fc6..428f62eacc37 100644
--- a/src/lib/select/select.ts
+++ b/src/lib/select/select.ts
@@ -14,16 +14,20 @@ import {
ViewEncapsulation,
ViewChild,
} from '@angular/core';
-import {MdOption, MdOptionSelectEvent} from '../core/option/option';
+import {MdOption, MdOptionSelectionChange} 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';
+import {Observable} from 'rxjs/Observable';
import {Subscription} from 'rxjs/Subscription';
import {transformPlaceholder, transformPanel, fadeInContent} from './select-animations';
import {ControlValueAccessor, NgControl} from '@angular/forms';
import {coerceBooleanProperty} from '../core/coercion/boolean-property';
import {ConnectedOverlayDirective} from '../core/overlay/overlay-directives';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
+import {SelectionModel} from '../core/selection/selection';
+import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors';
+import 'rxjs/add/observable/merge';
/**
* The following style constants are necessary to save here in order
@@ -99,11 +103,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** Whether or not the overlay panel is open. */
private _panelOpen = false;
- /** The currently selected option. */
- private _selected: MdOption;
-
/** Subscriptions to option events. */
- private _subscriptions: Subscription[] = [];
+ private _optionSubscription: Subscription;
/** Subscription to changes in the option list. */
private _changeSubscription: Subscription;
@@ -123,6 +124,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** The placeholder displayed in the trigger of the select. */
private _placeholder: string;
+ /** Deals with the selection logic. */
+ _model: SelectionModel;
+
/** The animation state of the placeholder. */
_placeholderState = '';
@@ -222,6 +226,18 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
get required() { return this._required; }
set required(value: any) { this._required = coerceBooleanProperty(value); }
+ /** Whether the user should be allowed to select multiple options. */
+ @Input()
+ get multiple(): boolean { return this._multiple; }
+ set multiple(value: boolean) {
+ if (this._model) {
+ throw new MdSelectDynamicMultipleError();
+ }
+
+ this._multiple = coerceBooleanProperty(value);
+ }
+ private _multiple: boolean = false;
+
/** Event emitted when the select has been opened. */
@Output() onOpen: EventEmitter = new EventEmitter();
@@ -240,6 +256,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
}
ngAfterContentInit() {
+ this._model = new SelectionModel(this.multiple);
this._initKeyManager();
this._resetOptions();
this._changeSubscription = this.options.changes.subscribe(() => {
@@ -276,11 +293,13 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** Closes the overlay panel and focuses the host element. */
close(): void {
- this._panelOpen = false;
- if (!this._selected) {
- this._placeholderState = '';
+ if (this._panelOpen) {
+ this._panelOpen = false;
+ if (this._model.isEmpty()) {
+ this._placeholderState = '';
+ }
+ this._focusHost();
}
- this._focusHost();
}
/**
@@ -340,10 +359,18 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
}
/** The currently selected option. */
- get selected(): MdOption {
- return this._selected;
+ get selected(): MdOption|MdOption[] {
+ return this.multiple ? this._model.selected : this._model.selected[0];
}
+ /** Label with the selected value(s). */
+ get selectedLabel(): string {
+ return this.multiple ?
+ this._model.selected.map(option => option.viewValue).join(', ') :
+ this._model.selected[0].viewValue;
+ }
+
+ /** Whether the element is in RTL mode. */
_isRtl(): boolean {
return this._dir ? this._dir.value === 'rtl' : false;
}
@@ -414,24 +441,58 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
* Sets the selected option based on a value. If no option can be
* found with the designated value, the select trigger is cleared.
*/
- private _setSelectionByValue(value: any): void {
- const options = this.options.toArray();
+ private _setSelectionByValue(value: any|any[]): void {
+ const isArray = Array.isArray(value);
+
+ if (this.multiple && value && !isArray) {
+ throw new MdSelectNonArrayValueError();
+ }
+
+ if (isArray) {
+ this._clearSelection();
+
+ value.forEach((currentValue: any) => {
+ let correspondingOption = this._getOptionByValue(currentValue);
+
+ if (correspondingOption) {
+ correspondingOption.select();
+ this._model.select(correspondingOption);
+ }
+ });
+ } else {
+ let correspondingOption = this._getOptionByValue(value);
- for (let i = 0; i < this.options.length; i++) {
- if (options[i].value === value) {
- options[i].select();
- return;
+ if (correspondingOption) {
+ correspondingOption.select();
+ this._model.select(correspondingOption);
+ } else {
+ this._clearSelection();
}
}
- // Clear selection if no item was selected.
- this._clearSelection();
+ this._setValueWidth();
+
+ if (this._model.isEmpty()) {
+ this._placeholderState = '';
+ }
+ }
+
+ /** Finds and option based on it's value. */
+ private _getOptionByValue(value: any): MdOption {
+ return this.options.find(option => option.value === value);
}
- /** Clears the select trigger and deselects every option in the list. */
- private _clearSelection(): void {
- this._selected = null;
- this._updateOptions();
+ /**
+ * Clears the select trigger and deselects every option in the list.
+ * @param exception Option that should not be deselected.
+ */
+ private _clearSelection(exception?: MdOption): void {
+ this._model.clear();
+ this.options.forEach(option => {
+ if (option !== exception) {
+ option.deselect();
+ }
+ });
}
private _getTriggerRect(): ClientRect {
@@ -441,9 +502,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
private _initKeyManager() {
this._keyManager = new FocusKeyManager(this.options);
- this._tabSubscription = this._keyManager.tabOut.subscribe(() => {
- this.close();
- });
+ this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close());
}
/** Drops current option subscriptions and IDs and resets from scratch. */
@@ -451,31 +510,54 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
this._dropSubscriptions();
this._listenToOptions();
this._setOptionIds();
+ this._setOptionMultiple();
}
- /** Listens to selection events on each option. */
+ /** Listens to user-generated selection events on each option. */
private _listenToOptions(): void {
- this.options.forEach((option: MdOption) => {
- const sub = option.onSelect.subscribe((event: MdOptionSelectEvent) => {
- if (event.isUserInput && this._selected !== option) {
- this._emitChangeEvent(option);
+ let source = Observable.merge.apply(Observable,
+ this.options.map(option => option.onSelectionChange));
+
+ this._optionSubscription = source
+ .filter((event: MdOptionSelectionChange) => event.isUserInput)
+ .subscribe((event: MdOptionSelectionChange) => {
+ let wasSelected = this._model.isSelected(event.source);
+
+ if (this.multiple) {
+ this._model.toggle(event.source);
+ } else {
+ this._clearSelection(event.source);
+ this._model.select(event.source);
+ }
+
+ if (wasSelected !== this._model.isSelected(event.source)) {
+ this._propagateChanges();
+ }
+
+ this._setValueWidth();
+
+ if (!this.multiple) {
+ this.close();
}
- this._onSelect(option);
});
- this._subscriptions.push(sub);
- });
}
/** Unsubscribes from all option subscriptions. */
private _dropSubscriptions(): void {
- this._subscriptions.forEach((sub: Subscription) => sub.unsubscribe());
- this._subscriptions = [];
+ if (this._optionSubscription) {
+ this._optionSubscription.unsubscribe();
+ this._optionSubscription = null;
+ }
}
- /** Emits an event when the user selects an option. */
- private _emitChangeEvent(option: MdOption): void {
- this._onChange(option.value);
- this.change.emit(new MdSelectChange(this, option.value));
+ /** Sets the changes to the model value and emits a change event. */
+ private _propagateChanges(): void {
+ let valueToEmit = Array.isArray(this.selected) ?
+ this.selected.map(option => option.value) :
+ this.selected.value;
+
+ this._onChange(valueToEmit);
+ this.change.emit(new MdSelectChange(this, valueToEmit));
}
/** Records option IDs to pass to the aria-owns property. */
@@ -483,23 +565,14 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
this._optionIds = this.options.map(option => option.id).join(' ');
}
- /** When a new option is selected, deselects the others and closes the panel. */
- private _onSelect(option: MdOption): void {
- this._selected = option;
- this._updateOptions();
- this._setValueWidth();
- this._placeholderState = '';
- if (this.panelOpen) {
- this.close();
- }
- }
-
- /** Deselect each option that doesn't match the current selection. */
- private _updateOptions(): void {
- this.options.forEach((option: MdOption) => {
- if (option !== this.selected) {
- option.deselect();
- }
+ /**
+ * Sets the `multiple` property on each option. The promise is necessary in order to avoid
+ * Angular errors when modifying the property after init.
+ * TODO: there should be a better way of doing this.
+ */
+ private _setOptionMultiple() {
+ Promise.resolve(null).then(() => {
+ this.options.forEach(option => option.multiple = this.multiple);
});
}
@@ -512,14 +585,15 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
this._selectedValueWidth = this._triggerWidth - 13;
}
- /** Focuses the selected item. If no option is selected, it will focus
+ /**
+ * Focuses the selected item. If no option is selected, it will focus
* the first item instead.
*/
private _focusCorrectOption(): void {
- if (this.selected) {
- this._keyManager.setActiveItem(this._getOptionIndex(this.selected));
- } else {
+ if (this._model.isEmpty()) {
this._keyManager.setFirstItemActive();
+ } else {
+ this._keyManager.setActiveItem(this._getOptionIndex(this._model.selected[0]));
}
}
@@ -546,8 +620,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
// The farthest the panel can be scrolled before it hits the bottom
const maxScroll = scrollContainerHeight - panelHeight;
- if (this.selected) {
- const selectedIndex = this._getOptionIndex(this.selected);
+ if (!this._model.isEmpty()) {
+ const selectedIndex = this._getOptionIndex(this._model.selected[0]);
// We must maintain a scroll buffer so the selected option will be scrolled to the
// center of the overlay panel rather than the top.
const scrollBuffer = panelHeight / 2;