Skip to content

Commit 654869e

Browse files
committed
feat(select): add multiple selection mode
* Integrates the `SelectionModel` into `md-select`. * Adds the `multiple` option which allows users to select multiple options from a `md-select`. * Fixes a button that wasn't being cleaned up from dialog tests, causing some select tests to fail. Fixes angular#2412.
1 parent 4ab6f30 commit 654869e

File tree

11 files changed

+501
-132
lines changed

11 files changed

+501
-132
lines changed

src/demo-app/select/select-demo.html

+64-31
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,78 @@
11
<div style="height: 1000px">This div is for testing scrolled selects.</div>
22
<button md-button (click)="showSelect=!showSelect">SHOW SELECT</button>
33
<div class="demo-select">
4-
<div *ngIf="showSelect">
5-
<md-card>
6-
<md-select placeholder="Food i would like to eat" [formControl]="foodControl">
7-
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }} </md-option>
4+
<md-card>
5+
<md-card-subtitle>ngModel</md-card-subtitle>
6+
7+
<md-card-content>
8+
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired"
9+
[disabled]="drinksDisabled" #drinkControl="ngModel">
10+
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
11+
{{ drink.viewValue }}
12+
</md-option>
813
</md-select>
9-
<p> Value: {{ foodControl.value }} </p>
10-
<p> Touched: {{ foodControl.touched }} </p>
11-
<p> Dirty: {{ foodControl.dirty }} </p>
12-
<p> Status: {{ foodControl.status }} </p>
13-
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
14-
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
15-
<button md-button (click)="foodControl.reset()">RESET</button>
16-
</md-card>
17-
</div>
14+
<p> Value: {{ currentDrink }} </p>
15+
<p> Touched: {{ drinkControl.touched }} </p>
16+
<p> Dirty: {{ drinkControl.dirty }} </p>
17+
<p> Status: {{ drinkControl.control?.status }} </p>
18+
<button md-button (click)="currentDrink='sprite-1'">SET VALUE</button>
19+
<button md-button (click)="drinksRequired=!drinksRequired">TOGGLE REQUIRED</button>
20+
<button md-button (click)="drinksDisabled=!drinksDisabled">TOGGLE DISABLED</button>
21+
<button md-button (click)="drinkControl.reset()">RESET</button>
22+
</md-card-content>
23+
</md-card>
1824

1925
<md-card>
20-
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="isRequired" [disabled]="isDisabled"
21-
#drinkControl="ngModel">
22-
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
23-
{{ drink.viewValue }}
24-
</md-option>
25-
</md-select>
26-
<p> Value: {{ currentDrink }} </p>
27-
<p> Touched: {{ drinkControl.touched }} </p>
28-
<p> Dirty: {{ drinkControl.dirty }} </p>
29-
<p> Status: {{ drinkControl.control?.status }} </p>
30-
<button md-button (click)="currentDrink='sprite-1'">SET VALUE</button>
31-
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
32-
<button md-button (click)="isDisabled=!isDisabled">TOGGLE DISABLED</button>
33-
<button md-button (click)="drinkControl.reset()">RESET</button>
26+
<md-card-subtitle>Multiple selection</md-card-subtitle>
27+
28+
<md-card-content>
29+
<md-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon"
30+
[required]="pokemonRequired" [disabled]="pokemonDisabled" #pokemonControl="ngModel">
31+
<md-option *ngFor="let creature of pokemon" [value]="creature.value">
32+
{{ creature.viewValue }}
33+
</md-option>
34+
</md-select>
35+
<p> Value: {{ currentPokemon }} </p>
36+
<p> Touched: {{ pokemonControl.touched }} </p>
37+
<p> Dirty: {{ pokemonControl.dirty }} </p>
38+
<p> Status: {{ pokemonControl.control?.status }} </p>
39+
<button md-button (click)="currentPokemon=['eevee-4', 'psyduck-6']">SET VALUE</button>
40+
<button md-button (click)="pokemonRequired=!pokemonRequired">TOGGLE REQUIRED</button>
41+
<button md-button (click)="pokemonDisabled=!pokemonDisabled">TOGGLE DISABLED</button>
42+
<button md-button (click)="pokemonControl.reset()">RESET</button>
43+
</md-card-content>
3444
</md-card>
3545

3646
<div *ngIf="showSelect">
3747
<md-card>
38-
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
39-
<md-option *ngFor="let starter of pokemon" [value]="starter.value"> {{ starter.viewValue }} </md-option>
40-
</md-select>
48+
<md-card-subtitle>formControl</md-card-subtitle>
49+
50+
<md-card-content>
51+
<md-select placeholder="Food i would like to eat" [formControl]="foodControl">
52+
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }}</md-option>
53+
</md-select>
54+
<p> Value: {{ foodControl.value }} </p>
55+
<p> Touched: {{ foodControl.touched }} </p>
56+
<p> Dirty: {{ foodControl.dirty }} </p>
57+
<p> Status: {{ foodControl.status }} </p>
58+
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
59+
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
60+
<button md-button (click)="foodControl.reset()">RESET</button>
61+
</md-card-content>
62+
</md-card>
63+
</div>
64+
65+
<div *ngIf="showSelect">
66+
<md-card>
67+
<md-card-subtitle>Change event</md-card-subtitle>
68+
69+
<md-card-content>
70+
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
71+
<md-option *ngFor="let creature of pokemon" [value]="creature.value">{{ creature.viewValue }}</md-option>
72+
</md-select>
4173

42-
<p> Change event value: {{ latestChangeEvent?.value }} </p>
74+
<p> Change event value: {{ latestChangeEvent?.value }} </p>
75+
</md-card-content>
4376
</md-card>
4477
</div>
4578

src/demo-app/select/select-demo.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ import {MdSelectChange} from '@angular/material';
99
styleUrls: ['select-demo.css'],
1010
})
1111
export class SelectDemo {
12-
isRequired = false;
13-
isDisabled = false;
12+
drinksRequired = false;
13+
pokemonRequired = false;
14+
drinksDisabled = false;
15+
pokemonDisabled = false;
1416
showSelect = false;
1517
currentDrink: string;
18+
currentPokemon: string[];
1619
latestChangeEvent: MdSelectChange;
1720
foodControl = new FormControl('pizza-1');
1821

@@ -37,7 +40,11 @@ export class SelectDemo {
3740
pokemon = [
3841
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
3942
{value: 'charizard-1', viewValue: 'Charizard'},
40-
{value: 'squirtle-2', viewValue: 'Squirtle'}
43+
{value: 'squirtle-2', viewValue: 'Squirtle'},
44+
{value: 'pikachu-3', viewValue: 'Pikachu'},
45+
{value: 'eevee-4', viewValue: 'Eevee'},
46+
{value: 'ditto-5', viewValue: 'Ditto'},
47+
{value: 'psyduck-6', viewValue: 'Psyduck'},
4148
];
4249

4350
toggleDisabled() {

src/lib/autocomplete/autocomplete-trigger.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export class MdAutocompleteTrigger implements OnDestroy {
7272

7373
/** Stream of autocomplete option selections. */
7474
get optionSelections(): Observable<any>[] {
75-
return this.autocomplete.options.map(option => option.onSelect);
75+
return this.autocomplete.options.map(option => option.onToggle);
7676
}
7777

7878
/** Destroys the autocomplete suggestion panel. */

src/lib/core/option/option.ts

+22-10
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import {MdRippleModule} from '../ripple/ripple';
2020
*/
2121
let _uniqueIdCounter = 0;
2222

23+
export class MdOptionToggle {
24+
constructor(public source: MdOption, public isUserInput = false) { }
25+
}
26+
2327
/**
2428
* Single option inside of a `<md-select>` element.
2529
*/
@@ -48,9 +52,15 @@ export class MdOption {
4852

4953
private _id: string = `md-option-${_uniqueIdCounter++}`;
5054

55+
/** Whether the wrapping component is in multiple selection mode. */
56+
multiple: boolean = false;
57+
5158
/** The unique ID of the option. */
5259
get id() { return this._id; }
5360

61+
/** Whether or not the option is currently selected. */
62+
get selected(): boolean { return this._selected; }
63+
5464
/** The form value of the option. */
5565
@Input() value: any;
5666

@@ -59,16 +69,11 @@ export class MdOption {
5969
get disabled() { return this._disabled; }
6070
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
6171

62-
/** Event emitted when the option is selected. */
63-
@Output() onSelect = new EventEmitter();
72+
/** Event emitted when the option is selected or deselected. */
73+
@Output() onToggle = new EventEmitter<MdOptionToggle>();
6474

6575
constructor(private _element: ElementRef, private _renderer: Renderer) {}
6676

67-
/** Whether or not the option is currently selected. */
68-
get selected(): boolean {
69-
return this._selected;
70-
}
71-
7277
/**
7378
* The displayed value of the option. It is necessary to show the selected option in the
7479
* select's trigger.
@@ -81,12 +86,13 @@ export class MdOption {
8186
/** Selects the option. */
8287
select(): void {
8388
this._selected = true;
84-
this.onSelect.emit();
89+
this._emitToggleEvent();
8590
}
8691

8792
/** Deselects the option. */
8893
deselect(): void {
8994
this._selected = false;
95+
this._emitToggleEvent();
9096
}
9197

9298
/** Sets focus onto this option. */
@@ -107,8 +113,8 @@ export class MdOption {
107113
*/
108114
_selectViaInteraction() {
109115
if (!this.disabled) {
110-
this._selected = true;
111-
this.onSelect.emit(true);
116+
this._selected = this.multiple ? !this._selected : true;
117+
this._emitToggleEvent(true);
112118
}
113119
}
114120

@@ -117,10 +123,16 @@ export class MdOption {
117123
return this.disabled ? '-1' : '0';
118124
}
119125

126+
/** Fetches the host DOM element. */
120127
_getHostElement(): HTMLElement {
121128
return this._element.nativeElement;
122129
}
123130

131+
/** Emits the toggle event. */
132+
private _emitToggleEvent(isUserInput?: boolean) {
133+
this.onToggle.emit(new MdOptionToggle(this, isUserInput));
134+
};
135+
124136
}
125137

126138
@NgModule({

src/lib/core/selection/selection.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,16 @@ describe('SelectionModel', () => {
156156
expect(model.isEmpty()).toBe(false);
157157
});
158158

159+
it('should be able to toggle an option', () => {
160+
let model = new SelectionModel();
161+
162+
model.toggle(1);
163+
expect(model.isSelected(1)).toBe(true);
164+
165+
model.toggle(1);
166+
expect(model.isSelected(1)).toBe(false);
167+
});
168+
159169
it('should be able to clear the selected options', () => {
160170
let model = new SelectionModel(true);
161171

src/lib/core/selection/selection.ts

+11
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ export class SelectionModel<T> {
5959
this._emitChangeEvent();
6060
}
6161

62+
/**
63+
* Toggles a value between selected and deselected.
64+
*/
65+
toggle(value: T): void {
66+
if (this.isSelected(value)) {
67+
this.deselect(value);
68+
} else {
69+
this.select(value);
70+
}
71+
}
72+
6273
/**
6374
* Clears all of the selected values.
6475
*/

src/lib/dialog/dialog.spec.ts

+2
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,8 @@ describe('MdDialog', () => {
307307

308308
expect(document.activeElement.id)
309309
.toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close');
310+
311+
document.body.removeChild(button);
310312
}));
311313
});
312314

src/lib/select/select-errors.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {MdError} from '../core/errors/error';
2+
3+
/**
4+
* Exception thrown when attempting to change a select's `multiple` option after initialization.
5+
* @docs-private
6+
*/
7+
export class MdSelectDynamicMultipleError extends MdError {
8+
constructor() {
9+
super('Cannot change `multiple` mode of select after initialization.');
10+
}
11+
}
12+
13+
/**
14+
* Exception thrown when attempting to assign a non-array value to a select in `multiple` mode.
15+
* @docs-private
16+
*/
17+
export class MdSelectNonArrayValueError extends MdError {
18+
constructor() {
19+
super('Cannot assign non-array value to select in `multiple` mode.');
20+
}
21+
}

src/lib/select/select.html

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<div class="md-select-trigger" cdk-overlay-origin (click)="toggle()" #origin="cdkOverlayOrigin" #trigger>
2-
<span class="md-select-placeholder" [class.md-floating-placeholder]="this.selected"
2+
<span class="md-select-placeholder" [class.md-floating-placeholder]="!_model.isEmpty()"
33
[@transformPlaceholder]="_placeholderState" [style.width.px]="_selectedValueWidth"> {{ placeholder }} </span>
4-
<span class="md-select-value" *ngIf="selected"> {{ selected?.viewValue }} </span>
4+
<span class="md-select-value" *ngIf="!_model.isEmpty()"> {{ selectedLabel }} </span>
55
<span class="md-select-arrow"></span>
66
</div>
77

0 commit comments

Comments
 (0)