Skip to content

Commit

Permalink
feat(select): add multiple selection mode
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 angular#2412.
  • Loading branch information
crisbeto committed Jan 20, 2017
1 parent 0420729 commit 4fa000a
Show file tree
Hide file tree
Showing 11 changed files with 505 additions and 138 deletions.
95 changes: 64 additions & 31 deletions src/demo-app/select/select-demo.html
Original file line number Diff line number Diff line change
@@ -1,45 +1,78 @@
<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-card>
<md-card-subtitle>ngModel</md-card-subtitle>

<md-card-content>
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="drinksRequired"
[disabled]="drinksDisabled" #drinkControl="ngModel">
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.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>
<p> Value: {{ currentDrink }} </p>
<p> Touched: {{ drinkControl.touched }} </p>
<p> Dirty: {{ drinkControl.dirty }} </p>
<p> Status: {{ drinkControl.control?.status }} </p>
<button md-button (click)="currentDrink='sprite-1'">SET VALUE</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-content>
</md-card>

<md-card>
<md-select placeholder="Drink" [(ngModel)]="currentDrink" [required]="isRequired" [disabled]="isDisabled"
#drinkControl="ngModel">
<md-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drink.disabled">
{{ drink.viewValue }}
</md-option>
</md-select>
<p> Value: {{ currentDrink }} </p>
<p> Touched: {{ drinkControl.touched }} </p>
<p> Dirty: {{ drinkControl.dirty }} </p>
<p> Status: {{ drinkControl.control?.status }} </p>
<button md-button (click)="currentDrink='sprite-1'">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)="drinkControl.reset()">RESET</button>
<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)="currentPokemon=['eevee-4', 'psyduck-6']">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
13 changes: 10 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;
foodControl = new FormControl('pizza-1');

Expand All @@ -37,7 +40,11 @@ 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() {
Expand Down
6 changes: 3 additions & 3 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
import {MdAutocomplete} from './autocomplete';
import {PositionStrategy} from '../core/overlay/position/position-strategy';
import {Observable} from 'rxjs/Observable';
import {MdOptionSelectEvent} from '../core/option/option';
import {MdOptionSelectionChange} from '../core/option/option';
import 'rxjs/add/observable/merge';
import {Dir} from '../core/rtl/dir';
import 'rxjs/add/operator/startWith';
Expand Down Expand Up @@ -75,7 +75,7 @@ export class MdAutocompleteTrigger implements OnDestroy {

/** Stream of autocomplete option selections. */
get optionSelections(): Observable<any>[] {
return this.autocomplete.options.map(option => option.onSelect);
return this.autocomplete.options.map(option => option.onSelectionChange);
}


Expand Down Expand Up @@ -111,7 +111,7 @@ export class MdAutocompleteTrigger implements 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._controlDir.control.setValue(event.source.value);
if (event.isUserInput) {
Expand Down
34 changes: 21 additions & 13 deletions src/lib/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import {MdRippleModule} from '../ripple/ripple';
*/
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 Down Expand Up @@ -54,9 +54,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 @@ -65,16 +71,11 @@ 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;
}

/**
* The displayed value of the option. It is necessary to show the selected option in the
* select's trigger.
Expand All @@ -87,12 +88,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 @@ -113,8 +115,8 @@ export class MdOption {
*/
_selectViaInteraction() {
if (!this.disabled) {
this._selected = true;
this.onSelect.emit(new MdOptionSelectEvent(this, true));
this._selected = this.multiple ? !this._selected : true;
this._emitSelectionChangeEvent(true);
}
}

Expand All @@ -123,10 +125,16 @@ export class MdOption {
return this.disabled ? '-1' : '0';
}

/** Fetches the host DOM element. */
_getHostElement(): HTMLElement {
return this._element.nativeElement;
}

/** Emits the selection change event event. */
private _emitSelectionChangeEvent(isUserInput?: boolean) {
this.onSelectionChange.emit(new MdOptionSelectionChange(this, isUserInput));
};

}

@NgModule({
Expand Down
10 changes: 10 additions & 0 deletions src/lib/core/selection/selection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ describe('SelectionModel', () => {
expect(model.isEmpty()).toBe(false);
});

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);

Expand Down
11 changes: 11 additions & 0 deletions src/lib/core/selection/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ export class SelectionModel<T> {
this._emitChangeEvent();
}

/**
* Toggles a value between selected and deselected.
*/
toggle(value: T): void {
if (this.isSelected(value)) {
this.deselect(value);
} else {
this.select(value);
}
}

/**
* Clears all of the selected values.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/lib/dialog/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@ describe('MdDialog', () => {

expect(document.activeElement.id)
.toBe('dialog-trigger', 'Expected that the trigger was refocused after dialog close');

document.body.removeChild(button);
}));
});

Expand Down
21 changes: 21 additions & 0 deletions src/lib/select/select-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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.
* @docs-private
*/
export class MdSelectNonArrayValueError extends MdError {
constructor() {
super('Cannot assign non-array value to select in `multiple` mode.');
}
}
4 changes: 2 additions & 2 deletions src/lib/select/select.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="md-select-trigger" cdk-overlay-origin (click)="toggle()" #origin="cdkOverlayOrigin" #trigger>
<span class="md-select-placeholder" [class.md-floating-placeholder]="this.selected"
<span class="md-select-placeholder" [class.md-floating-placeholder]="!_model.isEmpty()"
[@transformPlaceholder]="_placeholderState" [style.width.px]="_selectedValueWidth"> {{ placeholder }} </span>
<span class="md-select-value" *ngIf="selected"> {{ selected?.viewValue }} </span>
<span class="md-select-value" *ngIf="!_model.isEmpty()"> {{ selectedLabel }} </span>
<span class="md-select-arrow"></span>
</div>

Expand Down
Loading

0 comments on commit 4fa000a

Please sign in to comment.