diff --git a/src/demo-app/list/list-demo.html b/src/demo-app/list/list-demo.html index 491a3dbf90b3..d3f3a87f100b 100644 --- a/src/demo-app/list/list-demo.html +++ b/src/demo-app/list/list-demo.html @@ -105,7 +105,9 @@

Nav lists

Selection list

- +

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. */