Skip to content

Commit 3fbb28a

Browse files
devversionmmalerba
authored andcommitted
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
1 parent b6f2484 commit 3fbb28a

File tree

4 files changed

+389
-124
lines changed

4 files changed

+389
-124
lines changed

src/demo-app/list/list-demo.html

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,9 @@ <h2>Nav lists</h2>
105105
<div>
106106
<h2>Selection list</h2>
107107

108-
<mat-selection-list #groceries>
108+
<mat-selection-list #groceries [ngModel]="selectedOptions"
109+
(ngModelChange)="onSelectedOptionsChange($event)"
110+
(change)="changeEventCount = changeEventCount + 1">
109111
<h3 mat-subheader>Groceries</h3>
110112

111113
<mat-list-option value="bananas">Bananas</mat-list-option>
@@ -114,7 +116,10 @@ <h3 mat-subheader>Groceries</h3>
114116
<mat-list-option value="strawberries">Strawberries</mat-list-option>
115117
</mat-selection-list>
116118

117-
<p>Selected: {{groceries.selectedOptions.selected.length}}</p>
119+
<p>Selected: {{selectedOptions | json}}</p>
120+
<p>Change Event Count {{changeEventCount}}</p>
121+
<p>Model Change Event Count {{modelChangeEventCount}}</p>
122+
118123
<p>
119124
<button mat-raised-button (click)="groceries.selectAll()">Select all</button>
120125
<button mat-raised-button (click)="groceries.deselectAll()">Deselect all</button>

src/demo-app/list/list-demo.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,13 @@ export class ListDemo {
5959

6060
thirdLine: boolean = false;
6161
infoClicked: boolean = false;
62+
63+
selectedOptions: string[] = ['apples'];
64+
changeEventCount: number = 0;
65+
modelChangeEventCount: number = 0;
66+
67+
onSelectedOptionsChange(values: string[]) {
68+
this.selectedOptions = values;
69+
this.modelChangeEventCount++;
70+
}
6271
}

src/lib/list/selection-list.spec.ts

Lines changed: 205 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ import {DOWN_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
22
import {Platform} from '@angular/cdk/platform';
33
import {createKeyboardEvent, dispatchFakeEvent} from '@angular/cdk/testing';
44
import {Component, DebugElement} from '@angular/core';
5-
import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing';
5+
import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
66
import {By} from '@angular/platform-browser';
7-
import {MatListModule, MatListOption, MatSelectionList, MatListOptionChange} from './index';
8-
9-
10-
describe('MatSelectionList', () => {
7+
import {
8+
MatListModule,
9+
MatListOption,
10+
MatListOptionChange,
11+
MatSelectionList,
12+
MatSelectionListChange
13+
} from './index';
14+
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
15+
16+
describe('MatSelectionList without forms', () => {
1117
describe('with list option', () => {
1218
let fixture: ComponentFixture<SelectionListWithListOptions>;
1319
let listOptions: DebugElement[];
@@ -61,6 +67,44 @@ describe('MatSelectionList', () => {
6167
});
6268
});
6369

70+
it('should not emit a selectionChange event if an option changed programmatically', () => {
71+
spyOn(fixture.componentInstance, 'onValueChange');
72+
73+
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
74+
75+
listOptions[2].componentInstance.toggle();
76+
fixture.detectChanges();
77+
78+
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
79+
});
80+
81+
it('should emit a selectionChange event if an option got clicked', () => {
82+
spyOn(fixture.componentInstance, 'onValueChange');
83+
84+
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(0);
85+
86+
dispatchFakeEvent(listOptions[2].nativeElement, 'click');
87+
fixture.detectChanges();
88+
89+
expect(fixture.componentInstance.onValueChange).toHaveBeenCalledTimes(1);
90+
});
91+
92+
it('should emit a deprecated selectionChange event on the list option that got clicked', () => {
93+
const optionInstance = listOptions[2].componentInstance as MatListOption;
94+
let lastChangeEvent: MatListOptionChange | null = null;
95+
96+
optionInstance.selectionChange.subscribe(ev => lastChangeEvent = ev);
97+
98+
expect(lastChangeEvent).toBeNull();
99+
100+
dispatchFakeEvent(listOptions[2].nativeElement, 'click');
101+
fixture.detectChanges();
102+
103+
expect(lastChangeEvent).not.toBeNull();
104+
expect(lastChangeEvent!.source).toBe(optionInstance);
105+
expect(lastChangeEvent!.selected).toBe(true);
106+
});
107+
64108
it('should be able to dispatch one selected item', () => {
65109
let testListItem = listOptions[2].injector.get<MatListOption>(MatListOption);
66110
let selectList =
@@ -480,90 +524,167 @@ describe('MatSelectionList', () => {
480524
expect(listItemContent.nativeElement.classList).toContain('mat-list-item-content-reverse');
481525
});
482526
});
527+
});
483528

529+
describe('MatSelectionList with forms', () => {
484530

485-
describe('with multiple values', () => {
486-
let fixture: ComponentFixture<SelectionListWithMultipleValues>;
487-
let listOption: DebugElement[];
488-
let listItemEl: DebugElement;
489-
let selectionList: DebugElement;
531+
beforeEach(async(() => {
532+
TestBed.configureTestingModule({
533+
imports: [MatListModule, FormsModule, ReactiveFormsModule],
534+
declarations: [
535+
SelectionListWithModel,
536+
SelectionListWithFormControl
537+
]
538+
});
490539

491-
beforeEach(async(() => {
492-
TestBed.configureTestingModule({
493-
imports: [MatListModule],
494-
declarations: [
495-
SelectionListWithMultipleValues
496-
],
497-
});
540+
TestBed.compileComponents();
541+
}));
498542

499-
TestBed.compileComponents();
543+
describe('and ngModel', () => {
544+
let fixture: ComponentFixture<SelectionListWithModel>;
545+
let selectionListDebug: DebugElement;
546+
let selectionList: MatSelectionList;
547+
let listOptions: MatListOption[];
548+
let ngModel: NgModel;
549+
550+
beforeEach(() => {
551+
fixture = TestBed.createComponent(SelectionListWithModel);
552+
fixture.detectChanges();
553+
554+
selectionListDebug = fixture.debugElement.query(By.directive(MatSelectionList));
555+
selectionList = selectionListDebug.componentInstance;
556+
ngModel = selectionListDebug.injector.get<NgModel>(NgModel);
557+
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
558+
.map(optionDebugEl => optionDebugEl.componentInstance);
559+
});
560+
561+
it('should update the model if an option got selected programmatically', fakeAsync(() => {
562+
expect(fixture.componentInstance.selectedOptions.length)
563+
.toBe(0, 'Expected no options to be selected by default');
564+
565+
listOptions[0].toggle();
566+
fixture.detectChanges();
567+
568+
tick();
569+
570+
expect(fixture.componentInstance.selectedOptions.length)
571+
.toBe(1, 'Expected first list option to be selected');
500572
}));
501573

502-
beforeEach(async(() => {
503-
fixture = TestBed.createComponent(SelectionListWithMultipleValues);
504-
listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
505-
listItemEl = fixture.debugElement.query(By.css('.mat-list-item'));
506-
selectionList = fixture.debugElement.query(By.directive(MatSelectionList));
574+
it('should update the model if an option got clicked', fakeAsync(() => {
575+
expect(fixture.componentInstance.selectedOptions.length)
576+
.toBe(0, 'Expected no options to be selected by default');
577+
578+
dispatchFakeEvent(listOptions[0]._getHostElement(), 'click');
507579
fixture.detectChanges();
580+
581+
tick();
582+
583+
expect(fixture.componentInstance.selectedOptions.length)
584+
.toBe(1, 'Expected first list option to be selected');
508585
}));
509586

510-
it('should have a value for each item', () => {
511-
expect(listOption[0].componentInstance.value).toBe(1);
512-
expect(listOption[1].componentInstance.value).toBe('a');
513-
expect(listOption[2].componentInstance.value).toBe(true);
514-
});
587+
it('should update the options if a model value is set', fakeAsync(() => {
588+
expect(fixture.componentInstance.selectedOptions.length)
589+
.toBe(0, 'Expected no options to be selected by default');
515590

516-
});
591+
fixture.componentInstance.selectedOptions = ['opt3'];
592+
fixture.detectChanges();
517593

518-
describe('with option selected events', () => {
519-
let fixture: ComponentFixture<SelectionListWithOptionEvents>;
520-
let testComponent: SelectionListWithOptionEvents;
521-
let listOption: DebugElement[];
522-
let selectionList: DebugElement;
594+
tick();
523595

524-
beforeEach(async(() => {
525-
TestBed.configureTestingModule({
526-
imports: [MatListModule],
527-
declarations: [
528-
SelectionListWithOptionEvents
529-
],
530-
});
596+
expect(fixture.componentInstance.selectedOptions.length)
597+
.toBe(1, 'Expected first list option to be selected');
598+
}));
531599

532-
TestBed.compileComponents();
600+
it('should set the selection-list to touched on blur', fakeAsync(() => {
601+
expect(ngModel.touched)
602+
.toBe(false, 'Expected the selection-list to be untouched by default.');
603+
604+
dispatchFakeEvent(selectionListDebug.nativeElement, 'blur');
605+
fixture.detectChanges();
606+
607+
tick();
608+
609+
expect(ngModel.touched).toBe(true, 'Expected the selection-list to be touched after blur');
533610
}));
534611

535-
beforeEach(async(() => {
536-
fixture = TestBed.createComponent(SelectionListWithOptionEvents);
537-
testComponent = fixture.debugElement.componentInstance;
538-
listOption = fixture.debugElement.queryAll(By.directive(MatListOption));
539-
selectionList = fixture.debugElement.query(By.directive(MatSelectionList));
612+
it('should be pristine by default', fakeAsync(() => {
613+
fixture = TestBed.createComponent(SelectionListWithModel);
614+
fixture.componentInstance.selectedOptions = ['opt2'];
615+
fixture.detectChanges();
616+
617+
ngModel =
618+
fixture.debugElement.query(By.directive(MatSelectionList)).injector.get<NgModel>(NgModel);
619+
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
620+
.map(optionDebugEl => optionDebugEl.componentInstance);
621+
622+
// Flush the initial tick to ensure that every action from the ControlValueAccessor
623+
// happened before the actual test starts.
624+
tick();
625+
626+
expect(ngModel.pristine)
627+
.toBe(true, 'Expected the selection-list to be pristine by default.');
628+
629+
listOptions[1].toggle();
540630
fixture.detectChanges();
631+
632+
tick();
633+
634+
expect(ngModel.pristine)
635+
.toBe(false, 'Expected the selection-list to be dirty after state change.');
541636
}));
637+
});
638+
639+
describe('and formControl', () => {
640+
let fixture: ComponentFixture<SelectionListWithFormControl>;
641+
let selectionListDebug: DebugElement;
642+
let selectionList: MatSelectionList;
643+
let listOptions: MatListOption[];
542644

543-
it('should trigger the selected and deselected events when clicked in succession.', () => {
645+
beforeEach(() => {
646+
fixture = TestBed.createComponent(SelectionListWithFormControl);
647+
fixture.detectChanges();
544648

545-
let selected: boolean = false;
649+
selectionListDebug = fixture.debugElement.query(By.directive(MatSelectionList));
650+
selectionList = selectionListDebug.componentInstance;
651+
listOptions = fixture.debugElement.queryAll(By.directive(MatListOption))
652+
.map(optionDebugEl => optionDebugEl.componentInstance);
653+
});
546654

547-
spyOn(testComponent, 'onOptionSelectionChange')
548-
.and.callFake((event: MatListOptionChange) => {
549-
selected = event.selected;
550-
});
655+
it('should be able to disable options from the control', () => {
656+
expect(listOptions.every(option => !option.disabled))
657+
.toBe(true, 'Expected every list option to be enabled.');
551658

552-
listOption[0].nativeElement.click();
553-
expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(1);
554-
expect(selected).toBe(true);
659+
fixture.componentInstance.formControl.disable();
660+
fixture.detectChanges();
555661

556-
listOption[0].nativeElement.click();
557-
expect(testComponent.onOptionSelectionChange).toHaveBeenCalledTimes(2);
558-
expect(selected).toBe(false);
662+
expect(listOptions.every(option => option.disabled))
663+
.toBe(true, 'Expected every list option to be disabled.');
559664
});
560665

561-
});
666+
it('should be able to set the value through the form control', () => {
667+
expect(listOptions.every(option => !option.selected))
668+
.toBe(true, 'Expected every list option to be unselected.');
669+
670+
fixture.componentInstance.formControl.setValue(['opt2', 'opt3']);
671+
fixture.detectChanges();
672+
673+
expect(listOptions[1].selected).toBe(true, 'Expected second option to be selected.');
674+
expect(listOptions[2].selected).toBe(true, 'Expected third option to be selected.');
562675

676+
fixture.componentInstance.formControl.setValue(null);
677+
fixture.detectChanges();
678+
679+
expect(listOptions.every(option => !option.selected))
680+
.toBe(true, 'Expected every list option to be unselected.');
681+
});
682+
});
563683
});
564684

685+
565686
@Component({template: `
566-
<mat-selection-list id="selection-list-1">
687+
<mat-selection-list id="selection-list-1" (selectionChange)="onValueChange($event)">
567688
<mat-list-option checkboxPosition="before" disabled="true" value="inbox">
568689
Inbox (disabled selection-option)
569690
</mat-list-option>
@@ -580,6 +701,8 @@ describe('MatSelectionList', () => {
580701
</mat-selection-list>`})
581702
class SelectionListWithListOptions {
582703
showLastOption: boolean = true;
704+
705+
onValueChange(_change: MatSelectionListChange) {}
583706
}
584707

585708
@Component({template: `
@@ -656,27 +779,27 @@ class SelectionListWithTabindexBinding {
656779
disabled: boolean;
657780
}
658781

659-
@Component({template: `
660-
<mat-selection-list id="selection-list-5">
661-
<mat-list-option [value]="1" checkboxPosition="after">
662-
1
663-
</mat-list-option>
664-
<mat-list-option value="a" checkboxPosition="after">
665-
a
666-
</mat-list-option>
667-
<mat-list-option [value]="true" checkboxPosition="after">
668-
true
669-
</mat-list-option>
670-
</mat-selection-list>`})
671-
class SelectionListWithMultipleValues {
782+
@Component({
783+
template: `
784+
<mat-selection-list [(ngModel)]="selectedOptions">
785+
<mat-list-option value="opt1">Option 1</mat-list-option>
786+
<mat-list-option value="opt2">Option 2</mat-list-option>
787+
<mat-list-option value="opt3">Option 3</mat-list-option>
788+
</mat-selection-list>`
789+
})
790+
class SelectionListWithModel {
791+
selectedOptions: string[] = [];
672792
}
673793

674-
@Component({template: `
675-
<mat-selection-list id="selection-list-6">
676-
<mat-list-option (selectionChange)="onOptionSelectionChange($event)">
677-
Inbox
678-
</mat-list-option>
679-
</mat-selection-list>`})
680-
class SelectionListWithOptionEvents {
681-
onOptionSelectionChange: (event?: MatListOptionChange) => void = () => {};
794+
@Component({
795+
template: `
796+
<mat-selection-list [formControl]="formControl">
797+
<mat-list-option value="opt1">Option 1</mat-list-option>
798+
<mat-list-option value="opt2">Option 2</mat-list-option>
799+
<mat-list-option value="opt3">Option 3</mat-list-option>
800+
</mat-selection-list>
801+
`
802+
})
803+
class SelectionListWithFormControl {
804+
formControl = new FormControl();
682805
}

0 commit comments

Comments
 (0)