+
Groceries
Bananas
@@ -114,7 +116,10 @@ Groceries
Strawberries
- Selected: {{groceries.selectedOptions.selected.length}}
+ Selected: {{selectedOptions | json}}
+ Change Event Count {{changeEventCount}}
+ Model Change Event Count {{modelChangeEventCount}}
+
diff --git a/src/demo-app/list/list-demo.ts b/src/demo-app/list/list-demo.ts
index a9b6420e7099..6e264021d7be 100644
--- a/src/demo-app/list/list-demo.ts
+++ b/src/demo-app/list/list-demo.ts
@@ -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++;
+ }
}
diff --git a/src/lib/list/selection-list.spec.ts b/src/lib/list/selection-list.spec.ts
index 71eef8c5c409..4b6183141b98 100644
--- a/src/lib/list/selection-list.spec.ts
+++ b/src/lib/list/selection-list.spec.ts
@@ -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';
-
-
-describe('MatSelectionList', () => {
+import {
+ MatListModule,
+ MatListOption,
+ MatListOptionChange,
+ MatSelectionList,
+ MatSelectionListChange
+} from './index';
+import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
+
+describe('MatSelectionList without forms', () => {
describe('with list option', () => {
let fixture: ComponentFixture;
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);
let selectList =
@@ -480,90 +524,167 @@ describe('MatSelectionList', () => {
expect(listItemContent.nativeElement.classList).toContain('mat-list-item-content-reverse');
});
});
+});
+describe('MatSelectionList with forms', () => {
- describe('with multiple values', () => {
- let fixture: ComponentFixture;
- let listOption: DebugElement[];
- let listItemEl: DebugElement;
- let selectionList: DebugElement;
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [MatListModule, FormsModule, ReactiveFormsModule],
+ declarations: [
+ SelectionListWithModel,
+ SelectionListWithFormControl
+ ]
+ });
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [MatListModule],
- declarations: [
- SelectionListWithMultipleValues
- ],
- });
+ TestBed.compileComponents();
+ }));
- TestBed.compileComponents();
+ describe('and ngModel', () => {
+ let fixture: ComponentFixture;
+ 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);
+ 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');
}));
- 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));
+ 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 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);
- });
+ 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();
- describe('with option selected events', () => {
- let fixture: ComponentFixture;
- let testComponent: SelectionListWithOptionEvents;
- let listOption: DebugElement[];
- let selectionList: DebugElement;
+ tick();
- beforeEach(async(() => {
- TestBed.configureTestingModule({
- imports: [MatListModule],
- declarations: [
- SelectionListWithOptionEvents
- ],
- });
+ expect(fixture.componentInstance.selectedOptions.length)
+ .toBe(1, 'Expected first list option to be selected');
+ }));
- TestBed.compileComponents();
+ 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');
}));
- beforeEach(async(() => {
- fixture = TestBed.createComponent(SelectionListWithOptionEvents);
- testComponent = fixture.debugElement.componentInstance;
- listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
- selectionList = fixture.debugElement.query(By.directive(MatSelectionList));
+ 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);
+ 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;
+ let selectionListDebug: DebugElement;
+ let selectionList: MatSelectionList;
+ let listOptions: MatListOption[];
- it('should trigger the selected and deselected events when clicked in succession.', () => {
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SelectionListWithFormControl);
+ fixture.detectChanges();
- let selected: boolean = false;
+ selectionListDebug = fixture.debugElement.query(By.directive(MatSelectionList));
+ selectionList = selectionListDebug.componentInstance;
+ listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
+ .map(optionDebugEl => optionDebugEl.componentInstance);
+ });
- spyOn(testComponent, 'onOptionSelectionChange')
- .and.callFake((event: MatListOptionChange) => {
- selected = event.selected;
- });
+ 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.');
- listOption[0].nativeElement.click();
- expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(1);
- expect(selected).toBe(true);
+ fixture.componentInstance.formControl.disable();
+ fixture.detectChanges();
- listOption[0].nativeElement.click();
- expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(2);
- expect(selected).toBe(false);
+ 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: `
-
+
Inbox (disabled selection-option)
@@ -580,6 +701,8 @@ describe('MatSelectionList', () => {
`})
class SelectionListWithListOptions {
showLastOption: boolean = true;
+
+ onValueChange(_change: MatSelectionListChange) {}
}
@Component({template: `
@@ -656,27 +779,27 @@ class SelectionListWithTabindexBinding {
disabled: boolean;
}
-@Component({template: `
-
-
- 1
-
-
- a
-
-
- true
-
-`})
-class SelectionListWithMultipleValues {
+@Component({
+ template: `
+
+ Option 1
+ Option 2
+ Option 3
+ `
+})
+class SelectionListWithModel {
+ selectedOptions: string[] = [];
}
-@Component({template: `
-
-
- Inbox
-
-`})
-class SelectionListWithOptionEvents {
- onOptionSelectionChange: (event?: MatListOptionChange) => void = () => {};
+@Component({
+ template: `
+
+ Option 1
+ Option 2
+ Option 3
+
+ `
+})
+class SelectionListWithFormControl {
+ formControl = new FormControl();
}
diff --git a/src/lib/list/selection-list.ts b/src/lib/list/selection-list.ts
index def298abe634..30315ded622a 100644
--- a/src/lib/list/selection-list.ts
+++ b/src/lib/list/selection-list.ts
@@ -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,15 +121,20 @@ export class MatListOption extends _MatListOptionMixinBase
/** Whether the label should appear before or after the checkbox. Defaults to 'after' */
@Input() checkboxPosition: 'before' | 'after' = 'after';
+ /** Value of the option */
+ @Input() value: any;
+
/** 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); }
+ get disabled() { return (this.selectionList && this.selectionList.disabled) || this._disabled; }
+ set disabled(value: any) {
+ const newValue = coerceBooleanProperty(value);
- /** Value of the option */
- @Input() value: any;
+ if (newValue !== this._disabled) {
+ this._disabled = newValue;
+ this._changeDetector.markForCheck();
+ }
+ }
/** Whether the option is selected. */
@Input()
@@ -114,15 +142,18 @@ export class MatListOption extends _MatListOptionMixinBase
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();
+ /**
+ * Emits a change event whenever the selected state of an option changes.
+ * @deprecated Use the `selectionChange` event on the `` instead.
+ */
+ @Output() selectionChange: EventEmitter =
+ new EventEmitter();
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: '',
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;
@@ -218,9 +280,19 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
/** The option components contained within this selection-list. */
@ContentChildren(MatListOption) options: QueryList;
+ /** Emits a change event whenever the selected state of an option changes. */
+ @Output() selectionChange: EventEmitter =
+ new EventEmitter();
+
/** The currently selected options. */
selectedOptions: SelectionModel = new SelectionModel(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.
*/