From dcc857664c9313694d020845135f1ce63a66f43a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 9 Mar 2017 22:57:12 +0100 Subject: [PATCH] feat(select): add multiple selection mode (#2722) * * 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. * fix: remove array literal from template * fix: avoid issues with material in compatibility mode * fix: test failure in IE * fix: checkbox always being rendered inside option --- src/demo-app/select/select-demo.html | 75 +++-- src/demo-app/select/select-demo.ts | 17 +- src/lib/autocomplete/autocomplete-trigger.ts | 10 +- src/lib/core/option/_option-theme.scss | 8 +- src/lib/core/option/_option.scss | 10 + src/lib/core/option/option.html | 7 + src/lib/core/option/option.ts | 56 ++-- .../_pseudo-checkbox-theme.scss | 2 - src/lib/core/selection/selection.spec.ts | 50 +++ src/lib/core/selection/selection.ts | 58 +++- src/lib/core/style/_list-common.scss | 2 +- src/lib/select/index.ts | 5 +- src/lib/select/select-errors.ts | 22 ++ src/lib/select/select.html | 6 +- src/lib/select/select.spec.ts | 296 ++++++++++++++++-- src/lib/select/select.ts | 246 +++++++++++---- 16 files changed, 710 insertions(+), 160 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 821934b4b59f..f5cddc897323 100644 --- a/src/demo-app/select/select-demo.html +++ b/src/demo-app/select/select-demo.html @@ -1,23 +1,10 @@
This div is for testing scrolled selects.
-
- - - {{ food.viewValue }} - -

Value: {{ foodControl.value }}

-

Touched: {{ foodControl.touched }}

-

Dirty: {{ foodControl.dirty }}

-

Status: {{ foodControl.status }}

- - - -
-
- - ngModel + + {{ drink.viewValue }} @@ -37,18 +24,62 @@

- - + +
+ + 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 b731087b5624..04813bdae4e9 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; floatPlaceholder: string = 'auto'; foodControl = new FormControl('pizza-1'); @@ -38,10 +41,18 @@ 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() { this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable(); } + + setPokemonValue() { + this.currentPokemon = ['eevee-4', 'psyduck-6']; + } } diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 09829b82adb0..1dbfe73991c5 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -15,7 +15,7 @@ import {MdAutocomplete} from './autocomplete'; import {PositionStrategy} from '../core/overlay/position/position-strategy'; import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy'; import {Observable} from 'rxjs/Observable'; -import {MdOptionSelectEvent, MdOption} from '../core/option/option'; +import {MdOptionSelectionChange, MdOption} from '../core/option/option'; import {ENTER, UP_ARROW, DOWN_ARROW} from '../core/keyboard/keycodes'; import {Dir} from '../core/rtl/dir'; import {Subscription} from 'rxjs/Subscription'; @@ -146,7 +146,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { * A stream of actions that should close the autocomplete panel, including * when an option is selected, on blur, and when TAB is pressed. */ - get panelClosingActions(): Observable { + get panelClosingActions(): Observable { return Observable.merge( this.optionSelections, this._blurStream.asObservable(), @@ -155,8 +155,8 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { } /** Stream of autocomplete option selections. */ - get optionSelections(): Observable { - return Observable.merge(...this.autocomplete.options.map(option => option.onSelect)); + get optionSelections(): Observable { + return Observable.merge(...this.autocomplete.options.map(option => option.onSelectionChange)); } /** The currently active option, coerced to MdOption type. */ @@ -301,7 +301,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, 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._setTriggerValue(event.source.value); this._onChange(event.source.value); diff --git a/src/lib/core/option/_option-theme.scss b/src/lib/core/option/_option-theme.scss index 8e163c225fca..499b41c7a989 100644 --- a/src/lib/core/option/_option-theme.scss +++ b/src/lib/core/option/_option-theme.scss @@ -12,8 +12,12 @@ } &.mat-selected { - background: mat-color($background, hover); color: mat-color($primary); + + // In multiple mode there is a checkbox to show that the option is selected. + &:not(.mat-option-multiple) { + background: mat-color($background, hover); + } } &.mat-active { @@ -26,4 +30,4 @@ } } -} \ No newline at end of file +} diff --git a/src/lib/core/option/_option.scss b/src/lib/core/option/_option.scss index 6a9cd576f952..135835de866d 100644 --- a/src/lib/core/option/_option.scss +++ b/src/lib/core/option/_option.scss @@ -30,5 +30,15 @@ opacity: 0.5; } } + + .mat-option-pseudo-checkbox { + $margin: $mat-menu-side-padding / 2; + margin-right: $margin; + + [dir='rtl'] & { + margin-left: $margin; + margin-right: 0; + } + } } diff --git a/src/lib/core/option/option.html b/src/lib/core/option/option.html index 863531ca0e19..fb398f4b3529 100644 --- a/src/lib/core/option/option.html +++ b/src/lib/core/option/option.html @@ -1,3 +1,10 @@ + + + + +
diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index b3ee72398fba..a350a72cdb28 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -7,12 +7,16 @@ import { NgModule, ModuleWithProviders, Renderer, - ViewEncapsulation + ViewEncapsulation, + Inject, + Optional, } from '@angular/core'; import {CommonModule} from '@angular/common'; import {ENTER, SPACE} from '../keyboard/keycodes'; import {coerceBooleanProperty} from '../coercion/boolean-property'; import {MdRippleModule} from '../ripple/index'; +import {MdSelectionModule} from '../selection/index'; +import {MATERIAL_COMPATIBILITY_MODE} from '../../core/compatibility/compatibility'; /** * Option IDs need to be unique across components, so this counter exists outside of @@ -20,9 +24,9 @@ import {MdRippleModule} from '../ripple/index'; */ 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) { } } @@ -36,6 +40,7 @@ export class MdOptionSelectEvent { 'role': 'option', '[attr.tabindex]': '_getTabIndex()', '[class.mat-selected]': 'selected', + '[class.mat-option-multiple]': 'multiple', '[class.mat-active]': 'active', '[id]': 'id', '[attr.aria-selected]': 'selected.toString()', @@ -57,9 +62,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; @@ -68,15 +79,13 @@ 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; - } + constructor( + private _element: ElementRef, + private _renderer: Renderer, + @Optional() @Inject(MATERIAL_COMPATIBILITY_MODE) public _isCompatibilityMode: boolean) {} /** * Whether or not the option is currently active and ready to be selected. @@ -100,12 +109,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. */ @@ -118,7 +128,7 @@ export class MdOption { * active. This is used by the ActiveDescendantKeyManager so key * events will display the proper options as active on arrow key events. */ - setActiveStyles() { + setActiveStyles(): void { Promise.resolve(null).then(() => this._active = true); } @@ -127,7 +137,7 @@ export class MdOption { * active. This is used by the ActiveDescendantKeyManager so key * events will display the proper options as active on arrow key events. */ - setInactiveStyles() { + setInactiveStyles(): void { Promise.resolve(null).then(() => this._active = false); } @@ -142,26 +152,32 @@ export class MdOption { * Selects the option while indicating the selection came from the user. Used to * determine if the select's view -> model callback should be invoked. */ - _selectViaInteraction() { + _selectViaInteraction(): void { if (!this.disabled) { - this._selected = true; - this.onSelect.emit(new MdOptionSelectEvent(this, true)); + this._selected = this.multiple ? !this._selected : true; + this._emitSelectionChangeEvent(true); } } /** Returns the correct tabindex for the option depending on disabled state. */ - _getTabIndex() { + _getTabIndex(): string { return this.disabled ? '-1' : '0'; } + /** Fetches the host DOM element. */ _getHostElement(): HTMLElement { return this._element.nativeElement; } + /** Emits the selection change event. */ + private _emitSelectionChangeEvent(isUserInput = false): void { + this.onSelectionChange.emit(new MdOptionSelectionChange(this, isUserInput)); + }; + } @NgModule({ - imports: [MdRippleModule, CommonModule], + imports: [MdRippleModule, CommonModule, MdSelectionModule], exports: [MdOption], declarations: [MdOption] }) diff --git a/src/lib/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss b/src/lib/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss index fe85a4ce4ab0..118235123d26 100644 --- a/src/lib/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss +++ b/src/lib/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss @@ -24,8 +24,6 @@ } .mat-pseudo-checkbox-checked, .mat-pseudo-checkbox-indeterminate { - border: none; - &.mat-primary { background: mat-color($primary, 500); } diff --git a/src/lib/core/selection/selection.spec.ts b/src/lib/core/selection/selection.spec.ts index b05e5236d9fd..b39fb736ba7d 100644 --- a/src/lib/core/selection/selection.spec.ts +++ b/src/lib/core/selection/selection.spec.ts @@ -52,6 +52,16 @@ describe('SelectionModel', () => { expect(model.isSelected(1)).toBe(true); expect(model.isSelected(2)).toBe(true); }); + + it('should be able to sort the selected values', () => { + model = new SelectionModel(true, [2, 3, 1]); + + expect(model.selected).toEqual([2, 3, 1]); + + model.sort(); + + expect(model.selected).toEqual([1, 2, 3]); + }); }); describe('onChange event', () => { @@ -146,6 +156,26 @@ describe('SelectionModel', () => { }); }); + describe('disabling the change event', () => { + let model: SelectionModel; + + beforeEach(() => { + model = new SelectionModel(true, null, false); + }); + + it('should not have an onChange stream if change events are disabled', () => { + expect(model.onChange).toBeFalsy(); + }); + + it('should still update the select value', () => { + model.select(1); + expect(model.selected).toEqual([1]); + + model.select(2); + expect(model.selected).toEqual([1, 2]); + }); + }); + it('should be able to determine whether it is empty', () => { let model = new SelectionModel(); @@ -156,6 +186,26 @@ describe('SelectionModel', () => { expect(model.isEmpty()).toBe(false); }); + it('should be able to determine whether it has a value', () => { + let model = new SelectionModel(); + + expect(model.hasValue()).toBe(false); + + model.select(1); + + expect(model.hasValue()).toBe(true); + }); + + 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..cbbd0b8c246c 100644 --- a/src/lib/core/selection/selection.ts +++ b/src/lib/core/selection/selection.ts @@ -28,9 +28,13 @@ export class SelectionModel { } /** Event emitted when the value has changed. */ - onChange: Subject> = new Subject(); + onChange: Subject> = this._emitChanges ? new Subject() : null; + + constructor( + private _isMulti = false, + initiallySelectedValues?: T[], + private _emitChanges = true) { - constructor(private _isMulti = false, initiallySelectedValues?: T[]) { if (initiallySelectedValues) { if (_isMulti) { initiallySelectedValues.forEach(value => this._markSelected(value)); @@ -59,6 +63,13 @@ export class SelectionModel { this._emitChangeEvent(); } + /** + * Toggles a value between selected and deselected. + */ + toggle(value: T): void { + this.isSelected(value) ? this.deselect(value) : this.select(value); + } + /** * Clears all of the selected values. */ @@ -75,12 +86,28 @@ export class SelectionModel { } /** - * Determines whether the model has a value. + * Determines whether the model does not have a value. */ isEmpty(): boolean { return this._selection.size === 0; } + /** + * Determines whether the model has a value. + */ + hasValue(): boolean { + return !this.isEmpty(); + } + + /** + * Sorts the selected values based on a predicate function. + */ + sort(predicate?: (a: T, b: T) => number): void { + if (this._isMulti && this.selected) { + this._selected.sort(predicate); + } + } + /** Emits a change event and clears the records of selected and deselected values. */ private _emitChangeEvent() { if (this._selectedToEmit.length || this._deselectedToEmit.length) { @@ -89,8 +116,9 @@ export class SelectionModel { this.onChange.next(eventData); this._deselectedToEmit = []; this._selectedToEmit = []; - this._selected = null; } + + this._selected = null; } /** Selects a value. */ @@ -101,17 +129,23 @@ export class SelectionModel { } this._selection.add(value); - this._selectedToEmit.push(value); + + if (this._emitChanges) { + this._selectedToEmit.push(value); + } } } - /** Deselects a value. */ - private _unmarkSelected(value: T) { - if (this.isSelected(value)) { - this._selection.delete(value); - this._deselectedToEmit.push(value); - } - } + /** Deselects a value. */ + private _unmarkSelected(value: T) { + if (this.isSelected(value)) { + this._selection.delete(value); + + if (this._emitChanges) { + this._deselectedToEmit.push(value); + } + } + } /** Clears out the selected values. */ private _unmarkAll() { diff --git a/src/lib/core/style/_list-common.scss b/src/lib/core/style/_list-common.scss index 3d140d630da0..0f435c1c52a2 100644 --- a/src/lib/core/style/_list-common.scss +++ b/src/lib/core/style/_list-common.scss @@ -2,7 +2,7 @@ // truncate neatly with an ellipsis. @mixin mat-truncate-line() { white-space: nowrap; - overflow-x: hidden; + overflow: hidden; text-overflow: ellipsis; } diff --git a/src/lib/select/index.ts b/src/lib/select/index.ts index c1e58f3ed157..e5390113f94a 100644 --- a/src/lib/select/index.ts +++ b/src/lib/select/index.ts @@ -2,10 +2,7 @@ import {NgModule, ModuleWithProviders} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MdSelect} from './select'; import {MdOptionModule} from '../core/option/option'; -import { - CompatibilityModule, - OverlayModule, -} from '../core'; +import {CompatibilityModule, OverlayModule} from '../core'; @NgModule({ diff --git a/src/lib/select/select-errors.ts b/src/lib/select/select-errors.ts new file mode 100644 index 000000000000..fcfb2f7ed597 --- /dev/null +++ b/src/lib/select/select-errors.ts @@ -0,0 +1,22 @@ +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. + * Note that `undefined` and `null` are still valid values to allow for resetting the value. + * @docs-private + */ +export class MdSelectNonArrayValueError extends MdError { + constructor() { + super('Cannot assign truthy non-array value to select in `multiple` mode.'); + } +} diff --git a/src/lib/select/select.html b/src/lib/select/select.html index d3ae2a6ca256..7e4a09e8832b 100644 --- a/src/lib/select/select.html +++ b/src/lib/select/select.html @@ -1,12 +1,12 @@
{{ placeholder }} - - {{ selected?.viewValue }} + + {{ triggerValue }} diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 868e5e3c71ba..aceba1735fbf 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -12,6 +12,7 @@ import { import {MdSelectModule} from './index'; import {OverlayContainer} from '../core/overlay/overlay-container'; import {MdSelect, MdSelectFloatPlaceholderType} from './select'; +import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors'; import {MdOption} from '../core/option/option'; import {Dir} from '../core/rtl/dir'; import { @@ -36,6 +37,7 @@ describe('MdSelect', () => { SelectWithChangeEvent, CustomSelectAccessor, CompWithCustomSelect, + MultiSelect, FloatPlaceholderSelect, SelectWithErrorSibling, ThrowsErrorOnInit, @@ -69,6 +71,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; @@ -179,6 +202,7 @@ describe('MdSelect', () => { fixture.detectChanges(); option = overlayContainerElement.querySelector('md-option') as HTMLElement; + expect(option.classList).toContain('mat-selected'); expect(fixture.componentInstance.options.first.selected).toBe(true); expect(fixture.componentInstance.select.selected) @@ -226,7 +250,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.'); }); })); @@ -296,27 +320,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; @@ -976,8 +979,8 @@ describe('MdSelect', () => { describe('x-axis positioning', () => { beforeEach(() => { - select.style.marginLeft = '20px'; - select.style.marginRight = '20px'; + select.style.marginLeft = '30px'; + select.style.marginRight = '30px'; }); it('should align the trigger and the selected option on the x-axis in ltr', () => { @@ -1013,6 +1016,49 @@ describe('MdSelect', () => { }); }); + describe('x-axis positioning in multi select mode', () => { + let multiFixture: ComponentFixture; + + beforeEach(() => { + multiFixture = TestBed.createComponent(MultiSelect); + multiFixture.detectChanges(); + trigger = multiFixture.debugElement.query(By.css('.mat-select-trigger')).nativeElement; + select = multiFixture.debugElement.query(By.css('md-select')).nativeElement; + + select.style.marginLeft = '20px'; + select.style.marginRight = '20px'; + }); + + it('should adjust for the checkbox in ltr', () => { + trigger.click(); + multiFixture.detectChanges(); + + const triggerLeft = trigger.getBoundingClientRect().left; + const firstOptionLeft = + document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().left; + + // 48px accounts for the checkbox size, margin and the panel's padding. + expect(firstOptionLeft.toFixed(2)) + .toEqual((triggerLeft - 48).toFixed(2), + `Expected trigger label to align along x-axis, accounting for the checkbox.`); + }); + + it('should adjust for the checkbox in rtl', () => { + dir.value = 'rtl'; + trigger.click(); + multiFixture.detectChanges(); + + const triggerRight = trigger.getBoundingClientRect().right; + const firstOptionRight = + document.querySelector('.cdk-overlay-pane md-option').getBoundingClientRect().right; + + // 48px accounts for the checkbox size, margin and the panel's padding. + expect(firstOptionRight.toFixed(2)) + .toEqual((triggerRight + 48).toFixed(2), + `Expected trigger label to align along x-axis, accounting for the checkbox.`); + }); + }); + }); describe('accessibility', () => { @@ -1280,7 +1326,7 @@ describe('MdSelect', () => { trigger.click(); fixture.detectChanges(); - let option = overlayContainerElement.querySelector('md-option') as HTMLElement; + const option = overlayContainerElement.querySelector('md-option') as HTMLElement; option.click(); option.click(); @@ -1355,6 +1401,181 @@ describe('MdSelect', () => { }); + 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('.mat-select-trigger')).nativeElement; + }); + + it('should be able to select multiple values', () => { + trigger.click(); + fixture.detectChanges(); + + const 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(); + + const 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(); + + const 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(); + + const optionNodes = overlayContainerElement.querySelectorAll('md-option') as + NodeListOf; + + const optionInstances = testInstance.options.toArray(); + + expect(optionNodes[0].classList).toContain('mat-selected'); + expect(optionNodes[5].classList).toContain('mat-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(); + + const options = overlayContainerElement.querySelectorAll('md-option') as + NodeListOf; + + options[0].click(); + fixture.detectChanges(); + + expect(options[0].classList).toContain('mat-selected'); + + testInstance.control.setValue(['eggs-5']); + fixture.detectChanges(); + + expect(options[0].classList).not.toContain('mat-selected'); + expect(options[5].classList).toContain('mat-selected'); + }); + + it('should not close the panel when clicking on options', () => { + trigger.click(); + fixture.detectChanges(); + + expect(testInstance.select.panelOpen).toBe(true); + + const options = overlayContainerElement.querySelectorAll('md-option') as + NodeListOf; + + options[0].click(); + options[1].click(); + fixture.detectChanges(); + + expect(testInstance.select.panelOpen).toBe(true); + }); + + it('should sort the selected options based on their order in the panel', () => { + trigger.click(); + fixture.detectChanges(); + + const options = overlayContainerElement.querySelectorAll('md-option') as + NodeListOf; + + options[2].click(); + options[0].click(); + options[1].click(); + fixture.detectChanges(); + + expect(trigger.textContent).toContain('Steak, Pizza, Tacos'); + expect(fixture.componentInstance.control.value).toEqual(['steak-0', 'pizza-1', 'tacos-2']); + }); + + it('should sort the values, that get set via the model, based on the panel order', () => { + trigger.click(); + fixture.detectChanges(); + + testInstance.control.setValue(['tacos-2', 'steak-0', 'pizza-1']); + fixture.detectChanges(); + + expect(trigger.textContent).toContain('Steak, Pizza, Tacos'); + }); + + 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.'); + }); + }); + })); + + }); + }); @@ -1618,6 +1839,31 @@ class FloatPlaceholderSelect { @ViewChild(MdSelect) select: MdSelect; } +@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(); + + @ViewChild(MdSelect) select: MdSelect; + @ViewChildren(MdOption) options: QueryList; +} + class FakeViewportRuler { getViewportRect() { diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index ca7b390038ae..c0eb143bd086 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -15,16 +15,20 @@ import { ViewChild, ChangeDetectorRef, } 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'; import 'rxjs/add/operator/startWith'; @@ -56,6 +60,17 @@ export const SELECT_OPTION_HEIGHT_ADJUSTMENT = 9; /** The panel's padding on the x-axis */ export const SELECT_PANEL_PADDING_X = 16; +/** + * Distance between the panel edge and the option text in + * multi-selection mode. + * + * (SELECT_PADDING * 1.75) + 20 = 48 + * The padding is multiplied by 1.75 because the checkbox's margin is half the padding, and + * the browser adds ~4px, because we're using inline elements. + * The checkbox width is 20px. + */ +export const SELECT_MULTIPLE_PANEL_PADDING_X = SELECT_PANEL_PADDING_X * 1.75 + 20; + /** * The panel's padding on the y-axis. This padding indicates there are more * options available if you scroll. @@ -106,11 +121,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; @@ -130,6 +142,12 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** The placeholder displayed in the trigger of the select. */ private _placeholder: string; + /** Whether the component is in multiple selection mode. */ + private _multiple: boolean = false; + + /** Deals with the selection logic. */ + _selectionModel: SelectionModel; + /** The animation state of the placeholder. */ private _placeholderState = ''; @@ -229,6 +247,17 @@ 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._selectionModel) { + throw new MdSelectDynamicMultipleError(); + } + + this._multiple = coerceBooleanProperty(value); + } + /** Whether to float the placeholder text. */ @Input() get floatPlaceholder(): MdSelectFloatPlaceholderType { return this._floatPlaceholder; } @@ -237,6 +266,11 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr } private _floatPlaceholder: MdSelectFloatPlaceholderType = 'auto'; + /** Combined stream of all of the child options' change events. */ + get optionSelectionChanges(): Observable { + return Observable.merge(...this.options.map(option => option.onSelectionChange)); + } + /** Event emitted when the select has been opened. */ @Output() onOpen: EventEmitter = new EventEmitter(); @@ -255,6 +289,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr } ngAfterContentInit() { + this._selectionModel = new SelectionModel(this.multiple, null, false); this._initKeyManager(); this._changeSubscription = this.options.changes.startWith(null).subscribe(() => { @@ -297,11 +332,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._selectionModel.isEmpty()) { + this._placeholderState = ''; + } + this._focusHost(); } - this._focusHost(); } /** @@ -354,10 +391,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._selectionModel.selected : this._selectionModel.selected[0]; + } + + /** The value displayed in the trigger. */ + get triggerValue(): string { + return this.multiple ? + this._selectionModel.selected.map(option => option.viewValue).join(', ') : + this._selectionModel.selected[0].viewValue; } + /** Whether the element is in RTL mode. */ _isRtl(): boolean { return this._dir ? this._dir.value === 'rtl' : false; } @@ -428,16 +473,56 @@ 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 correspondingOption = this.options.find(option => option.value === value); - correspondingOption ? correspondingOption.select() : this._clearSelection(); + 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) => this._selectValue(currentValue)); + this._sortValues(); + } else if (!this._selectValue(value)) { + this._clearSelection(); + } + + this._setValueWidth(); + + if (this._selectionModel.isEmpty()) { + this._placeholderState = ''; + } + this._changeDetectorRef.markForCheck(); } - /** Clears the select trigger and deselects every option in the list. */ - private _clearSelection(): void { - this._selected = null; - this._updateOptions(); + /** + * Finds and selects and option based on its value. + * @returns Option that has the corresponding value. + */ + private _selectValue(value: any): MdOption { + let correspondingOption = this.options.find(option => option.value === value); + + if (correspondingOption) { + correspondingOption.select(); + this._selectionModel.select(correspondingOption); + } + + return correspondingOption; + } + + /** + * Clears the select trigger and deselects every option in the list. + * @param skip Option that should not be deselected. + */ + private _clearSelection(skip?: MdOption): void { + this._selectionModel.clear(); + this.options.forEach(option => { + if (option !== skip) { + option.deselect(); + } + }); } private _getTriggerRect(): ClientRect { @@ -447,9 +532,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. */ @@ -457,31 +540,73 @@ 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); + this._optionSubscription = this.optionSelectionChanges + .filter(event => event.isUserInput) + .subscribe(event => { + this._onSelect(event.source); + this._setValueWidth(); + + if (!this.multiple) { + this.close(); } - this._onSelect(option); }); - this._subscriptions.push(sub); - }); + } + + /** Invoked when an option is clicked. */ + private _onSelect(option: MdOption): void { + const wasSelected = this._selectionModel.isSelected(option); + + if (this.multiple) { + this._selectionModel.toggle(option); + wasSelected ? option.deselect() : option.select(); + this._sortValues(); + } else { + this._clearSelection(option); + this._selectionModel.select(option); + } + + if (wasSelected !== this._selectionModel.isSelected(option)) { + this._propagateChanges(); + } + } + + /** + * Sorts the model values, ensuring that they keep the same + * order that they have in the panel. + */ + private _sortValues(): void { + if (this._multiple) { + this._selectionModel.clear(); + + this.options.forEach(option => { + if (option.selected) { + this._selectionModel.select(option); + } + }); + } } /** 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)); + /** Emits change event to set the model value. */ + 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. */ @@ -489,26 +614,19 @@ 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(); + /** + * 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() { + if (this.multiple) { + Promise.resolve(null).then(() => { + this.options.forEach(option => option.multiple = this.multiple); + }); } } - /** Deselect each option that doesn't match the current selection. */ - private _updateOptions(): void { - this.options.forEach((option: MdOption) => { - if (option !== this.selected) { - option.deselect(); - } - }); - } - /** * Must set the width of the selected option's value programmatically * because it is absolutely positioned and otherwise will not clip @@ -518,14 +636,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._selectionModel.isEmpty()) { this._keyManager.setFirstItemActive(); + } else { + this._keyManager.setActiveItem(this._getOptionIndex(this._selectionModel.selected[0])); } } @@ -543,7 +662,11 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Calculates the scroll position and x- and y-offsets of the overlay panel. */ private _calculateOverlayPosition(): void { - this._offsetX = this._isRtl() ? SELECT_PANEL_PADDING_X : -SELECT_PANEL_PADDING_X; + this._offsetX = this.multiple ? SELECT_MULTIPLE_PANEL_PADDING_X : SELECT_PANEL_PADDING_X; + + if (!this._isRtl()) { + this._offsetX *= -1; + } const panelHeight = Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT); @@ -552,8 +675,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._selectionModel.hasValue()) { + const selectedIndex = this._getOptionIndex(this._selectionModel.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; @@ -609,7 +732,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr * Determines the CSS `visibility` of the placeholder element. */ _getPlaceholderVisibility(): 'visible'|'hidden' { - return (this.floatPlaceholder !== 'never' || !this.selected) ? 'visible' : 'hidden'; + return (this.floatPlaceholder !== 'never' || this._selectionModel.isEmpty()) ? + 'visible' : 'hidden'; } /** @@ -663,7 +787,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr const panelHeightTop = Math.abs(this._offsetY); const totalPanelHeight = Math.min(this.options.length * SELECT_OPTION_HEIGHT, SELECT_PANEL_MAX_HEIGHT); - const panelHeightBottom = totalPanelHeight - panelHeightTop - triggerRect.height; + const panelHeightBottom = totalPanelHeight - panelHeightTop - triggerRect.height; if (panelHeightBottom > bottomSpaceAvailable) { this._adjustPanelUp(panelHeightBottom, bottomSpaceAvailable);