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[];