Skip to content

Commit 7343e40

Browse files
committed
fix(select): unable to use the MatOption select/deselect API to toggle options
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 9062640 commit 7343e40

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
@@ -468,13 +468,18 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
468468
}
469469

470470
ngOnInit() {
471-
this._selectionModel = new SelectionModel<MatOption>(this.multiple, undefined, false);
471+
this._selectionModel = new SelectionModel<MatOption>(this.multiple);
472472
this.stateChanges.next();
473473
}
474474

475475
ngAfterContentInit() {
476476
this._initKeyManager();
477477

478+
this._selectionModel.onChange!.pipe(takeUntil(this._destroy)).subscribe(event => {
479+
event.added.forEach(option => option.select());
480+
event.removed.forEach(option => option.deselect());
481+
});
482+
478483
this.options.changes.pipe(startWith(null), takeUntil(this._destroy)).subscribe(() => {
479484
this._resetOptions();
480485
this._initializeSelection();
@@ -753,19 +758,18 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
753758
* Sets the selected option based on a value. If no option can be
754759
* found with the designated value, the select trigger is cleared.
755760
*/
756-
private _setSelectionByValue(value: any | any[], isUserInput = false): void {
761+
private _setSelectionByValue(value: any | any[]): void {
757762
if (this.multiple && value) {
758763
if (!Array.isArray(value)) {
759764
throw getMatSelectNonArrayValueError();
760765
}
761766

762-
this._clearSelection();
763-
value.forEach((currentValue: any) => this._selectValue(currentValue, isUserInput));
767+
this._selectionModel.clear();
768+
value.forEach((currentValue: any) => this._selectValue(currentValue));
764769
this._sortValues();
765770
} else {
766-
this._clearSelection();
767-
768-
const correspondingOption = this._selectValue(value, isUserInput);
771+
this._selectionModel.clear();
772+
const correspondingOption = this._selectValue(value);
769773

770774
// Shift focus to the active item. Note that we shouldn't do this in multiple
771775
// mode, because we don't know what option the user interacted with last.
@@ -781,7 +785,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
781785
* Finds and selects and option based on its value.
782786
* @returns Option that has the corresponding value.
783787
*/
784-
private _selectValue(value: any, isUserInput = false): MatOption | undefined {
788+
private _selectValue(value: any): MatOption | undefined {
785789
const correspondingOption = this.options.find((option: MatOption) => {
786790
try {
787791
// Treat null as a special reset value.
@@ -796,29 +800,12 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
796800
});
797801

798802
if (correspondingOption) {
799-
isUserInput ? correspondingOption._selectViaInteraction() : correspondingOption.select();
800803
this._selectionModel.select(correspondingOption);
801-
this.stateChanges.next();
802804
}
803805

804806
return correspondingOption;
805807
}
806808

807-
808-
/**
809-
* Clears the select trigger and deselects every option in the list.
810-
* @param skip Option that should not be deselected.
811-
*/
812-
private _clearSelection(skip?: MatOption): void {
813-
this._selectionModel.clear();
814-
this.options.forEach(option => {
815-
if (option !== skip) {
816-
option.deselect();
817-
}
818-
});
819-
this.stateChanges.next();
820-
}
821-
822809
/** Sets up a key manager to listen to keyboard events on the overlay panel. */
823810
private _initKeyManager() {
824811
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options)
@@ -846,16 +833,14 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
846833
private _resetOptions(): void {
847834
const changedOrDestroyed = merge(this.options.changes, this._destroy);
848835

849-
this.optionSelectionChanges
850-
.pipe(takeUntil(changedOrDestroyed), filter(event => event.isUserInput))
851-
.subscribe(event => {
852-
this._onSelect(event.source);
836+
this.optionSelectionChanges.pipe(takeUntil(changedOrDestroyed)).subscribe(event => {
837+
this._onSelect(event.source, event.isUserInput);
853838

854-
if (!this.multiple && this._panelOpen) {
855-
this.close();
856-
this.focus();
857-
}
858-
});
839+
if (event.isUserInput && !this.multiple && this._panelOpen) {
840+
this.close();
841+
this.focus();
842+
}
843+
});
859844

860845
// Listen to changes in the internal state of the options and react accordingly.
861846
// Handles cases like the labels of the selected options changing.
@@ -870,51 +855,42 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
870855
}
871856

872857
/** Invoked when an option is clicked. */
873-
private _onSelect(option: MatOption): void {
858+
private _onSelect(option: MatOption, isUserInput: boolean): void {
874859
const wasSelected = this._selectionModel.isSelected(option);
875860

876-
// TODO(crisbeto): handle blank/null options inside multi-select.
877-
if (this.multiple) {
878-
this._selectionModel.toggle(option);
879-
this.stateChanges.next();
880-
wasSelected ? option.deselect() : option.select();
881-
this._keyManager.setActiveItem(option);
882-
this._sortValues();
883-
884-
// In case the user select the option with their mouse, we
885-
// want to restore focus back to the trigger, in order to
886-
// prevent the select keyboard controls from clashing with
887-
// the ones from `mat-option`.
888-
this.focus();
861+
if (option.value == null) {
862+
this._selectionModel.clear();
863+
this._propagateChanges(option.value);
889864
} else {
890-
this._clearSelection(option.value == null ? undefined : option);
891-
892-
if (option.value == null) {
893-
this._propagateChanges(option.value);
894-
} else {
895-
this._selectionModel.select(option);
896-
this.stateChanges.next();
865+
option.selected ? this._selectionModel.select(option) : this._selectionModel.deselect(option);
866+
867+
// TODO(crisbeto): handle blank/null options inside multi-select.
868+
if (this.multiple) {
869+
this._sortValues();
870+
871+
if (isUserInput) {
872+
this._keyManager.setActiveItem(option);
873+
// In case the user selected the option with their mouse, we
874+
// want to restore focus back to the trigger, in order to
875+
// prevent the select keyboard controls from clashing with
876+
// the ones from `mat-option`.
877+
this.focus();
878+
}
897879
}
898880
}
899881

900882
if (wasSelected !== this._selectionModel.isSelected(option)) {
901883
this._propagateChanges();
902884
}
903-
}
904885

905-
/**
906-
* Sorts the model values, ensuring that they keep the same
907-
* order that they have in the panel.
908-
*/
909-
private _sortValues(): void {
910-
if (this._multiple) {
911-
this._selectionModel.clear();
886+
this.stateChanges.next();
887+
}
912888

913-
this.options.forEach(option => {
914-
if (option.selected) {
915-
this._selectionModel.select(option);
916-
}
917-
});
889+
/** Sorts the selected values in the selected based on their order in the panel. */
890+
private _sortValues() {
891+
if (this.multiple) {
892+
const options = this.options.toArray();
893+
this._selectionModel.sort((a, b) => options.indexOf(a) - options.indexOf(b));
918894
this.stateChanges.next();
919895
}
920896
}

0 commit comments

Comments
 (0)