feat(selection-list): support for ngModel (#7456)

* feat(selection-list): support for ngModel

* Adds support for NgModel to the selection-list.

Fixes #6896

* Rename to selectionChange. Add deprecated `selectionChange` event on option

* Reintroduce MatListOptionChange for deprecation
This commit is contained in:
Paul Gschwendtner
2017-12-05 22:31:00 +01:00
committed by mmalerba
parent b6f2484a94
commit 3fbb28a646
4 changed files with 414 additions and 149 deletions
+7 -2
View File
@@ -105,7 +105,9 @@
<div>
<h2>Selection list</h2>
<mat-selection-list #groceries>
<mat-selection-list #groceries [ngModel]="selectedOptions"
(ngModelChange)="onSelectedOptionsChange($event)"
(change)="changeEventCount = changeEventCount + 1">
<h3 mat-subheader>Groceries</h3>
<mat-list-option value="bananas">Bananas</mat-list-option>
@@ -114,7 +116,10 @@
<mat-list-option value="strawberries">Strawberries</mat-list-option>
</mat-selection-list>
<p>Selected: {{groceries.selectedOptions.selected.length}}</p>
<p>Selected: {{selectedOptions | json}}</p>
<p>Change Event Count {{changeEventCount}}</p>
<p>Model Change Event Count {{modelChangeEventCount}}</p>
<p>
<button mat-raised-button (click)="groceries.selectAll()">Select all</button>
<button mat-raised-button (click)="groceries.deselectAll()">Deselect all</button>
+9
View File
@@ -59,4 +59,13 @@ export class ListDemo {
thirdLine: boolean = false;
infoClicked: boolean = false;
selectedOptions: string[] = ['apples'];
changeEventCount: number = 0;
modelChangeEventCount: number = 0;
onSelectedOptionsChange(values: string[]) {
this.selectedOptions = values;
this.modelChangeEventCount++;
}
}
+229 -106
View File
@@ -2,12 +2,18 @@ import {DOWN_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
import {Platform} from '@angular/cdk/platform';
import {createKeyboardEvent, dispatchFakeEvent} from '@angular/cdk/testing';
import {Component, DebugElement} from '@angular/core';
import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing';
import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {MatListModule, MatListOption, MatSelectionList, MatListOptionChange} from './index';
import {
MatListModule,
MatListOption,
MatListOptionChange,
MatSelectionList,
MatSelectionListChange
} from './index';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
describe('MatSelectionList', () => {
describe('MatSelectionList without forms', () => {
describe('with list option', () => {
let fixture: ComponentFixture<SelectionListWithListOptions>;
let listOptions: DebugElement[];
@@ -61,6 +67,44 @@ describe('MatSelectionList', () => {
});
});
it('should not emit a selectionChange event if an option changed programmatically', () => {
spyOn(fixture.componentInstance, 'onValueChange');
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
listOptions[2].componentInstance.toggle();
fixture.detectChanges();
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
});
it('should emit a selectionChange event if an option got clicked', () => {
spyOn(fixture.componentInstance, 'onValueChange');
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
dispatchFakeEvent(listOptions[2].nativeElement, 'click');
fixture.detectChanges();
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(1);
});
it('should emit a deprecated selectionChange event on the list option that got clicked', () => {
const optionInstance = listOptions[2].componentInstance as MatListOption;
let lastChangeEvent: MatListOptionChange | null = null;
optionInstance.selectionChange.subscribe(ev => lastChangeEvent = ev);
expect(lastChangeEvent).toBeNull();
dispatchFakeEvent(listOptions[2].nativeElement, 'click');
fixture.detectChanges();
expect(lastChangeEvent).not.toBeNull();
expect(lastChangeEvent!.source).toBe(optionInstance);
expect(lastChangeEvent!.selected).toBe(true);
});
it('should be able to dispatch one selected item', () => {
let testListItem = listOptions[2].injector.get<MatListOption>(MatListOption);
let selectList =
@@ -480,90 +524,167 @@ describe('MatSelectionList', () => {
expect(listItemContent.nativeElement.classList).toContain('mat-list-item-content-reverse');
});
});
describe('with multiple values', () => {
let fixture: ComponentFixture<SelectionListWithMultipleValues>;
let listOption: DebugElement[];
let listItemEl: DebugElement;
let selectionList: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MatListModule],
declarations: [
SelectionListWithMultipleValues
],
});
TestBed.compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(SelectionListWithMultipleValues);
listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
listItemEl = fixture.debugElement.query(By.css('.mat-list-item'));
selectionList = fixture.debugElement.query(By.directive(MatSelectionList));
fixture.detectChanges();
}));
it('should have a value for each item', () => {
expect(listOption[0].componentInstance.value).toBe(1);
expect(listOption[1].componentInstance.value).toBe('a');
expect(listOption[2].componentInstance.value).toBe(true);
});
});
describe('with option selected events', () => {
let fixture: ComponentFixture<SelectionListWithOptionEvents>;
let testComponent: SelectionListWithOptionEvents;
let listOption: DebugElement[];
let selectionList: DebugElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MatListModule],
declarations: [
SelectionListWithOptionEvents
],
});
TestBed.compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(SelectionListWithOptionEvents);
testComponent = fixture.debugElement.componentInstance;
listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
selectionList = fixture.debugElement.query(By.directive(MatSelectionList));
fixture.detectChanges();
}));
it('should trigger the selected and deselected events when clicked in succession.', () => {
let selected: boolean = false;
spyOn(testComponent, 'onOptionSelectionChange')
.and.callFake((event: MatListOptionChange) => {
selected = event.selected;
});
listOption[0].nativeElement.click();
expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(1);
expect(selected).toBe(true);
listOption[0].nativeElement.click();
expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(2);
expect(selected).toBe(false);
});
});
});
describe('MatSelectionList with forms', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MatListModule, FormsModule, ReactiveFormsModule],
declarations: [
SelectionListWithModel,
SelectionListWithFormControl
]
});
TestBed.compileComponents();
}));
describe('and ngModel', () => {
let fixture: ComponentFixture<SelectionListWithModel>;
let selectionListDebug: DebugElement;
let selectionList: MatSelectionList;
let listOptions: MatListOption[];
let ngModel: NgModel;
beforeEach(() => {
fixture = TestBed.createComponent(SelectionListWithModel);
fixture.detectChanges();
selectionListDebug = fixture.debugElement.query(By.directive(MatSelectionList));
selectionList = selectionListDebug.componentInstance;
ngModel = selectionListDebug.injector.get<NgModel>(NgModel);
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
.map(optionDebugEl => optionDebugEl.componentInstance);
});
it('should update the model if an option got selected programmatically', fakeAsync(() => {
expect(fixture.componentInstance.selectedOptions.length)
.toBe(0, 'Expected no options to be selected by default');
listOptions[0].toggle();
fixture.detectChanges();
tick();
expect(fixture.componentInstance.selectedOptions.length)
.toBe(1, 'Expected first list option to be selected');
}));
it('should update the model if an option got clicked', fakeAsync(() => {
expect(fixture.componentInstance.selectedOptions.length)
.toBe(0, 'Expected no options to be selected by default');
dispatchFakeEvent(listOptions[0]._getHostElement(), 'click');
fixture.detectChanges();
tick();
expect(fixture.componentInstance.selectedOptions.length)
.toBe(1, 'Expected first list option to be selected');
}));
it('should update the options if a model value is set', fakeAsync(() => {
expect(fixture.componentInstance.selectedOptions.length)
.toBe(0, 'Expected no options to be selected by default');
fixture.componentInstance.selectedOptions = ['opt3'];
fixture.detectChanges();
tick();
expect(fixture.componentInstance.selectedOptions.length)
.toBe(1, 'Expected first list option to be selected');
}));
it('should set the selection-list to touched on blur', fakeAsync(() => {
expect(ngModel.touched)
.toBe(false, 'Expected the selection-list to be untouched by default.');
dispatchFakeEvent(selectionListDebug.nativeElement, 'blur');
fixture.detectChanges();
tick();
expect(ngModel.touched).toBe(true, 'Expected the selection-list to be touched after blur');
}));
it('should be pristine by default', fakeAsync(() => {
fixture = TestBed.createComponent(SelectionListWithModel);
fixture.componentInstance.selectedOptions = ['opt2'];
fixture.detectChanges();
ngModel =
fixture.debugElement.query(By.directive(MatSelectionList)).injector.get<NgModel>(NgModel);
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
.map(optionDebugEl => optionDebugEl.componentInstance);
// Flush the initial tick to ensure that every action from the ControlValueAccessor
// happened before the actual test starts.
tick();
expect(ngModel.pristine)
.toBe(true, 'Expected the selection-list to be pristine by default.');
listOptions[1].toggle();
fixture.detectChanges();
tick();
expect(ngModel.pristine)
.toBe(false, 'Expected the selection-list to be dirty after state change.');
}));
});
describe('and formControl', () => {
let fixture: ComponentFixture<SelectionListWithFormControl>;
let selectionListDebug: DebugElement;
let selectionList: MatSelectionList;
let listOptions: MatListOption[];
beforeEach(() => {
fixture = TestBed.createComponent(SelectionListWithFormControl);
fixture.detectChanges();
selectionListDebug = fixture.debugElement.query(By.directive(MatSelectionList));
selectionList = selectionListDebug.componentInstance;
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
.map(optionDebugEl => optionDebugEl.componentInstance);
});
it('should be able to disable options from the control', () => {
expect(listOptions.every(option => !option.disabled))
.toBe(true, 'Expected every list option to be enabled.');
fixture.componentInstance.formControl.disable();
fixture.detectChanges();
expect(listOptions.every(option => option.disabled))
.toBe(true, 'Expected every list option to be disabled.');
});
it('should be able to set the value through the form control', () => {
expect(listOptions.every(option => !option.selected))
.toBe(true, 'Expected every list option to be unselected.');
fixture.componentInstance.formControl.setValue(['opt2', 'opt3']);
fixture.detectChanges();
expect(listOptions[1].selected).toBe(true, 'Expected second option to be selected.');
expect(listOptions[2].selected).toBe(true, 'Expected third option to be selected.');
fixture.componentInstance.formControl.setValue(null);
fixture.detectChanges();
expect(listOptions.every(option => !option.selected))
.toBe(true, 'Expected every list option to be unselected.');
});
});
});
@Component({template: `
<mat-selection-list id="selection-list-1">
<mat-selection-list id="selection-list-1" (selectionChange)="onValueChange($event)">
<mat-list-option checkboxPosition="before" disabled="true" value="inbox">
Inbox (disabled selection-option)
</mat-list-option>
@@ -580,6 +701,8 @@ describe('MatSelectionList', () => {
</mat-selection-list>`})
class SelectionListWithListOptions {
showLastOption: boolean = true;
onValueChange(_change: MatSelectionListChange) {}
}
@Component({template: `
@@ -656,27 +779,27 @@ class SelectionListWithTabindexBinding {
disabled: boolean;
}
@Component({template: `
<mat-selection-list id="selection-list-5">
<mat-list-option [value]="1" checkboxPosition="after">
1
</mat-list-option>
<mat-list-option value="a" checkboxPosition="after">
a
</mat-list-option>
<mat-list-option [value]="true" checkboxPosition="after">
true
</mat-list-option>
</mat-selection-list>`})
class SelectionListWithMultipleValues {
@Component({
template: `
<mat-selection-list [(ngModel)]="selectedOptions">
<mat-list-option value="opt1">Option 1</mat-list-option>
<mat-list-option value="opt2">Option 2</mat-list-option>
<mat-list-option value="opt3">Option 3</mat-list-option>
</mat-selection-list>`
})
class SelectionListWithModel {
selectedOptions: string[] = [];
}
@Component({template: `
<mat-selection-list id="selection-list-6">
<mat-list-option (selectionChange)="onOptionSelectionChange($event)">
Inbox
</mat-list-option>
</mat-selection-list>`})
class SelectionListWithOptionEvents {
onOptionSelectionChange: (event?: MatListOptionChange) => void = () => {};
@Component({
template: `
<mat-selection-list [formControl]="formControl">
<mat-list-option value="opt1">Option 1</mat-list-option>
<mat-list-option value="opt2">Option 2</mat-list-option>
<mat-list-option value="opt3">Option 3</mat-list-option>
</mat-selection-list>
`
})
class SelectionListWithFormControl {
formControl = new FormControl();
}
+169 -41
View File
@@ -39,6 +39,7 @@ import {
mixinDisableRipple,
mixinTabIndex,
} from '@angular/material/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
/** @docs-private */
@@ -50,12 +51,32 @@ export const _MatSelectionListMixinBase =
export class MatListOptionBase {}
export const _MatListOptionMixinBase = mixinDisableRipple(MatListOptionBase);
/** Change event object emitted by MatListOption */
/** @docs-private */
export const MAT_SELECTION_LIST_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MatSelectionList),
multi: true
};
/**
* Change event object emitted by MatListOption whenever the selected state changes.
* @deprecated Use the `MatSelectionListChange` event on the selection list instead.
*/
export class MatListOptionChange {
/** The source MatListOption of the event. */
source: MatListOption;
/** The new `selected` value of the option. */
selected: boolean;
constructor(
/** Reference to the list option that changed. */
public source: MatListOption,
/** The new selected state of the option. */
public selected: boolean) {}
}
/** Change event that is being fired whenever the selected state of an option changes. */
export class MatSelectionListChange {
constructor(
/** Reference to the selection list that emitted the event. */
public source: MatSelectionList,
/** Reference to the option that has been changed. */
public option: MatListOption) {}
}
/**
@@ -72,7 +93,7 @@ export class MatListOptionChange {
'role': 'option',
'class': 'mat-list-item mat-list-option',
'(focus)': '_handleFocus()',
'(blur)': '_hasFocus = false',
'(blur)': '_handleBlur()',
'(click)': '_handleClick()',
'tabindex': '-1',
'[class.mat-list-item-disabled]': 'disabled',
@@ -86,8 +107,10 @@ export class MatListOptionChange {
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatListOption extends _MatListOptionMixinBase
implements AfterContentInit, OnInit, OnDestroy, FocusableOption, CanDisableRipple {
implements AfterContentInit, OnDestroy, OnInit, FocusableOption, CanDisableRipple {
private _lineSetter: MatLineSetter;
private _selected: boolean = false;
private _disabled: boolean = false;
/** Whether the option has focus. */
@@ -98,31 +121,39 @@ export class MatListOption extends _MatListOptionMixinBase
/** Whether the label should appear before or after the checkbox. Defaults to 'after' */
@Input() checkboxPosition: 'before' | 'after' = 'after';
/** Whether the option is disabled. */
@Input()
get disabled(): boolean {
return (this.selectionList && this.selectionList.disabled) || this._disabled;
}
set disabled(value: boolean) { this._disabled = coerceBooleanProperty(value); }
/** Value of the option */
@Input() value: any;
/** Whether the option is disabled. */
@Input()
get disabled() { return (this.selectionList && this.selectionList.disabled) || this._disabled; }
set disabled(value: any) {
const newValue = coerceBooleanProperty(value);
if (newValue !== this._disabled) {
this._disabled = newValue;
this._changeDetector.markForCheck();
}
}
/** Whether the option is selected. */
@Input()
get selected(): boolean { return this.selectionList.selectedOptions.isSelected(this); }
set selected(value: boolean) {
const isSelected = coerceBooleanProperty(value);
if (isSelected !== this.selected) {
this.selectionList.selectedOptions.toggle(this);
this._changeDetector.markForCheck();
this.selectionChange.emit(this._createChangeEvent());
if (isSelected !== this._selected) {
this._setSelected(isSelected);
this.selectionList._reportValueChange();
}
}
/** Emitted when the option is selected or deselected. */
@Output() selectionChange = new EventEmitter<MatListOptionChange>();
/**
* Emits a change event whenever the selected state of an option changes.
* @deprecated Use the `selectionChange` event on the `<mat-selection-list>` instead.
*/
@Output() selectionChange: EventEmitter<MatListOptionChange> =
new EventEmitter<MatListOptionChange>();
constructor(private _element: ElementRef,
private _changeDetector: ChangeDetectorRef,
@@ -133,7 +164,12 @@ export class MatListOption extends _MatListOptionMixinBase
ngOnInit() {
if (this.selected) {
this.selectionList.selectedOptions.select(this);
// List options that are selected at initialization can't be reported properly to the form
// control. This is because it takes some time until the selection-list knows about all
// available options. Also it can happen that the ControlValueAccessor has an initial value
// that should be used instead. Deferring the value change report to the next tick ensures
// that the form control value is not being overwritten.
Promise.resolve(() => this.selected && this.selectionList._reportValueChange());
}
}
@@ -163,6 +199,12 @@ export class MatListOption extends _MatListOptionMixinBase
_handleClick() {
if (!this.disabled) {
this.toggle();
// Emit a change event if the selected state of the option changed through user interaction.
this.selectionList._emitChangeEvent(this);
// TODO: the `selectionChange` event on the option is deprecated. Remove that in the future.
this._emitDeprecatedChangeEvent();
}
}
@@ -171,20 +213,38 @@ export class MatListOption extends _MatListOptionMixinBase
this.selectionList._setFocusedOption(this);
}
/** Creates a selection event object from the specified option. */
private _createChangeEvent(option: MatListOption = this): MatListOptionChange {
const event = new MatListOptionChange();
event.source = option;
event.selected = option.selected;
return event;
_handleBlur() {
this._hasFocus = false;
this.selectionList.onTouched();
}
/** Retrieves the DOM element of the component host. */
_getHostElement(): HTMLElement {
return this._element.nativeElement;
}
/** Sets the selected state of the option. */
_setSelected(selected: boolean) {
if (selected === this._selected) {
return;
}
this._selected = selected;
if (selected) {
this.selectionList.selectedOptions.select(this);
} else {
this.selectionList.selectedOptions.deselect(this);
}
this._changeDetector.markForCheck();
}
/** Emits a selectionChange event for this option. */
_emitDeprecatedChangeEvent() {
// TODO: the `selectionChange` event on the option is deprecated. Remove that in the future.
this.selectionChange.emit(new MatListOptionChange(this, this.selected));
}
}
@@ -201,16 +261,18 @@ export class MatListOption extends _MatListOptionMixinBase
'[tabIndex]': 'tabIndex',
'class': 'mat-selection-list',
'(focus)': 'focus()',
'(blur)': 'onTouched()',
'(keydown)': '_keydown($event)',
'[attr.aria-disabled]': 'disabled.toString()'},
template: '<ng-content></ng-content>',
styleUrls: ['list.css'],
encapsulation: ViewEncapsulation.None,
providers: [MAT_SELECTION_LIST_VALUE_ACCESSOR],
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MatSelectionList extends _MatSelectionListMixinBase implements FocusableOption,
CanDisable, CanDisableRipple, HasTabIndex, AfterContentInit {
CanDisable, CanDisableRipple, HasTabIndex, AfterContentInit, ControlValueAccessor {
/** The FocusKeyManager which handles focus. */
_keyManager: FocusKeyManager<MatListOption>;
@@ -218,9 +280,19 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
/** The option components contained within this selection-list. */
@ContentChildren(MatListOption) options: QueryList<MatListOption>;
/** Emits a change event whenever the selected state of an option changes. */
@Output() selectionChange: EventEmitter<MatSelectionListChange> =
new EventEmitter<MatSelectionListChange>();
/** The currently selected options. */
selectedOptions: SelectionModel<MatListOption> = new SelectionModel<MatListOption>(true);
/** View to model callback that should be called whenever the selected options change. */
private _onChange: (value: any) => void = (_: any) => {};
/** View to model callback that should be called if the list or its options lost focus. */
onTouched: () => void = () => {};
constructor(private _element: ElementRef, @Attribute('tabindex') tabIndex: string) {
super();
@@ -238,20 +310,14 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
/** Selects all of the options. */
selectAll() {
this.options.forEach(option => {
if (!option.selected) {
option.toggle();
}
});
this.options.forEach(option => option._setSelected(true));
this._reportValueChange();
}
/** Deselects all of the options. */
deselectAll() {
this.options.forEach(option => {
if (option.selected) {
option.toggle();
}
});
this.options.forEach(option => option._setSelected(false));
this._reportValueChange();
}
/** Sets the focused option of the selection-list. */
@@ -286,6 +352,62 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
}
}
/** Reports a value change to the ControlValueAccessor */
_reportValueChange() {
if (this.options) {
this._onChange(this._getSelectedOptionValues());
}
}
/** Emits a change event if the selected state of an option changed. */
_emitChangeEvent(option: MatListOption) {
this.selectionChange.emit(new MatSelectionListChange(this, option));
}
/** Implemented as part of ControlValueAccessor. */
writeValue(values: string[]): void {
if (this.options) {
this._setOptionsFromValues(values || []);
}
}
/** Implemented as a part of ControlValueAccessor. */
setDisabledState(isDisabled: boolean): void {
if (this.options) {
this.options.forEach(option => option.disabled = isDisabled);
}
}
/** Implemented as part of ControlValueAccessor. */
registerOnChange(fn: (value: any) => void): void {
this._onChange = fn;
}
/** Implemented as part of ControlValueAccessor. */
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/** Returns the option with the specified value. */
private _getOptionByValue(value: string): MatListOption | undefined {
return this.options.find(option => option.value === value);
}
/** Sets the selected options based on the specified values. */
private _setOptionsFromValues(values: string[]) {
this.options.forEach(option => option._setSelected(false));
values
.map(value => this._getOptionByValue(value))
.filter(Boolean)
.forEach(option => option!._setSelected(true));
}
/** Returns the values of the selected options. */
private _getSelectedOptionValues(): string[] {
return this.options.filter(option => option.selected).map(option => option.value);
}
/** Toggles the selected state of the currently focused option. */
private _toggleSelectOnFocusedOption(): void {
let focusedIndex = this._keyManager.activeItemIndex;
@@ -295,13 +417,19 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
if (focusedOption) {
focusedOption.toggle();
// Emit a change event because the focused option changed its state through user
// interaction.
this._emitChangeEvent(focusedOption);
// TODO: the `selectionChange` event on the option is deprecated. Remove that in the future.
focusedOption._emitDeprecatedChangeEvent();
}
}
}
/**
* Utility to ensure all indexes are valid.
*
* @param index The index to be checked.
* @returns True if the index is valid for our list of options.
*/