From 4fa000a5f41e80dd5e48174e416fe8f004dc99a6 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 15 Jan 2017 13:42:41 +0100 Subject: [PATCH] feat(select): add multiple selection mode * Integrates the `SelectionModel` into `md-select`. * Adds the `multiple` option which allows users to select multiple options from a `md-select`. * Fixes a button that wasn't being cleaned up from dialog tests, causing some select tests to fail. Fixes #2412. --- src/demo-app/select/select-demo.html | 95 ++++--- src/demo-app/select/select-demo.ts | 13 +- src/lib/autocomplete/autocomplete-trigger.ts | 6 +- src/lib/core/option/option.ts | 34 ++- src/lib/core/selection/selection.spec.ts | 10 + src/lib/core/selection/selection.ts | 11 + src/lib/dialog/dialog.spec.ts | 2 + src/lib/select/select-errors.ts | 21 ++ src/lib/select/select.html | 4 +- src/lib/select/select.spec.ts | 247 +++++++++++++++++-- src/lib/select/select.ts | 200 ++++++++++----- 11 files changed, 505 insertions(+), 138 deletions(-) create mode 100644 src/lib/select/select-errors.ts 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;