Skip to content

Commit dcc8576

Browse files
crisbetotinayuangao
authored andcommitted
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
1 parent ce0e933 commit dcc8576

16 files changed

+710
-160
lines changed

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

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,10 @@
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>
8-
</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>
18-
194
<md-card>
20-
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="isRequired" [disabled]="isDisabled"
5+
<md-card-subtitle>ngModel</md-card-subtitle>
6+
7+
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired" [disabled]="drinksDisabled"
218
[floatPlaceholder]="floatPlaceholder" #drinkControl="ngModel">
229
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
2310
{{ drink.viewValue }}
@@ -37,18 +24,62 @@
3724
</p>
3825

3926
<button md-button (click)="currentDrink='water-2'">SET VALUE</button>
40-
<button md-button (click)="isRequired=!isRequired">TOGGLE REQUIRED</button>
41-
<button md-button (click)="isDisabled=!isDisabled">TOGGLE DISABLED</button>
27+
<button md-button (click)="drinksRequired=!drinksRequired">TOGGLE REQUIRED</button>
28+
<button md-button (click)="drinksDisabled=!drinksDisabled">TOGGLE DISABLED</button>
4229
<button md-button (click)="drinkControl.reset()">RESET</button>
4330
</md-card>
4431

32+
<md-card>
33+
<md-card-subtitle>Multiple selection</md-card-subtitle>
34+
35+
<md-card-content>
36+
<md-select multiple placeholder="Pokemon" [(ngModel)]="currentPokemon"
37+
[required]="pokemonRequired" [disabled]="pokemonDisabled" #pokemonControl="ngModel">
38+
<md-option *ngFor="let creature of pokemon" [value]="creature.value">
39+
{{ creature.viewValue }}
40+
</md-option>
41+
</md-select>
42+
<p> Value: {{ currentPokemon }} </p>
43+
<p> Touched: {{ pokemonControl.touched }} </p>
44+
<p> Dirty: {{ pokemonControl.dirty }} </p>
45+
<p> Status: {{ pokemonControl.control?.status }} </p>
46+
<button md-button (click)="setPokemonValue()">SET VALUE</button>
47+
<button md-button (click)="pokemonRequired=!pokemonRequired">TOGGLE REQUIRED</button>
48+
<button md-button (click)="pokemonDisabled=!pokemonDisabled">TOGGLE DISABLED</button>
49+
<button md-button (click)="pokemonControl.reset()">RESET</button>
50+
</md-card-content>
51+
</md-card>
52+
4553
<div *ngIf="showSelect">
4654
<md-card>
47-
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
48-
<md-option *ngFor="let starter of pokemon" [value]="starter.value"> {{ starter.viewValue }} </md-option>
49-
</md-select>
55+
<md-card-subtitle>formControl</md-card-subtitle>
56+
57+
<md-card-content>
58+
<md-select placeholder="Food i would like to eat" [formControl]="foodControl">
59+
<md-option *ngFor="let food of foods" [value]="food.value"> {{ food.viewValue }}</md-option>
60+
</md-select>
61+
<p> Value: {{ foodControl.value }} </p>
62+
<p> Touched: {{ foodControl.touched }} </p>
63+
<p> Dirty: {{ foodControl.dirty }} </p>
64+
<p> Status: {{ foodControl.status }} </p>
65+
<button md-button (click)="foodControl.setValue('pizza-1')">SET VALUE</button>
66+
<button md-button (click)="toggleDisabled()">TOGGLE DISABLED</button>
67+
<button md-button (click)="foodControl.reset()">RESET</button>
68+
</md-card-content>
69+
</md-card>
70+
</div>
71+
72+
<div *ngIf="showSelect">
73+
<md-card>
74+
<md-card-subtitle>Change event</md-card-subtitle>
75+
76+
<md-card-content>
77+
<md-select placeholder="Starter Pokemon" (change)="latestChangeEvent = $event">
78+
<md-option *ngFor="let creature of pokemon" [value]="creature.value">{{ creature.viewValue }}</md-option>
79+
</md-select>
5080

51-
<p> Change event value: {{ latestChangeEvent?.value }} </p>
81+
<p> Change event value: {{ latestChangeEvent?.value }} </p>
82+
</md-card-content>
5283
</md-card>
5384
</div>
5485

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

Lines changed: 14 additions & 3 deletions
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
floatPlaceholder: string = 'auto';
1821
foodControl = new FormControl('pizza-1');
@@ -38,10 +41,18 @@ export class SelectDemo {
3841
pokemon = [
3942
{value: 'bulbasaur-0', viewValue: 'Bulbasaur'},
4043
{value: 'charizard-1', viewValue: 'Charizard'},
41-
{value: 'squirtle-2', viewValue: 'Squirtle'}
44+
{value: 'squirtle-2', viewValue: 'Squirtle'},
45+
{value: 'pikachu-3', viewValue: 'Pikachu'},
46+
{value: 'eevee-4', viewValue: 'Eevee'},
47+
{value: 'ditto-5', viewValue: 'Ditto'},
48+
{value: 'psyduck-6', viewValue: 'Psyduck'},
4249
];
4350

4451
toggleDisabled() {
4552
this.foodControl.enabled ? this.foodControl.disable() : this.foodControl.enable();
4653
}
54+
55+
setPokemonValue() {
56+
this.currentPokemon = ['eevee-4', 'psyduck-6'];
57+
}
4758
}

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {MdAutocomplete} from './autocomplete';
1515
import {PositionStrategy} from '../core/overlay/position/position-strategy';
1616
import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy';
1717
import {Observable} from 'rxjs/Observable';
18-
import {MdOptionSelectEvent, MdOption} from '../core/option/option';
18+
import {MdOptionSelectionChange, MdOption} from '../core/option/option';
1919
import {ENTER, UP_ARROW, DOWN_ARROW} from '../core/keyboard/keycodes';
2020
import {Dir} from '../core/rtl/dir';
2121
import {Subscription} from 'rxjs/Subscription';
@@ -146,7 +146,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
146146
* A stream of actions that should close the autocomplete panel, including
147147
* when an option is selected, on blur, and when TAB is pressed.
148148
*/
149-
get panelClosingActions(): Observable<MdOptionSelectEvent> {
149+
get panelClosingActions(): Observable<MdOptionSelectionChange> {
150150
return Observable.merge(
151151
this.optionSelections,
152152
this._blurStream.asObservable(),
@@ -155,8 +155,8 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
155155
}
156156

157157
/** Stream of autocomplete option selections. */
158-
get optionSelections(): Observable<MdOptionSelectEvent> {
159-
return Observable.merge(...this.autocomplete.options.map(option => option.onSelect));
158+
get optionSelections(): Observable<MdOptionSelectionChange> {
159+
return Observable.merge(...this.autocomplete.options.map(option => option.onSelectionChange));
160160
}
161161

162162
/** The currently active option, coerced to MdOption type. */
@@ -301,7 +301,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
301301
* control to that value. It will also mark the control as dirty if this interaction
302302
* stemmed from the user.
303303
*/
304-
private _setValueAndClose(event: MdOptionSelectEvent | null): void {
304+
private _setValueAndClose(event: MdOptionSelectionChange | null): void {
305305
if (event) {
306306
this._setTriggerValue(event.source.value);
307307
this._onChange(event.source.value);

src/lib/core/option/_option-theme.scss

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@
1212
}
1313

1414
&.mat-selected {
15-
background: mat-color($background, hover);
1615
color: mat-color($primary);
16+
17+
// In multiple mode there is a checkbox to show that the option is selected.
18+
&:not(.mat-option-multiple) {
19+
background: mat-color($background, hover);
20+
}
1721
}
1822

1923
&.mat-active {
@@ -26,4 +30,4 @@
2630
}
2731

2832
}
29-
}
33+
}

src/lib/core/option/_option.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,15 @@
3030
opacity: 0.5;
3131
}
3232
}
33+
34+
.mat-option-pseudo-checkbox {
35+
$margin: $mat-menu-side-padding / 2;
36+
margin-right: $margin;
37+
38+
[dir='rtl'] & {
39+
margin-left: $margin;
40+
margin-right: 0;
41+
}
42+
}
3343
}
3444

src/lib/core/option/option.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
<span [ngSwitch]="_isCompatibilityMode" *ngIf="multiple">
2+
<mat-pseudo-checkbox class="mat-option-pseudo-checkbox" *ngSwitchCase="true"
3+
[state]="selected ? 'checked' : ''" color="primary"></mat-pseudo-checkbox>
4+
<md-pseudo-checkbox class="mat-option-pseudo-checkbox" *ngSwitchDefault
5+
[state]="selected ? 'checked' : ''" color="primary"></md-pseudo-checkbox>
6+
</span>
7+
18
<ng-content></ng-content>
29
<div class="mat-option-ripple" *ngIf="!disabled" md-ripple [mdRippleTrigger]="_getHostElement()">
310
</div>

src/lib/core/option/option.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,26 @@ import {
77
NgModule,
88
ModuleWithProviders,
99
Renderer,
10-
ViewEncapsulation
10+
ViewEncapsulation,
11+
Inject,
12+
Optional,
1113
} from '@angular/core';
1214
import {CommonModule} from '@angular/common';
1315
import {ENTER, SPACE} from '../keyboard/keycodes';
1416
import {coerceBooleanProperty} from '../coercion/boolean-property';
1517
import {MdRippleModule} from '../ripple/index';
18+
import {MdSelectionModule} from '../selection/index';
19+
import {MATERIAL_COMPATIBILITY_MODE} from '../../core/compatibility/compatibility';
1620

1721
/**
1822
* Option IDs need to be unique across components, so this counter exists outside of
1923
* the component definition.
2024
*/
2125
let _uniqueIdCounter = 0;
2226

23-
/** Event object emitted by MdOption when selected. */
24-
export class MdOptionSelectEvent {
25-
constructor(public source: MdOption, public isUserInput = false) {}
27+
/** Event object emitted by MdOption when selected or deselected. */
28+
export class MdOptionSelectionChange {
29+
constructor(public source: MdOption, public isUserInput = false) { }
2630
}
2731

2832

@@ -36,6 +40,7 @@ export class MdOptionSelectEvent {
3640
'role': 'option',
3741
'[attr.tabindex]': '_getTabIndex()',
3842
'[class.mat-selected]': 'selected',
43+
'[class.mat-option-multiple]': 'multiple',
3944
'[class.mat-active]': 'active',
4045
'[id]': 'id',
4146
'[attr.aria-selected]': 'selected.toString()',
@@ -57,9 +62,15 @@ export class MdOption {
5762

5863
private _id: string = `md-option-${_uniqueIdCounter++}`;
5964

65+
/** Whether the wrapping component is in multiple selection mode. */
66+
multiple: boolean = false;
67+
6068
/** The unique ID of the option. */
6169
get id() { return this._id; }
6270

71+
/** Whether or not the option is currently selected. */
72+
get selected(): boolean { return this._selected; }
73+
6374
/** The form value of the option. */
6475
@Input() value: any;
6576

@@ -68,15 +79,13 @@ export class MdOption {
6879
get disabled() { return this._disabled; }
6980
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
7081

71-
/** Event emitted when the option is selected. */
72-
@Output() onSelect = new EventEmitter<MdOptionSelectEvent>();
82+
/** Event emitted when the option is selected or deselected. */
83+
@Output() onSelectionChange = new EventEmitter<MdOptionSelectionChange>();
7384

74-
constructor(private _element: ElementRef, private _renderer: Renderer) {}
75-
76-
/** Whether or not the option is currently selected. */
77-
get selected(): boolean {
78-
return this._selected;
79-
}
85+
constructor(
86+
private _element: ElementRef,
87+
private _renderer: Renderer,
88+
@Optional() @Inject(MATERIAL_COMPATIBILITY_MODE) public _isCompatibilityMode: boolean) {}
8089

8190
/**
8291
* Whether or not the option is currently active and ready to be selected.
@@ -100,12 +109,13 @@ export class MdOption {
100109
/** Selects the option. */
101110
select(): void {
102111
this._selected = true;
103-
this.onSelect.emit(new MdOptionSelectEvent(this, false));
112+
this._emitSelectionChangeEvent();
104113
}
105114

106115
/** Deselects the option. */
107116
deselect(): void {
108117
this._selected = false;
118+
this._emitSelectionChangeEvent();
109119
}
110120

111121
/** Sets focus onto this option. */
@@ -118,7 +128,7 @@ export class MdOption {
118128
* active. This is used by the ActiveDescendantKeyManager so key
119129
* events will display the proper options as active on arrow key events.
120130
*/
121-
setActiveStyles() {
131+
setActiveStyles(): void {
122132
Promise.resolve(null).then(() => this._active = true);
123133
}
124134

@@ -127,7 +137,7 @@ export class MdOption {
127137
* active. This is used by the ActiveDescendantKeyManager so key
128138
* events will display the proper options as active on arrow key events.
129139
*/
130-
setInactiveStyles() {
140+
setInactiveStyles(): void {
131141
Promise.resolve(null).then(() => this._active = false);
132142
}
133143

@@ -142,26 +152,32 @@ export class MdOption {
142152
* Selects the option while indicating the selection came from the user. Used to
143153
* determine if the select's view -> model callback should be invoked.
144154
*/
145-
_selectViaInteraction() {
155+
_selectViaInteraction(): void {
146156
if (!this.disabled) {
147-
this._selected = true;
148-
this.onSelect.emit(new MdOptionSelectEvent(this, true));
157+
this._selected = this.multiple ? !this._selected : true;
158+
this._emitSelectionChangeEvent(true);
149159
}
150160
}
151161

152162
/** Returns the correct tabindex for the option depending on disabled state. */
153-
_getTabIndex() {
163+
_getTabIndex(): string {
154164
return this.disabled ? '-1' : '0';
155165
}
156166

167+
/** Fetches the host DOM element. */
157168
_getHostElement(): HTMLElement {
158169
return this._element.nativeElement;
159170
}
160171

172+
/** Emits the selection change event. */
173+
private _emitSelectionChangeEvent(isUserInput = false): void {
174+
this.onSelectionChange.emit(new MdOptionSelectionChange(this, isUserInput));
175+
};
176+
161177
}
162178

163179
@NgModule({
164-
imports: [MdRippleModule, CommonModule],
180+
imports: [MdRippleModule, CommonModule, MdSelectionModule],
165181
exports: [MdOption],
166182
declarations: [MdOption]
167183
})

src/lib/core/selection/pseudo-checkbox/_pseudo-checkbox-theme.scss

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@
2424
}
2525

2626
.mat-pseudo-checkbox-checked, .mat-pseudo-checkbox-indeterminate {
27-
border: none;
28-
2927
&.mat-primary {
3028
background: mat-color($primary, 500);
3129
}

0 commit comments

Comments
 (0)