Skip to content

Commit

Permalink
feat(select): add multiple selection mode (#2722)
Browse files Browse the repository at this point in the history
* * 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
  • Loading branch information
crisbeto authored and tinayuangao committed Mar 9, 2017
1 parent ce0e933 commit dcc8576
Show file tree
Hide file tree
Showing 16 changed files with 710 additions and 160 deletions.
75 changes: 53 additions & 22 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
@@ -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 }}
Expand All @@ -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>

<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>
<p> Change event value: {{ latestChangeEvent?.value }} </p>
</md-card-content>
</md-card>
</div>

Expand Down
17 changes: 14 additions & 3 deletions src/demo-app/select/select-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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'];
}
}
10 changes: 5 additions & 5 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/lib/core/option/_option-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -26,4 +30,4 @@
}

}
}
}
10 changes: 10 additions & 0 deletions src/lib/core/option/_option.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 7 additions & 0 deletions src/lib/core/option/option.html
Original file line number Diff line number Diff line change
@@ -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>
56 changes: 36 additions & 20 deletions src/lib/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@ 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
* the component definition.
*/
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) { }
}


Expand All @@ -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()',
Expand All @@ -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;

Expand All @@ -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.
Expand All @@ -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. */
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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]
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
}

.mat-pseudo-checkbox-checked, .mat-pseudo-checkbox-indeterminate {
border: none;

&.mat-primary {
background: mat-color($primary, 500);
}
Expand Down
Loading

0 comments on commit dcc8576

Please sign in to comment.