mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
committed by
tinayuangao
parent
ce0e933e0b
commit
dcc857664c
@@ -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>
|
||||
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// truncate neatly with an ellipsis.
|
||||
@mixin mat-truncate-line() {
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user