Skip to content

Commit 81537af

Browse files
crisbetojelbourn
authored andcommitted
fix(select): unable to use the MatOption select/deselect API to toggle options (#11528)
Reworks the way `MatSelect` handles syncing its selected state between its child options and the `SelectionModel`, making it easier to follow and generally more robust. Previously we kept syncing the selected state in parallel between the options themselves and the selection model, which meant that we had to prevent infinite loops by ignoring any non-user changes to the options' `selected` state. This made the `MatOption.select` and `MatOption.deselect` methods useless to consumers and was very error prone. The new approach makes the `select` and `deselect` methods usable and allows us to turn the `MatOption.selected` property into an input, getting it closer to the native `option` API. These changes also address the following issues that I bumped into along the way: * The `MatOption.onSelectionChange` was emitting even if the selection hadn't changed. * The `SelectionModel.sort` wasn't sorting its values if the consumer hadn't attempted to access the `selected` value since the last time it changed. Fixes #9314.
1 parent 4b2eb1c commit 81537af

File tree

6 files changed

+127
-78
lines changed

6 files changed

+127
-78
lines changed

src/cdk/collections/selection.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ describe('SelectionModel', () => {
8282

8383
expect(model.selected).toEqual([1, 2, 3]);
8484
});
85+
86+
it('should sort values if `selected` has not been accessed before', () => {
87+
model = new SelectionModel(true, [2, 3, 1]);
88+
89+
// Important: don't assert `selected` before sorting so the getter isn't invoked
90+
model.sort();
91+
expect(model.selected).toEqual([1, 2, 3]);
92+
});
8593
});
8694

8795
describe('onChange event', () => {

src/cdk/collections/selection.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {Subject} from 'rxjs';
1313
*/
1414
export class SelectionModel<T> {
1515
/** Currently-selected values. */
16-
private _selection: Set<T> = new Set();
16+
private _selection = new Set<T>();
1717

1818
/** Keeps track of the deselected options that haven't been emitted by the change event. */
1919
private _deselectedToEmit: T[] = [];
@@ -111,8 +111,8 @@ export class SelectionModel<T> {
111111
* Sorts the selected values based on a predicate function.
112112
*/
113113
sort(predicate?: (a: T, b: T) => number): void {
114-
if (this._multiple && this._selected) {
115-
this._selected.sort(predicate);
114+
if (this._multiple && this.selected) {
115+
this._selected!.sort(predicate);
116116
}
117117
}
118118

src/lib/core/option/option.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,50 @@ describe('MatOption component', () => {
2727
subscription.unsubscribe();
2828
});
2929

30+
it('should not emit to `onSelectionChange` if selecting an already-selected option', () => {
31+
const fixture = TestBed.createComponent(OptionWithDisable);
32+
fixture.detectChanges();
33+
34+
const optionInstance: MatOption =
35+
fixture.debugElement.query(By.directive(MatOption)).componentInstance;
36+
37+
optionInstance.select();
38+
expect(optionInstance.selected).toBe(true);
39+
40+
const spy = jasmine.createSpy('selection change spy');
41+
const subscription = optionInstance.onSelectionChange.subscribe(spy);
42+
43+
optionInstance.select();
44+
fixture.detectChanges();
45+
46+
expect(optionInstance.selected).toBe(true);
47+
expect(spy).not.toHaveBeenCalled();
48+
49+
subscription.unsubscribe();
50+
});
51+
52+
it('should not emit to `onSelectionChange` if deselecting an unselected option', () => {
53+
const fixture = TestBed.createComponent(OptionWithDisable);
54+
fixture.detectChanges();
55+
56+
const optionInstance: MatOption =
57+
fixture.debugElement.query(By.directive(MatOption)).componentInstance;
58+
59+
optionInstance.deselect();
60+
expect(optionInstance.selected).toBe(false);
61+
62+
const spy = jasmine.createSpy('selection change spy');
63+
const subscription = optionInstance.onSelectionChange.subscribe(spy);
64+
65+
optionInstance.deselect();
66+
fixture.detectChanges();
67+
68+
expect(optionInstance.selected).toBe(false);
69+
expect(spy).not.toHaveBeenCalled();
70+
71+
subscription.unsubscribe();
72+
});
73+
3074
describe('ripples', () => {
3175
let fixture: ComponentFixture<OptionWithDisable>;
3276
let optionDebugElement: DebugElement;

src/lib/core/option/option.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,20 @@ export class MatOption implements AfterViewChecked, OnDestroy {
145145

146146
/** Selects the option. */
147147
select(): void {
148-
this._selected = true;
149-
this._changeDetectorRef.markForCheck();
150-
this._emitSelectionChangeEvent();
148+
if (!this._selected) {
149+
this._selected = true;
150+
this._changeDetectorRef.markForCheck();
151+
this._emitSelectionChangeEvent();
152+
}
151153
}
152154

153155
/** Deselects the option. */
154156
deselect(): void {
155-
this._selected = false;
156-
this._changeDetectorRef.markForCheck();
157-
this._emitSelectionChangeEvent();
157+
if (this._selected) {
158+
this._selected = false;
159+
this._changeDetectorRef.markForCheck();
160+
this._emitSelectionChangeEvent();
161+
}
158162
}
159163

160164
/** Sets focus onto this option. */

src/lib/select/select.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ describe('MatSelect', () => {
8686
* overall test time.
8787
* @param declarations Components to declare for this block
8888
*/
89-
function configureMatSelectTestingModule(declarations) {
89+
function configureMatSelectTestingModule(declarations: any[]) {
9090
TestBed.configureTestingModule({
9191
imports: [
9292
MatFormFieldModule,
@@ -1096,6 +1096,23 @@ describe('MatSelect', () => {
10961096
.toBe(fixture.componentInstance.options.first);
10971097
}));
10981098

1099+
it('should be able to select an option using the MatOption API', fakeAsync(() => {
1100+
trigger.click();
1101+
fixture.detectChanges();
1102+
flush();
1103+
1104+
const optionInstances = fixture.componentInstance.options.toArray();
1105+
const optionNodes: NodeListOf<HTMLElement> =
1106+
overlayContainerElement.querySelectorAll('mat-option');
1107+
1108+
optionInstances[1].select();
1109+
fixture.detectChanges();
1110+
1111+
expect(optionNodes[1].classList).toContain('mat-selected');
1112+
expect(optionInstances[1].selected).toBe(true);
1113+
expect(fixture.componentInstance.select.selected).toBe(optionInstances[1]);
1114+
}));
1115+
10991116
it('should deselect other options when one is selected', fakeAsync(() => {
11001117
trigger.click();
11011118
fixture.detectChanges();

src/lib/select/select.ts

Lines changed: 44 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
479479
}
480480

481481
ngOnInit() {
482-
this._selectionModel = new SelectionModel<MatOption>(this.multiple, undefined, false);
482+
this._selectionModel = new SelectionModel<MatOption>(this.multiple);
483483
this.stateChanges.next();
484484

485485
// We need `distinctUntilChanged` here, because some browsers will
@@ -503,6 +503,11 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
503503
ngAfterContentInit() {
504504
this._initKeyManager();
505505

506+
this._selectionModel.onChange!.pipe(takeUntil(this._destroy)).subscribe(event => {
507+
event.added.forEach(option => option.select());
508+
event.removed.forEach(option => option.deselect());
509+
});
510+
506511
this.options.changes.pipe(startWith(null), takeUntil(this._destroy)).subscribe(() => {
507512
this._resetOptions();
508513
this._initializeSelection();
@@ -765,19 +770,18 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
765770
* Sets the selected option based on a value. If no option can be
766771
* found with the designated value, the select trigger is cleared.
767772
*/
768-
private _setSelectionByValue(value: any | any[], isUserInput = false): void {
773+
private _setSelectionByValue(value: any | any[]): void {
769774
if (this.multiple && value) {
770775
if (!Array.isArray(value)) {
771776
throw getMatSelectNonArrayValueError();
772777
}
773778

774-
this._clearSelection();
775-
value.forEach((currentValue: any) => this._selectValue(currentValue, isUserInput));
779+
this._selectionModel.clear();
780+
value.forEach((currentValue: any) => this._selectValue(currentValue));
776781
this._sortValues();
777782
} else {
778-
this._clearSelection();
779-
780-
const correspondingOption = this._selectValue(value, isUserInput);
783+
this._selectionModel.clear();
784+
const correspondingOption = this._selectValue(value);
781785

782786
// Shift focus to the active item. Note that we shouldn't do this in multiple
783787
// mode, because we don't know what option the user interacted with last.
@@ -793,7 +797,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
793797
* Finds and selects and option based on its value.
794798
* @returns Option that has the corresponding value.
795799
*/
796-
private _selectValue(value: any, isUserInput = false): MatOption | undefined {
800+
private _selectValue(value: any): MatOption | undefined {
797801
const correspondingOption = this.options.find((option: MatOption) => {
798802
try {
799803
// Treat null as a special reset value.
@@ -808,29 +812,12 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
808812
});
809813

810814
if (correspondingOption) {
811-
isUserInput ? correspondingOption._selectViaInteraction() : correspondingOption.select();
812815
this._selectionModel.select(correspondingOption);
813-
this.stateChanges.next();
814816
}
815817

816818
return correspondingOption;
817819
}
818820

819-
820-
/**
821-
* Clears the select trigger and deselects every option in the list.
822-
* @param skip Option that should not be deselected.
823-
*/
824-
private _clearSelection(skip?: MatOption): void {
825-
this._selectionModel.clear();
826-
this.options.forEach(option => {
827-
if (option !== skip) {
828-
option.deselect();
829-
}
830-
});
831-
this.stateChanges.next();
832-
}
833-
834821
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
835822
private _initKeyManager() {
836823
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options)
@@ -858,16 +845,14 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
858845
private _resetOptions(): void {
859846
const changedOrDestroyed = merge(this.options.changes, this._destroy);
860847

861-
this.optionSelectionChanges
862-
.pipe(takeUntil(changedOrDestroyed), filter(event => event.isUserInput))
863-
.subscribe(event => {
864-
this._onSelect(event.source);
848+
this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed)).subscribe(event => {
849+
this._onSelect(event.source, event.isUserInput);
865850

866-
if (!this.multiple && this._panelOpen) {
867-
this.close();
868-
this.focus();
869-
}
870-
});
851+
if (event.isUserInput && !this.multiple && this._panelOpen) {
852+
this.close();
853+
this.focus();
854+
}
855+
});
871856

872857
// Listen to changes in the internal state of the options and react accordingly.
873858
// Handles cases like the labels of the selected options changing.
@@ -882,51 +867,42 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
882867
}
883868

884869
/** Invoked when an option is clicked. */
885-
private _onSelect(option: MatOption): void {
870+
private _onSelect(option: MatOption, isUserInput: boolean): void {
886871
const wasSelected = this._selectionModel.isSelected(option);
887872

888-
// TODO(crisbeto): handle blank/null options inside multi-select.
889-
if (this.multiple) {
890-
this._selectionModel.toggle(option);
891-
this.stateChanges.next();
892-
wasSelected ? option.deselect() : option.select();
893-
this._keyManager.setActiveItem(option);
894-
this._sortValues();
895-
896-
// In case the user select the option with their mouse, we
897-
// want to restore focus back to the trigger, in order to
898-
// prevent the select keyboard controls from clashing with
899-
// the ones from `mat-option`.
900-
this.focus();
873+
if (option.value == null) {
874+
this._selectionModel.clear();
875+
this._propagateChanges(option.value);
901876
} else {
902-
this._clearSelection(option.value == null ? undefined : option);
903-
904-
if (option.value == null) {
905-
this._propagateChanges(option.value);
906-
} else {
907-
this._selectionModel.select(option);
908-
this.stateChanges.next();
877+
option.selected ? this._selectionModel.select(option) : this._selectionModel.deselect(option);
878+
879+
// TODO(crisbeto): handle blank/null options inside multi-select.
880+
if (this.multiple) {
881+
this._sortValues();
882+
883+
if (isUserInput) {
884+
this._keyManager.setActiveItem(option);
885+
// In case the user selected the option with their mouse, we
886+
// want to restore focus back to the trigger, in order to
887+
// prevent the select keyboard controls from clashing with
888+
// the ones from `mat-option`.
889+
this.focus();
890+
}
909891
}
910892
}
911893

912894
if (wasSelected !== this._selectionModel.isSelected(option)) {
913895
this._propagateChanges();
914896
}
915-
}
916897

917-
/**
918-
* Sorts the model values, ensuring that they keep the same
919-
* order that they have in the panel.
920-
*/
921-
private _sortValues(): void {
922-
if (this._multiple) {
923-
this._selectionModel.clear();
898+
this.stateChanges.next();
899+
}
924900

925-
this.options.forEach(option => {
926-
if (option.selected) {
927-
this._selectionModel.select(option);
928-
}
929-
});
901+
/** Sorts the selected values in the selected based on their order in the panel. */
902+
private _sortValues() {
903+
if (this.multiple) {
904+
const options = this.options.toArray();
905+
this._selectionModel.sort((a, b) => options.indexOf(a) - options.indexOf(b));
930906
this.stateChanges.next();
931907
}
932908
}

0 commit comments

Comments
 (0)