diff --git a/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.css b/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.css new file mode 100644 index 000000000000..951aee759961 --- /dev/null +++ b/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.css @@ -0,0 +1,10 @@ +.example-form { + min-width: 150px; + max-width: 500px; + width: 100%; + margin-top: 16px; +} + +.example-full-width { + width: 100%; +} diff --git a/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.html b/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.html new file mode 100644 index 000000000000..c40d79faf328 --- /dev/null +++ b/src/components-examples/material/autocomplete/autocomplete-revert-to-value/autocomplete-revert-to-value-example.html @@ -0,0 +1,20 @@ +Control value: {{myControl.value || 'empty'}} + +
+ + Revert value to + + None + @for (state of states; track state) { + + {{ state.name }} + ({{ state.code }}) + + } + + +
+ + Revert value to + + None + @for (state of states; track state) { + + {{ state.name }} + + } + + +
Disable States diff --git a/src/dev-app/autocomplete/autocomplete-demo.ts b/src/dev-app/autocomplete/autocomplete-demo.ts index 408217b7679e..9c4ed53f5dcb 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.ts +++ b/src/dev-app/autocomplete/autocomplete-demo.ts @@ -13,6 +13,7 @@ import {MatAutocompleteModule} from '@angular/material/autocomplete'; import {MatButtonModule} from '@angular/material/button'; import {MatCardModule} from '@angular/material/card'; import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatSelectModule} from '@angular/material/select'; import {ThemePalette} from '@angular/material/core'; import {MatDialog, MatDialogRef} from '@angular/material/dialog'; import {MatInputModule} from '@angular/material/input'; @@ -42,6 +43,7 @@ type DisableStateOption = 'none' | 'first-middle-last' | 'all'; MatCardModule, MatCheckboxModule, MatInputModule, + MatSelectModule, ReactiveFormsModule, ], changeDetection: ChangeDetectionStrategy.OnPush, @@ -68,6 +70,9 @@ export class AutocompleteDemo { reactiveRequireSelection = false; templateRequireSelection = false; + reactiveRevertToValue = null; + templateRevertToValue = null; + reactiveHideSingleSelectionIndicator = false; templateHideSingleSelectionIndicator = false; diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index 7ddf9fc79ef4..d6b6741ae9da 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -757,8 +757,13 @@ export class MatAutocompleteTrigger this._element.nativeElement.value !== this._valueOnAttach ) { this._clearPreviousSelectedOption(null); - this._assignOptionValue(null); - this._onChange(null); + if (panel.revertToValue) { + this._assignOptionValue(panel.revertToValue); + this._onChange(panel.revertToValue); + } else { + this._assignOptionValue(null); + this._onChange(null); + } } this.closePanel(); diff --git a/src/material/autocomplete/autocomplete.md b/src/material/autocomplete/autocomplete.md index 12306f3f9b7d..07f6d6225b39 100644 --- a/src/material/autocomplete/autocomplete.md +++ b/src/material/autocomplete/autocomplete.md @@ -77,6 +77,19 @@ injection token. +### Revert to a given value instead of `null` + +Instead of setting the autocomplete value to `null`, the `revertToValue` input can be set to +provide a value to be set instead. This is useful in cases where the autocomplete should change +to a previously known value or default value if nothing is selected, instead of `null`. + +Because the value may not present in the filtered options, this does _not_ trigger the +`selectionChange` event. However, for both reactive and template form controls, the value will +be updated appropriately. This does mean that it is possible to set the value to something that +is not present in the options list. + + + ### Automatically highlighting the first option If your use case requires for the first autocomplete option to be highlighted when the user opens diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index 5284c4ff922d..3d22fe02f4c7 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -2859,6 +2859,95 @@ describe('MatAutocomplete', () => { expect(input.value).toBe(''); expect(stateCtrl.value).toBe(null); })); + + it('should revert to the provided value if requireSelection is enabled and revertToValue is provided', waitForAsync(async () => { + const input = fixture.nativeElement.querySelector('input'); + const {stateCtrl, trigger} = fixture.componentInstance; + fixture.componentInstance.requireSelection = true; + fixture.componentInstance.revertToValue = {code: 'DE', name: 'Delaware'}; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + // Simulate opening the input and clicking the first option. + trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + (overlayContainerElement.querySelector('mat-option') as HTMLElement).click(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe('Alabama'); + expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); + + // Simulate pressing backspace while focus is still on the input. + dispatchFakeEvent(input, 'keydown'); + input.value = 'Alabam'; + fixture.detectChanges(); + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + expect(trigger.panelOpen).toBe(true); + expect(input.value).toBe('Alabam'); + expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); + + // Simulate clicking away. + input.blur(); + dispatchFakeEvent(document, 'click'); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe('Delaware'); + expect(stateCtrl.value).toEqual({code: 'DE', name: 'Delaware'}); + })); + + it('should keep the input value if requireSelection is disabled and revertToValue is provided', waitForAsync(async () => { + const input = fixture.nativeElement.querySelector('input'); + const {stateCtrl, trigger} = fixture.componentInstance; + fixture.componentInstance.requireSelection = false; + fixture.componentInstance.revertToValue = {code: 'DE', name: 'Delaware'}; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + // Simulate opening the input and clicking the first option. + trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + (overlayContainerElement.querySelector('mat-option') as HTMLElement).click(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe('Alabama'); + expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); + + // Simulate pressing backspace while focus is still on the input. + dispatchFakeEvent(input, 'keydown'); + input.value = 'Alabam'; + fixture.detectChanges(); + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + expect(trigger.panelOpen).toBe(true); + expect(input.value).toBe('Alabam'); + expect(stateCtrl.value).toEqual('Alabam'); + + // Simulate clicking away. + input.blur(); + dispatchFakeEvent(document, 'click'); + fixture.detectChanges(); + + await new Promise(r => setTimeout(r)); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe('Alabam'); + expect(stateCtrl.value).toEqual('Alabam'); + })); }); describe('panel closing', () => { @@ -3997,6 +4086,7 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` [displayWith]="displayFn" [disableRipple]="disableRipple" [requireSelection]="requireSelection" + [revertToValue]="revertToValue" [aria-label]="ariaLabel" [aria-labelledby]="ariaLabelledby" (opened)="openedSpy()" @@ -4033,6 +4123,7 @@ class SimpleAutocomplete implements OnDestroy { autocompleteDisabled = false; hasLabel = true; requireSelection = false; + revertToValue: {code: string; name: string; height?: number; disabled?: boolean} | null = null; ariaLabel: string; ariaLabelledby: string; panelClass = 'class-one class-two'; diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index 54078571d1f0..0be6e9d9abf3 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -70,6 +70,12 @@ export interface MatAutocompleteDefaultOptions { */ requireSelection?: boolean; + /** + * If requireSelection is true, this input can be used to specify the value to revert to when + * the user closes the autocomplete panel without selecting an option. Defaults to null. + */ + revertToValue?: unknown | null; + /** Class to be applied to the autocomplete's backdrop. */ backdropClass?: string; @@ -192,6 +198,12 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy { */ @Input({transform: booleanAttribute}) requireSelection: boolean; + /** + * If requireSelection is true, this input can be used to specify the value to revert to when + * the user closes the autocomplete panel without selecting an option. Defaults to null. + */ + @Input() revertToValue: unknown | null = null; + /** * Specify the width of the autocomplete panel. Can be any CSS sizing value, otherwise it will * match the width of its host.