Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(material/select): add opt-in input that allows selection of nullable options #30142

Merged
merged 1 commit into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading