diff --git a/src/components-examples/material/select/index.ts b/src/components-examples/material/select/index.ts
index 698b773b35a5..594bd086b375 100644
--- a/src/components-examples/material/select/index.ts
+++ b/src/components-examples/material/select/index.ts
@@ -12,4 +12,5 @@ export {SelectResetExample} from './select-reset/select-reset-example';
export {SelectValueBindingExample} from './select-value-binding/select-value-binding-example';
export {SelectReactiveFormExample} from './select-reactive-form/select-reactive-form-example';
export {SelectInitialValueExample} from './select-initial-value/select-initial-value-example';
+export {SelectSelectableNullExample} from './select-selectable-null/select-selectable-null-example';
export {SelectHarnessExample} from './select-harness/select-harness-example';
diff --git a/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.html b/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.html
new file mode 100644
index 000000000000..a1bc2340a5c8
--- /dev/null
+++ b/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.html
@@ -0,0 +1,19 @@
+
mat-select allowing selection of nullable options
+
+ State
+
+ @for (option of options; track option) {
+ {{option.label}}
+ }
+
+
+
+mat-select with default configuration
+
+ State
+
+ @for (option of options; track option) {
+ {{option.label}}
+ }
+
+
diff --git a/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.ts b/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.ts
new file mode 100644
index 000000000000..a5b29834cae1
--- /dev/null
+++ b/src/components-examples/material/select/select-selectable-null/select-selectable-null-example.ts
@@ -0,0 +1,21 @@
+import {Component} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {MatInputModule} from '@angular/material/input';
+import {MatSelectModule} from '@angular/material/select';
+import {MatFormFieldModule} from '@angular/material/form-field';
+
+/** @title Select with selectable null options */
+@Component({
+ selector: 'select-selectable-null-example',
+ templateUrl: 'select-selectable-null-example.html',
+ imports: [MatFormFieldModule, MatSelectModule, MatInputModule, FormsModule],
+})
+export class SelectSelectableNullExample {
+ value: number | null = null;
+ options = [
+ {label: 'None', value: null},
+ {label: 'One', value: 1},
+ {label: 'Two', value: 2},
+ {label: 'Three', value: 3},
+ ];
+}
diff --git a/src/material/select/select.md b/src/material/select/select.md
index c91a694cb18b..2a00d2849caf 100644
--- a/src/material/select/select.md
+++ b/src/material/select/select.md
@@ -66,6 +66,15 @@ If you want one of your options to reset the select's value, you can omit specif
+### Allowing nullable options to be selected
+
+By default any options with a `null` or `undefined` value will reset the select's value. If instead
+you want the nullable options to be selectable, you can enable the `canSelectNullableOptions` input.
+The default value for the input can be controlled application-wide through the `MAT_SELECT_CONFIG`
+injection token.
+
+
+
### Creating groups of options
The `` element can be used to group common options under a subheading. The name of the
diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts
index c865d3f74dcf..bb7ca95eba22 100644
--- a/src/material/select/select.spec.ts
+++ b/src/material/select/select.spec.ts
@@ -3508,7 +3508,7 @@ describe('MatSelect', () => {
expect(trigger.textContent).not.toContain('None');
}));
- it('should not mark the reset option as selected ', fakeAsync(() => {
+ it('should not mark the reset option as selected', fakeAsync(() => {
options[5].click();
fixture.detectChanges();
flush();
@@ -3545,6 +3545,102 @@ describe('MatSelect', () => {
});
});
+ describe('allowing selection of nullable options', () => {
+ beforeEach(waitForAsync(() => configureMatSelectTestingModule([ResetValuesSelect])));
+
+ let fixture: ComponentFixture;
+ let trigger: HTMLElement;
+ let formField: HTMLElement;
+ let options: NodeListOf;
+ let label: HTMLLabelElement;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(ResetValuesSelect);
+ fixture.componentInstance.canSelectNullableOptions = true;
+ fixture.detectChanges();
+ trigger = fixture.debugElement.query(By.css('.mat-mdc-select-trigger'))!.nativeElement;
+ formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement;
+ label = formField.querySelector('label')!;
+
+ trigger.click();
+ fixture.detectChanges();
+ flush();
+
+ options = overlayContainerElement.querySelectorAll('mat-option') as NodeListOf;
+ options[0].click();
+ fixture.detectChanges();
+ flush();
+ }));
+
+ it('should select an option with an undefined value', fakeAsync(() => {
+ options[4].click();
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.componentInstance.control.value).toBe(undefined);
+ expect(fixture.componentInstance.select.selected).toBeTruthy();
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ expect(trigger.textContent).toContain('Undefined');
+ }));
+
+ it('should select an option with a null value', fakeAsync(() => {
+ options[5].click();
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.componentInstance.control.value).toBe(null);
+ expect(fixture.componentInstance.select.selected).toBeTruthy();
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ expect(trigger.textContent).toContain('Null');
+ }));
+
+ it('should select a blank option', fakeAsync(() => {
+ options[6].click();
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.componentInstance.control.value).toBe(undefined);
+ expect(fixture.componentInstance.select.selected).toBeTruthy();
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ expect(trigger.textContent).toContain('None');
+ }));
+
+ it('should mark a nullable option as selected', fakeAsync(() => {
+ options[5].click();
+ fixture.detectChanges();
+ flush();
+
+ fixture.componentInstance.select.open();
+ fixture.detectChanges();
+ flush();
+
+ expect(options[5].classList).toContain('mdc-list-item--selected');
+ }));
+
+ it('should not reset when any other falsy option is selected', fakeAsync(() => {
+ options[3].click();
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.componentInstance.control.value).toBe(false);
+ expect(fixture.componentInstance.select.selected).toBeTruthy();
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ expect(trigger.textContent).toContain('Falsy');
+ }));
+
+ it('should consider the nullable values as selected when resetting the form control', () => {
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+
+ fixture.componentInstance.control.reset();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.control.value).toBe(null);
+ expect(fixture.componentInstance.select.selected).toBeTruthy();
+ expect(label.classList).toContain('mdc-floating-label--float-above');
+ expect(trigger.textContent).toContain('Null');
+ });
+ });
+
describe('with reset option and a form control', () => {
let fixture: ComponentFixture;
let options: HTMLElement[];
@@ -5057,7 +5153,7 @@ class BasicSelectWithTheming {
template: `
Select a food
-
+
@for (food of foods; track food) {
{{ food.viewValue }}
}
@@ -5076,7 +5172,8 @@ class ResetValuesSelect {
{viewValue: 'Undefined'},
{value: null, viewValue: 'Null'},
];
- control = new FormControl('' as string | boolean | null);
+ control = new FormControl('' as string | boolean | null | undefined);
+ canSelectNullableOptions = false;
@ViewChild(MatSelect) select: MatSelect;
}
diff --git a/src/material/select/select.ts b/src/material/select/select.ts
index c81a601a8954..3c5937e6da8c 100644
--- a/src/material/select/select.ts
+++ b/src/material/select/select.ts
@@ -135,6 +135,12 @@ export interface MatSelectConfig {
* If set to null or an empty string, the panel will grow to match the longest option's text.
*/
panelWidth?: string | number | null;
+
+ /**
+ * Whether nullable options can be selected by default.
+ * See `MatSelect.canSelectNullableOptions` for more information.
+ */
+ canSelectNullableOptions?: boolean;
}
/** Injection token that can be used to provide the default options the select module. */
@@ -218,8 +224,8 @@ export class MatSelect
protected _parentFormField = inject(MAT_FORM_FIELD, {optional: true});
ngControl = inject(NgControl, {self: true, optional: true})!;
private _liveAnnouncer = inject(LiveAnnouncer);
-
protected _defaultOptions = inject(MAT_SELECT_CONFIG, {optional: true});
+ private _initialized = new Subject();
/** All of the defined select options. */
@ContentChildren(MatOption, {descendants: true}) options: QueryList;
@@ -552,7 +558,14 @@ export class MatSelect
? this._defaultOptions.panelWidth
: 'auto';
- private _initialized = new Subject();
+ /**
+ * By default selecting an option with a `null` or `undefined` value will reset the select's
+ * value. Enable this option if the reset behavior doesn't match your requirements and instead
+ * the nullable options should become selected. The value of this input can be controlled app-wide
+ * using the `MAT_SELECT_CONFIG` injection token.
+ */
+ @Input({transform: booleanAttribute})
+ canSelectNullableOptions: boolean = this._defaultOptions?.canSelectNullableOptions ?? false;
/** Combined stream of all of the child options' change events. */
readonly optionSelectionChanges: Observable = defer(() => {
@@ -1098,7 +1111,10 @@ export class MatSelect
try {
// Treat null as a special reset value.
- return option.value != null && this._compareWith(option.value, value);
+ return (
+ (option.value != null || this.canSelectNullableOptions) &&
+ this._compareWith(option.value, value)
+ );
} catch (error) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
// Notify developers of errors in their comparator.
@@ -1243,7 +1259,7 @@ export class MatSelect
private _onSelect(option: MatOption, isUserInput: boolean): void {
const wasSelected = this._selectionModel.isSelected(option);
- if (option.value == null && !this._multiple) {
+ if (!this.canSelectNullableOptions && option.value == null && !this._multiple) {
option.deselect();
this._selectionModel.clear();
diff --git a/tools/public_api_guard/material/select.md b/tools/public_api_guard/material/select.md
index 10a5fdb41d89..a39d47dbd3d6 100644
--- a/tools/public_api_guard/material/select.md
+++ b/tools/public_api_guard/material/select.md
@@ -84,6 +84,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
ariaLabel: string;
ariaLabelledby: string;
protected _canOpen(): boolean;
+ canSelectNullableOptions: boolean;
// (undocumented)
protected _changeDetectorRef: ChangeDetectorRef;
close(): void;
@@ -121,6 +122,8 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
get multiple(): boolean;
set multiple(value: boolean);
// (undocumented)
+ static ngAcceptInputType_canSelectNullableOptions: unknown;
+ // (undocumented)
static ngAcceptInputType_disabled: unknown;
// (undocumented)
static ngAcceptInputType_disableOptionCentering: unknown;
@@ -209,7 +212,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
protected _viewportRuler: ViewportRuler;
writeValue(value: any): void;
// (undocumented)
- static ɵcmp: i0.ɵɵComponentDeclaration;
+ static ɵcmp: i0.ɵɵComponentDeclaration;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration;
}
@@ -231,6 +234,7 @@ export class MatSelectChange {
// @public
export interface MatSelectConfig {
+ canSelectNullableOptions?: boolean;
disableOptionCentering?: boolean;
hideSingleSelectionIndicator?: boolean;
overlayPanelClass?: string | string[];