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
This commit is contained in:
Kristiyan Kostadinov
2017-03-09 22:57:12 +01:00
committed by tinayuangao
parent ce0e933e0b
commit dcc857664c
16 changed files with 710 additions and 160 deletions
+53 -22
View File
@@ -1,23 +1,10 @@
<div style="height: 1000px">This div is for testing scrolled selects.</div>
<button md-button (click)="showSelect=!showSelect">SHOW SELECT</button>
<div class="demo-select">
<div *ngIf="showSelect">
<md-card>
<md-select placeholder="Food i would like to eat" [formControl]="foodControl">
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
</md-select>
<p> Value: {{ foodControl.value }} </p>
<p> Touched: {{ foodControl.touched }} </p>
<p> Dirty: {{ foodControl.dirty }} </p>
<p> Status: {{ foodControl.status }} </p>
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
<button md-button (click)="foodControl.reset()">RESET</button>
</md-card>
</div>
<md-card>
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="isRequired" [disabled]="isDisabled"
<md-card-subtitle>ngModel</md-card-subtitle>
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired" [disabled]="drinksDisabled"
[floatPlaceholder]="floatPlaceholder" #drinkControl="ngModel">
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
@@ -37,18 +24,62 @@
</p>
<button md-button (click)="currentDrink='water-2'">SET VALUE</button>
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
<button md-button (click)="isDisabled=!isDisabled">TOGGLE DISABLED</button>
<button md-button (click)="drinksRequired=!drinksRequired">TOGGLE REQUIRED</button>
<button md-button (click)="drinksDisabled=!drinksDisabled">TOGGLE DISABLED</button>
<button md-button (click)="drinkControl.reset()">RESET</button>
</md-card>
<md-card>
<md-card-subtitle>Multiple selection</md-card-subtitle>
<md-card-content>
<md-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon"
[required]="pokemonRequired" [disabled]="pokemonDisabled" #pokemonControl="ngModel">
<md-option *ngFor="let creature of pokemon" [value]="creature.value">
{{ creature.viewValue }}
</md-option>
</md-select>
<p> Value: {{ currentPokemon }} </p>
<p> Touched: {{ pokemonControl.touched }} </p>
<p> Dirty: {{ pokemonControl.dirty }} </p>
<p> Status: {{ pokemonControl.control?.status }} </p>
<button md-button (click)="setPokemonValue()">SET VALUE</button>
<button md-button (click)="pokemonRequired=!pokemonRequired">TOGGLE REQUIRED</button>
<button md-button (click)="pokemonDisabled=!pokemonDisabled">TOGGLE DISABLED</button>
<button md-button (click)="pokemonControl.reset()">RESET</button>
</md-card-content>
</md-card>
<div *ngIf="showSelect">
<md-card>
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
<md-option *ngFor="let starter of pokemon" [value]="starter.value"> {{ starter.viewValue }} </md-option>
</md-select>
<md-card-subtitle>formControl</md-card-subtitle>
<p> Change event value: {{ latestChangeEvent?.value }} </p>
<md-card-content>
<md-select placeholder="Food i would like to eat" [formControl]="foodControl">
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }}</md-option>
</md-select>
<p> Value: {{ foodControl.value }} </p>
<p> Touched: {{ foodControl.touched }} </p>
<p> Dirty: {{ foodControl.dirty }} </p>
<p> Status: {{ foodControl.status }} </p>
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
<button md-button (click)="foodControl.reset()">RESET</button>
</md-card-content>
</md-card>
</div>
<div *ngIf="showSelect">
<md-card>
<md-card-subtitle>Change event</md-card-subtitle>
<md-card-content>
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
<md-option *ngFor="let creature of pokemon" [value]="creature.value">{{ creature.viewValue }}</md-option>
</md-select>
<p> Change event value: {{ latestChangeEvent?.value }} </p>
</md-card-content>
</md-card>
</div>
+14 -3
View File
@@ -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'];
}
}
+5 -5
View File
@@ -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<MdOptionSelectEvent> {
get panelClosingActions(): Observable<MdOptionSelectionChange> {
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<MdOptionSelectEvent> {
return Observable.merge(...this.autocomplete.options.map(option => option.onSelect));
get optionSelections(): Observable<MdOptionSelectionChange> {
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);
+6 -2
View File
@@ -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 @@
}
}
}
}
+10
View File
@@ -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;
}
}
}
+7
View File
@@ -1,3 +1,10 @@
<span [ngSwitch]="_isCompatibilityMode" *ngIf="multiple">
<mat-pseudo-checkbox class="mat-option-pseudo-checkbox" *ngSwitchCase="true"
[state]="selected ? 'checked' : ''" color="primary"></mat-pseudo-checkbox>
<md-pseudo-checkbox class="mat-option-pseudo-checkbox" *ngSwitchDefault
[state]="selected ? 'checked' : ''" color="primary"></md-pseudo-checkbox>
</span>
<ng-content></ng-content>
<div class="mat-option-ripple" *ngIf="!disabled" md-ripple [mdRippleTrigger]="_getHostElement()">
</div>
+36 -20
View File
@@ -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<MdOptionSelectEvent>();
/** Event emitted when the option is selected or deselected. */
@Output() onSelectionChange = new EventEmitter<MdOptionSelectionChange>();
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]
})
@@ -24,8 +24,6 @@
}
.mat-pseudo-checkbox-checked, .mat-pseudo-checkbox-indeterminate {
border: none;
&.mat-primary {
background: mat-color($primary, 500);
}
+50
View File
@@ -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<any>;
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);
+46 -12
View File
@@ -28,9 +28,13 @@ export class SelectionModel<T> {
}
/** Event emitted when the value has changed. */
onChange: Subject<SelectionChange<T>> = new Subject();
onChange: Subject<SelectionChange<T>> = 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<T> {
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<T> {
}
/**
* 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<T> {
this.onChange.next(eventData);
this._deselectedToEmit = [];
this._selectedToEmit = [];
this._selected = null;
}
this._selected = null;
}
/** Selects a value. */
@@ -101,17 +129,23 @@ export class SelectionModel<T> {
}
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() {
+1 -1
View File
@@ -2,7 +2,7 @@
// truncate neatly with an ellipsis.
@mixin mat-truncate-line() {
white-space: nowrap;
overflow-x: hidden;
overflow: hidden;
text-overflow: ellipsis;
}
+1 -4
View File
@@ -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({
+22
View File
@@ -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.');
}
}
+3 -3
View File
@@ -1,12 +1,12 @@
<div class="mat-select-trigger" cdk-overlay-origin (click)="toggle()" #origin="cdkOverlayOrigin" #trigger>
<span
class="mat-select-placeholder"
[class.mat-floating-placeholder]="selected"
[class.mat-floating-placeholder]="_selectionModel.hasValue()"
[@transformPlaceholder]="_getPlaceholderAnimationState()"
[style.visibility]="_getPlaceholderVisibility()"
[style.width.px]="_selectedValueWidth"> {{ placeholder }} </span>
<span class="mat-select-value" *ngIf="selected">
<span class="mat-select-value-text">{{ selected?.viewValue }}</span>
<span class="mat-select-value" *ngIf="_selectionModel.hasValue()">
<span class="mat-select-value-text">{{ triggerValue }}</span>
</span>
<span class="mat-select-arrow"></span>
+271 -25
View File
@@ -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<BasicSelect>;
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<BasicSelect>;
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<MultiSelect>;
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<MultiSelect>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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<HTMLElement>;
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: `
<md-select multiple placeholder="Food" [formControl]="control">
<md-option *ngFor="let food of foods" [value]="food.value">{{ food.viewValue }}</md-option>
</md-select>
`
})
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<MdOption>;
}
class FakeViewportRuler {
getViewportRect() {
+185 -61
View File
@@ -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<MdOption>;
/** 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<MdOptionSelectionChange> {
return Observable.merge(...this.options.map(option => option.onSelectionChange));
}
/** Event emitted when the select has been opened. */
@Output() onOpen: EventEmitter<void> = new EventEmitter<void>();
@@ -255,6 +289,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
}
ngAfterContentInit() {
this._selectionModel = new SelectionModel<MdOption>(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);