Skip to content

Commit 0296713

Browse files
authored
fix(material/select): add opt-in input that allows selection of nullable options (#30142)
By default `mat-select` treats options with nullable values as "reset options", meaning that they can't be selected, but rather they clear the select's value. This behavior is based on how the native `select` works, however in some cases it's not desirable. These changes add an input that users can use to opt out of the default behavior. Fixes #25120.
1 parent f7d787b commit 0296713

File tree

7 files changed

+175
-8
lines changed

7 files changed

+175
-8
lines changed

src/components-examples/material/select/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ export {SelectResetExample} from './select-reset/select-reset-example';
1212
export {SelectValueBindingExample} from './select-value-binding/select-value-binding-example';
1313
export {SelectReactiveFormExample} from './select-reactive-form/select-reactive-form-example';
1414
export {SelectInitialValueExample} from './select-initial-value/select-initial-value-example';
15+
export {SelectSelectableNullExample} from './select-selectable-null/select-selectable-null-example';
1516
export {SelectHarnessExample} from './select-harness/select-harness-example';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<h4>mat-select allowing selection of nullable options</h4>
2+
<mat-form-field>
3+
<mat-label>State</mat-label>
4+
<mat-select [(ngModel)]="value" canSelectNullableOptions>
5+
@for (option of options; track option) {
6+
<mat-option [value]="option.value">{{option.label}}</mat-option>
7+
}
8+
</mat-select>
9+
</mat-form-field>
10+
11+
<h4>mat-select with default configuration</h4>
12+
<mat-form-field>
13+
<mat-label>State</mat-label>
14+
<mat-select [(ngModel)]="value">
15+
@for (option of options; track option) {
16+
<mat-option [value]="option.value">{{option.label}}</mat-option>
17+
}
18+
</mat-select>
19+
</mat-form-field>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {Component} from '@angular/core';
2+
import {FormsModule} from '@angular/forms';
3+
import {MatInputModule} from '@angular/material/input';
4+
import {MatSelectModule} from '@angular/material/select';
5+
import {MatFormFieldModule} from '@angular/material/form-field';
6+
7+
/** @title Select with selectable null options */
8+
@Component({
9+
selector: 'select-selectable-null-example',
10+
templateUrl: 'select-selectable-null-example.html',
11+
imports: [MatFormFieldModule, MatSelectModule, MatInputModule, FormsModule],
12+
})
13+
export class SelectSelectableNullExample {
14+
value: number | null = null;
15+
options = [
16+
{label: 'None', value: null},
17+
{label: 'One', value: 1},
18+
{label: 'Two', value: 2},
19+
{label: 'Three', value: 3},
20+
];
21+
}

src/material/select/select.md

+9
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ If you want one of your options to reset the select's value, you can omit specif
6666

6767
<!-- example(select-reset) -->
6868

69+
### Allowing nullable options to be selected
70+
71+
By default any options with a `null` or `undefined` value will reset the select's value. If instead
72+
you want the nullable options to be selectable, you can enable the `canSelectNullableOptions` input.
73+
The default value for the input can be controlled application-wide through the `MAT_SELECT_CONFIG`
74+
injection token.
75+
76+
<!-- example(select-selectable-null) -->
77+
6978
### Creating groups of options
7079

7180
The `<mat-optgroup>` element can be used to group common options under a subheading. The name of the

src/material/select/select.spec.ts

+100-3
Original file line numberDiff line numberDiff line change
@@ -3508,7 +3508,7 @@ describe('MatSelect', () => {
35083508
expect(trigger.textContent).not.toContain('None');
35093509
}));
35103510

3511-
it('should not mark the reset option as selected ', fakeAsync(() => {
3511+
it('should not mark the reset option as selected', fakeAsync(() => {
35123512
options[5].click();
35133513
fixture.detectChanges();
35143514
flush();
@@ -3545,6 +3545,102 @@ describe('MatSelect', () => {
35453545
});
35463546
});
35473547

3548+
describe('allowing selection of nullable options', () => {
3549+
beforeEach(waitForAsync(() => configureMatSelectTestingModule([ResetValuesSelect])));
3550+
3551+
let fixture: ComponentFixture<ResetValuesSelect>;
3552+
let trigger: HTMLElement;
3553+
let formField: HTMLElement;
3554+
let options: NodeListOf<HTMLElement>;
3555+
let label: HTMLLabelElement;
3556+
3557+
beforeEach(fakeAsync(() => {
3558+
fixture = TestBed.createComponent(ResetValuesSelect);
3559+
fixture.componentInstance.canSelectNullableOptions = true;
3560+
fixture.detectChanges();
3561+
trigger = fixture.debugElement.query(By.css('.mat-mdc-select-trigger'))!.nativeElement;
3562+
formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement;
3563+
label = formField.querySelector('label')!;
3564+
3565+
trigger.click();
3566+
fixture.detectChanges();
3567+
flush();
3568+
3569+
options = overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
3570+
options[0].click();
3571+
fixture.detectChanges();
3572+
flush();
3573+
}));
3574+
3575+
it('should select an option with an undefined value', fakeAsync(() => {
3576+
options[4].click();
3577+
fixture.detectChanges();
3578+
flush();
3579+
3580+
expect(fixture.componentInstance.control.value).toBe(undefined);
3581+
expect(fixture.componentInstance.select.selected).toBeTruthy();
3582+
expect(label.classList).toContain('mdc-floating-label--float-above');
3583+
expect(trigger.textContent).toContain('Undefined');
3584+
}));
3585+
3586+
it('should select an option with a null value', fakeAsync(() => {
3587+
options[5].click();
3588+
fixture.detectChanges();
3589+
flush();
3590+
3591+
expect(fixture.componentInstance.control.value).toBe(null);
3592+
expect(fixture.componentInstance.select.selected).toBeTruthy();
3593+
expect(label.classList).toContain('mdc-floating-label--float-above');
3594+
expect(trigger.textContent).toContain('Null');
3595+
}));
3596+
3597+
it('should select a blank option', fakeAsync(() => {
3598+
options[6].click();
3599+
fixture.detectChanges();
3600+
flush();
3601+
3602+
expect(fixture.componentInstance.control.value).toBe(undefined);
3603+
expect(fixture.componentInstance.select.selected).toBeTruthy();
3604+
expect(label.classList).toContain('mdc-floating-label--float-above');
3605+
expect(trigger.textContent).toContain('None');
3606+
}));
3607+
3608+
it('should mark a nullable option as selected', fakeAsync(() => {
3609+
options[5].click();
3610+
fixture.detectChanges();
3611+
flush();
3612+
3613+
fixture.componentInstance.select.open();
3614+
fixture.detectChanges();
3615+
flush();
3616+
3617+
expect(options[5].classList).toContain('mdc-list-item--selected');
3618+
}));
3619+
3620+
it('should not reset when any other falsy option is selected', fakeAsync(() => {
3621+
options[3].click();
3622+
fixture.detectChanges();
3623+
flush();
3624+
3625+
expect(fixture.componentInstance.control.value).toBe(false);
3626+
expect(fixture.componentInstance.select.selected).toBeTruthy();
3627+
expect(label.classList).toContain('mdc-floating-label--float-above');
3628+
expect(trigger.textContent).toContain('Falsy');
3629+
}));
3630+
3631+
it('should consider the nullable values as selected when resetting the form control', () => {
3632+
expect(label.classList).toContain('mdc-floating-label--float-above');
3633+
3634+
fixture.componentInstance.control.reset();
3635+
fixture.detectChanges();
3636+
3637+
expect(fixture.componentInstance.control.value).toBe(null);
3638+
expect(fixture.componentInstance.select.selected).toBeTruthy();
3639+
expect(label.classList).toContain('mdc-floating-label--float-above');
3640+
expect(trigger.textContent).toContain('Null');
3641+
});
3642+
});
3643+
35483644
describe('with reset option and a form control', () => {
35493645
let fixture: ComponentFixture<SelectWithResetOptionAndFormControl>;
35503646
let options: HTMLElement[];
@@ -5057,7 +5153,7 @@ class BasicSelectWithTheming {
50575153
template: `
50585154
<mat-form-field>
50595155
<mat-label>Select a food</mat-label>
5060-
<mat-select [formControl]="control">
5156+
<mat-select [formControl]="control" [canSelectNullableOptions]="canSelectNullableOptions">
50615157
@for (food of foods; track food) {
50625158
<mat-option [value]="food.value">{{ food.viewValue }}</mat-option>
50635159
}
@@ -5076,7 +5172,8 @@ class ResetValuesSelect {
50765172
{viewValue: 'Undefined'},
50775173
{value: null, viewValue: 'Null'},
50785174
];
5079-
control = new FormControl('' as string | boolean | null);
5175+
control = new FormControl('' as string | boolean | null | undefined);
5176+
canSelectNullableOptions = false;
50805177

50815178
@ViewChild(MatSelect) select: MatSelect;
50825179
}

src/material/select/select.ts

+20-4
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ export interface MatSelectConfig {
135135
* If set to null or an empty string, the panel will grow to match the longest option's text.
136136
*/
137137
panelWidth?: string | number | null;
138+
139+
/**
140+
* Whether nullable options can be selected by default.
141+
* See `MatSelect.canSelectNullableOptions` for more information.
142+
*/
143+
canSelectNullableOptions?: boolean;
138144
}
139145

140146
/** Injection token that can be used to provide the default options the select module. */
@@ -218,8 +224,8 @@ export class MatSelect
218224
protected _parentFormField = inject<MatFormField>(MAT_FORM_FIELD, {optional: true});
219225
ngControl = inject(NgControl, {self: true, optional: true})!;
220226
private _liveAnnouncer = inject(LiveAnnouncer);
221-
222227
protected _defaultOptions = inject(MAT_SELECT_CONFIG, {optional: true});
228+
private _initialized = new Subject();
223229

224230
/** All of the defined select options. */
225231
@ContentChildren(MatOption, {descendants: true}) options: QueryList<MatOption>;
@@ -552,7 +558,14 @@ export class MatSelect
552558
? this._defaultOptions.panelWidth
553559
: 'auto';
554560

555-
private _initialized = new Subject();
561+
/**
562+
* By default selecting an option with a `null` or `undefined` value will reset the select's
563+
* value. Enable this option if the reset behavior doesn't match your requirements and instead
564+
* the nullable options should become selected. The value of this input can be controlled app-wide
565+
* using the `MAT_SELECT_CONFIG` injection token.
566+
*/
567+
@Input({transform: booleanAttribute})
568+
canSelectNullableOptions: boolean = this._defaultOptions?.canSelectNullableOptions ?? false;
556569

557570
/** Combined stream of all of the child options' change events. */
558571
readonly optionSelectionChanges: Observable<MatOptionSelectionChange> = defer(() => {
@@ -1098,7 +1111,10 @@ export class MatSelect
10981111

10991112
try {
11001113
// Treat null as a special reset value.
1101-
return option.value != null && this._compareWith(option.value, value);
1114+
return (
1115+
(option.value != null || this.canSelectNullableOptions) &&
1116+
this._compareWith(option.value, value)
1117+
);
11021118
} catch (error) {
11031119
if (typeof ngDevMode === 'undefined' || ngDevMode) {
11041120
// Notify developers of errors in their comparator.
@@ -1243,7 +1259,7 @@ export class MatSelect
12431259
private _onSelect(option: MatOption, isUserInput: boolean): void {
12441260
const wasSelected = this._selectionModel.isSelected(option);
12451261

1246-
if (option.value == null && !this._multiple) {
1262+
if (!this.canSelectNullableOptions && option.value == null && !this._multiple) {
12471263
option.deselect();
12481264
this._selectionModel.clear();
12491265

tools/public_api_guard/material/select.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
8484
ariaLabel: string;
8585
ariaLabelledby: string;
8686
protected _canOpen(): boolean;
87+
canSelectNullableOptions: boolean;
8788
// (undocumented)
8889
protected _changeDetectorRef: ChangeDetectorRef;
8990
close(): void;
@@ -121,6 +122,8 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
121122
get multiple(): boolean;
122123
set multiple(value: boolean);
123124
// (undocumented)
125+
static ngAcceptInputType_canSelectNullableOptions: unknown;
126+
// (undocumented)
124127
static ngAcceptInputType_disabled: unknown;
125128
// (undocumented)
126129
static ngAcceptInputType_disableOptionCentering: unknown;
@@ -209,7 +212,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
209212
protected _viewportRuler: ViewportRuler;
210213
writeValue(value: any): void;
211214
// (undocumented)
212-
static ɵcmp: i0.ɵɵComponentDeclaration<MatSelect, "mat-select", ["matSelect"], { "userAriaDescribedBy": { "alias": "aria-describedby"; "required": false; }; "panelClass": { "alias": "panelClass"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "hideSingleSelectionIndicator": { "alias": "hideSingleSelectionIndicator"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; "disableOptionCentering": { "alias": "disableOptionCentering"; "required": false; }; "compareWith": { "alias": "compareWith"; "required": false; }; "value": { "alias": "value"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "typeaheadDebounceInterval": { "alias": "typeaheadDebounceInterval"; "required": false; }; "sortComparator": { "alias": "sortComparator"; "required": false; }; "id": { "alias": "id"; "required": false; }; "panelWidth": { "alias": "panelWidth"; "required": false; }; }, { "openedChange": "openedChange"; "_openedStream": "opened"; "_closedStream": "closed"; "selectionChange": "selectionChange"; "valueChange": "valueChange"; }, ["customTrigger", "options", "optionGroups"], ["mat-select-trigger", "*"], true, never>;
215+
static ɵcmp: i0.ɵɵComponentDeclaration<MatSelect, "mat-select", ["matSelect"], { "userAriaDescribedBy": { "alias": "aria-describedby"; "required": false; }; "panelClass": { "alias": "panelClass"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "hideSingleSelectionIndicator": { "alias": "hideSingleSelectionIndicator"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; "disableOptionCentering": { "alias": "disableOptionCentering"; "required": false; }; "compareWith": { "alias": "compareWith"; "required": false; }; "value": { "alias": "value"; "required": false; }; "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; "typeaheadDebounceInterval": { "alias": "typeaheadDebounceInterval"; "required": false; }; "sortComparator": { "alias": "sortComparator"; "required": false; }; "id": { "alias": "id"; "required": false; }; "panelWidth": { "alias": "panelWidth"; "required": false; }; "canSelectNullableOptions": { "alias": "canSelectNullableOptions"; "required": false; }; }, { "openedChange": "openedChange"; "_openedStream": "opened"; "_closedStream": "closed"; "selectionChange": "selectionChange"; "valueChange": "valueChange"; }, ["customTrigger", "options", "optionGroups"], ["mat-select-trigger", "*"], true, never>;
213216
// (undocumented)
214217
static ɵfac: i0.ɵɵFactoryDeclaration<MatSelect, never>;
215218
}
@@ -231,6 +234,7 @@ export class MatSelectChange {
231234

232235
// @public
233236
export interface MatSelectConfig {
237+
canSelectNullableOptions?: boolean;
234238
disableOptionCentering?: boolean;
235239
hideSingleSelectionIndicator?: boolean;
236240
overlayPanelClass?: string | string[];

0 commit comments

Comments
 (0)