Skip to content

Commit

Permalink
fix(material/select): add opt-in input that allows selection of nulla…
Browse files Browse the repository at this point in the history
…ble 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.

(cherry picked from commit 0296713)
  • Loading branch information
crisbeto committed Dec 10, 2024
1 parent 600a8b0 commit fbaf286
Show file tree
Hide file tree
Showing 7 changed files with 175 additions and 8 deletions.
1 change: 1 addition & 0 deletions src/components-examples/material/select/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<h4>mat-select allowing selection of nullable options</h4>
<mat-form-field>
<mat-label>State</mat-label>
<mat-select [(ngModel)]="value" canSelectNullableOptions>
@for (option of options; track option) {
<mat-option [value]="option.value">{{option.label}}</mat-option>
}
</mat-select>
</mat-form-field>

<h4>mat-select with default configuration</h4>
<mat-form-field>
<mat-label>State</mat-label>
<mat-select [(ngModel)]="value">
@for (option of options; track option) {
<mat-option [value]="option.value">{{option.label}}</mat-option>
}
</mat-select>
</mat-form-field>
Original file line number Diff line number Diff line change
@@ -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},
];
}
9 changes: 9 additions & 0 deletions src/material/select/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ If you want one of your options to reset the select's value, you can omit specif

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

### 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.

<!-- example(select-selectable-null) -->

### Creating groups of options

The `<mat-optgroup>` element can be used to group common options under a subheading. The name of the
Expand Down
103 changes: 100 additions & 3 deletions src/material/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -3545,6 +3545,102 @@ describe('MatSelect', () => {
});
});

describe('allowing selection of nullable options', () => {
beforeEach(waitForAsync(() => configureMatSelectTestingModule([ResetValuesSelect])));

let fixture: ComponentFixture<ResetValuesSelect>;
let trigger: HTMLElement;
let formField: HTMLElement;
let options: NodeListOf<HTMLElement>;
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<HTMLElement>;
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<SelectWithResetOptionAndFormControl>;
let options: HTMLElement[];
Expand Down Expand Up @@ -5057,7 +5153,7 @@ class BasicSelectWithTheming {
template: `
<mat-form-field>
<mat-label>Select a food</mat-label>
<mat-select [formControl]="control">
<mat-select [formControl]="control" [canSelectNullableOptions]="canSelectNullableOptions">
@for (food of foods; track food) {
<mat-option [value]="food.value">{{ food.viewValue }}</mat-option>
}
Expand All @@ -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;
}
Expand Down
24 changes: 20 additions & 4 deletions src/material/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -218,8 +224,8 @@ export class MatSelect
protected _parentFormField = inject<MatFormField>(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<MatOption>;
Expand Down Expand Up @@ -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<MatOptionSelectionChange> = defer(() => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down
6 changes: 5 additions & 1 deletion tools/public_api_guard/material/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -209,7 +212,7 @@ export class MatSelect implements AfterContentInit, OnChanges, OnDestroy, OnInit
protected _viewportRuler: ViewportRuler;
writeValue(value: any): void;
// (undocumented)
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>;
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>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatSelect, never>;
}
Expand All @@ -231,6 +234,7 @@ export class MatSelectChange {

// @public
export interface MatSelectConfig {
canSelectNullableOptions?: boolean;
disableOptionCentering?: boolean;
hideSingleSelectionIndicator?: boolean;
overlayPanelClass?: string | string[];
Expand Down

0 comments on commit fbaf286

Please sign in to comment.