Skip to content

Commit 8edb840

Browse files
feat:(angular/component): Configurable reversion value for requireSelection for MatAutocomplete
Adds a new input for `MatAutocomplete`: `revertValueTo`. This input works with `requireSelection` and allows the developer to provide a value that should be reverted to _instead_ of `null`. One use case for this feature is to have the autocomplete revert to the _last known_ value instead of `null` with something like: `[revertValueTo]="formControl.value"` This defaults to `null` in order to maintain backwards compatibility. The use-case that led to this change is a time zone picker. If the user does not select a time zone, it will revert to either the previously selected time zone or the browser's time zone. Video using the demo app: https://www.youtube.com/watch?v=cqZ2WzNDZbk
1 parent 83006db commit 8edb840

File tree

11 files changed

+227
-4
lines changed

11 files changed

+227
-4
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
.example-form {
2+
min-width: 150px;
3+
max-width: 500px;
4+
width: 100%;
5+
margin-top: 16px;
6+
}
7+
8+
.example-full-width {
9+
width: 100%;
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Control value: {{myControl.value || 'empty'}}
2+
3+
<form class="example-form">
4+
<mat-form-field class="example-full-width">
5+
<mat-label>Number</mat-label>
6+
<input #input
7+
type="text"
8+
placeholder="Pick one"
9+
matInput
10+
[formControl]="myControl"
11+
[matAutocomplete]="auto"
12+
(input)="filter()"
13+
(focus)="filter()">
14+
<mat-autocomplete requireSelection #auto="matAutocomplete" revertToValue="Three">
15+
@for (option of filteredOptions; track option) {
16+
<mat-option [value]="option">{{option}}</mat-option>
17+
}
18+
</mat-autocomplete>
19+
</mat-form-field>
20+
</form>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {Component, ElementRef, ViewChild} from '@angular/core';
2+
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
3+
import {MatAutocompleteModule} from '@angular/material/autocomplete';
4+
import {MatInputModule} from '@angular/material/input';
5+
import {MatFormFieldModule} from '@angular/material/form-field';
6+
7+
/**
8+
* @title Revert to a given value instead of null for requireSelection
9+
*/
10+
@Component({
11+
selector: 'autocomplete-revert-to-value-example',
12+
templateUrl: 'autocomplete-revert-to-value-example.html',
13+
styleUrl: 'autocomplete-revert-to-value-example.css',
14+
imports: [
15+
FormsModule,
16+
MatFormFieldModule,
17+
MatInputModule,
18+
MatAutocompleteModule,
19+
ReactiveFormsModule,
20+
],
21+
})
22+
export class AutocompleteRevertToValueExample {
23+
@ViewChild('input') input: ElementRef<HTMLInputElement>;
24+
myControl = new FormControl('');
25+
options: string[] = ['One', 'Two', 'Three', 'Four', 'Five'];
26+
filteredOptions: string[];
27+
28+
constructor() {
29+
this.filteredOptions = this.options.slice();
30+
}
31+
32+
filter(): void {
33+
const filterValue = this.input.nativeElement.value.toLowerCase();
34+
this.filteredOptions = this.options.filter(o => o.toLowerCase().includes(filterValue));
35+
}
36+
}

src/components-examples/material/autocomplete/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export {AutocompleteOverviewExample} from './autocomplete-overview/autocomplete-
66
export {AutocompletePlainInputExample} from './autocomplete-plain-input/autocomplete-plain-input-example';
77
export {AutocompleteSimpleExample} from './autocomplete-simple/autocomplete-simple-example';
88
export {AutocompleteRequireSelectionExample} from './autocomplete-require-selection/autocomplete-require-selection-example';
9+
export {AutocompleteRevertToValueExample} from './autocomplete-revert-to-value/autocomplete-revert-to-value-example';
910
export {AutocompleteHarnessExample} from './autocomplete-harness/autocomplete-harness-example';

src/dev-app/autocomplete/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ ng_project(
2020
"//src/material/dialog",
2121
"//src/material/form-field",
2222
"//src/material/input",
23+
"//src/material/select",
2324
],
2425
)
2526

src/dev-app/autocomplete/autocomplete-demo.html

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
[displayWith]="displayFn"
2121
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator"
2222
[autoActiveFirstOption]="reactiveAutoActiveFirstOption"
23-
[requireSelection]="reactiveRequireSelection">
23+
[requireSelection]="reactiveRequireSelection"
24+
[revertToValue]="reactiveRevertToValue">
2425
@for (state of reactiveStates; track state; let index = $index) {
2526
<mat-option [value]="state" [disabled]="reactiveIsStateDisabled(state.index)">
2627
<span>{{ state.name }}</span>
@@ -59,6 +60,20 @@
5960
Require Selection
6061
</mat-checkbox>
6162
</p>
63+
<p>
64+
<mat-form-field>
65+
<mat-label>Revert value to</mat-label>
66+
<mat-select [(ngModel)]="reactiveRevertToValue" [disabled]="!reactiveRequireSelection">
67+
<mat-option [value]="null">None</mat-option>
68+
@for (state of states; track state) {
69+
<mat-option [value]="state">
70+
<span>{{ state.name }}</span>
71+
<span class="demo-secondary-text"> ({{ state.code }}) </span>
72+
</mat-option>
73+
}
74+
</mat-select>
75+
</mat-form-field>
76+
</p>
6277

6378
</mat-card>
6479

@@ -76,7 +91,8 @@
7691
<mat-autocomplete #tdAuto="matAutocomplete"
7792
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator"
7893
[autoActiveFirstOption]="templateAutoActiveFirstOption"
79-
[requireSelection]="templateRequireSelection">
94+
[requireSelection]="templateRequireSelection"
95+
[revertToValue]="templateRevertToValue">
8096
@for (state of tdStates; track state) {
8197
<mat-option [value]="state.name"
8298
[disabled]="templateIsStateDisabled(state.index)">
@@ -113,6 +129,19 @@
113129
Require Selection
114130
</mat-checkbox>
115131
</p>
132+
<p>
133+
<mat-form-field>
134+
<mat-label>Revert value to</mat-label>
135+
<mat-select [(ngModel)]="templateRevertToValue" [disabled]="!templateRequireSelection">
136+
<mat-option [value]="null">None</mat-option>
137+
@for (state of states; track state) {
138+
<mat-option [value]="state.name">
139+
<span>{{ state.name }}</span>
140+
</mat-option>
141+
}
142+
</mat-select>
143+
</mat-form-field>
144+
</p>
116145
<p>
117146
<label for="template-disable-state-options">Disable States</label>
118147
<select [(ngModel)]="templateDisableStateOption" id="template-disable-state-options">

src/dev-app/autocomplete/autocomplete-demo.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {MatAutocompleteModule} from '@angular/material/autocomplete';
1313
import {MatButtonModule} from '@angular/material/button';
1414
import {MatCardModule} from '@angular/material/card';
1515
import {MatCheckboxModule} from '@angular/material/checkbox';
16+
import {MatSelectModule} from '@angular/material/select';
1617
import {ThemePalette} from '@angular/material/core';
1718
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
1819
import {MatInputModule} from '@angular/material/input';
@@ -42,6 +43,7 @@ type DisableStateOption = 'none' | 'first-middle-last' | 'all';
4243
MatCardModule,
4344
MatCheckboxModule,
4445
MatInputModule,
46+
MatSelectModule,
4547
ReactiveFormsModule,
4648
],
4749
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -68,6 +70,9 @@ export class AutocompleteDemo {
6870
reactiveRequireSelection = false;
6971
templateRequireSelection = false;
7072

73+
reactiveRevertToValue = null;
74+
templateRevertToValue = null;
75+
7176
reactiveHideSingleSelectionIndicator = false;
7277
templateHideSingleSelectionIndicator = false;
7378

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,8 +757,13 @@ export class MatAutocompleteTrigger
757757
this._element.nativeElement.value !== this._valueOnAttach
758758
) {
759759
this._clearPreviousSelectedOption(null);
760-
this._assignOptionValue(null);
761-
this._onChange(null);
760+
if (panel.revertToValue) {
761+
this._assignOptionValue(panel.revertToValue);
762+
this._onChange(panel.revertToValue);
763+
} else {
764+
this._assignOptionValue(null);
765+
this._onChange(null);
766+
}
762767
}
763768

764769
this.closePanel();

src/material/autocomplete/autocomplete.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ injection token.
7777

7878
<!-- example(autocomplete-require-selection) -->
7979

80+
### Revert to a given value instead of `null`
81+
82+
Instead of setting the autocomplete value to `null`, the `revertToValue` input can be set to
83+
provide a value to be set instead. This is useful in cases where the autocomplete should change
84+
to a previously known value or default value if nothing is selected, instead of `null`.
85+
86+
Because the value may not present in the filtered options, this does _not_ trigger the
87+
`selectionChange` event. However, for both reactive and template form controls, the value will
88+
be updated appropriately. This does mean that it is possible to set the value to something that
89+
is not present in the options list.
90+
91+
<!-- example(autocomplete-revert-to-value) -->
92+
8093
### Automatically highlighting the first option
8194

8295
If your use case requires for the first autocomplete option to be highlighted when the user opens

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2859,6 +2859,95 @@ describe('MatAutocomplete', () => {
28592859
expect(input.value).toBe('');
28602860
expect(stateCtrl.value).toBe(null);
28612861
}));
2862+
2863+
it('should revert to the provided value if requireSelection is enabled and revertToValue is provided', waitForAsync(async () => {
2864+
const input = fixture.nativeElement.querySelector('input');
2865+
const {stateCtrl, trigger} = fixture.componentInstance;
2866+
fixture.componentInstance.requireSelection = true;
2867+
fixture.componentInstance.revertToValue = {code: 'DE', name: 'Delaware'};
2868+
fixture.changeDetectorRef.markForCheck();
2869+
fixture.detectChanges();
2870+
await new Promise(r => setTimeout(r));
2871+
2872+
// Simulate opening the input and clicking the first option.
2873+
trigger.openPanel();
2874+
fixture.detectChanges();
2875+
await new Promise(r => setTimeout(r));
2876+
(overlayContainerElement.querySelector('mat-option') as HTMLElement).click();
2877+
await new Promise(r => setTimeout(r));
2878+
fixture.detectChanges();
2879+
2880+
expect(trigger.panelOpen).toBe(false);
2881+
expect(input.value).toBe('Alabama');
2882+
expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
2883+
2884+
// Simulate pressing backspace while focus is still on the input.
2885+
dispatchFakeEvent(input, 'keydown');
2886+
input.value = 'Alabam';
2887+
fixture.detectChanges();
2888+
dispatchFakeEvent(input, 'input');
2889+
fixture.detectChanges();
2890+
await new Promise(r => setTimeout(r));
2891+
2892+
expect(trigger.panelOpen).toBe(true);
2893+
expect(input.value).toBe('Alabam');
2894+
expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
2895+
2896+
// Simulate clicking away.
2897+
input.blur();
2898+
dispatchFakeEvent(document, 'click');
2899+
fixture.detectChanges();
2900+
await new Promise(r => setTimeout(r));
2901+
2902+
expect(trigger.panelOpen).toBe(false);
2903+
expect(input.value).toBe('Delaware');
2904+
expect(stateCtrl.value).toEqual({code: 'DE', name: 'Delaware'});
2905+
}));
2906+
2907+
it('should keep the input value if requireSelection is disabled and revertToValue is provided', waitForAsync(async () => {
2908+
const input = fixture.nativeElement.querySelector('input');
2909+
const {stateCtrl, trigger} = fixture.componentInstance;
2910+
fixture.componentInstance.requireSelection = false;
2911+
fixture.componentInstance.revertToValue = {code: 'DE', name: 'Delaware'};
2912+
fixture.changeDetectorRef.markForCheck();
2913+
fixture.detectChanges();
2914+
await new Promise(r => setTimeout(r));
2915+
2916+
// Simulate opening the input and clicking the first option.
2917+
trigger.openPanel();
2918+
fixture.detectChanges();
2919+
await new Promise(r => setTimeout(r));
2920+
(overlayContainerElement.querySelector('mat-option') as HTMLElement).click();
2921+
await new Promise(r => setTimeout(r));
2922+
fixture.detectChanges();
2923+
2924+
expect(trigger.panelOpen).toBe(false);
2925+
expect(input.value).toBe('Alabama');
2926+
expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
2927+
2928+
// Simulate pressing backspace while focus is still on the input.
2929+
dispatchFakeEvent(input, 'keydown');
2930+
input.value = 'Alabam';
2931+
fixture.detectChanges();
2932+
dispatchFakeEvent(input, 'input');
2933+
fixture.detectChanges();
2934+
await new Promise(r => setTimeout(r));
2935+
2936+
expect(trigger.panelOpen).toBe(true);
2937+
expect(input.value).toBe('Alabam');
2938+
expect(stateCtrl.value).toEqual('Alabam');
2939+
2940+
// Simulate clicking away.
2941+
input.blur();
2942+
dispatchFakeEvent(document, 'click');
2943+
fixture.detectChanges();
2944+
2945+
await new Promise(r => setTimeout(r));
2946+
2947+
expect(trigger.panelOpen).toBe(false);
2948+
expect(input.value).toBe('Alabam');
2949+
expect(stateCtrl.value).toEqual('Alabam');
2950+
}));
28622951
});
28632952

28642953
describe('panel closing', () => {
@@ -3997,6 +4086,7 @@ const SIMPLE_AUTOCOMPLETE_TEMPLATE = `
39974086
[displayWith]="displayFn"
39984087
[disableRipple]="disableRipple"
39994088
[requireSelection]="requireSelection"
4089+
[revertToValue]="revertToValue"
40004090
[aria-label]="ariaLabel"
40014091
[aria-labelledby]="ariaLabelledby"
40024092
(opened)="openedSpy()"
@@ -4033,6 +4123,7 @@ class SimpleAutocomplete implements OnDestroy {
40334123
autocompleteDisabled = false;
40344124
hasLabel = true;
40354125
requireSelection = false;
4126+
revertToValue: {code: string; name: string; height?: number; disabled?: boolean} | null = null;
40364127
ariaLabel: string;
40374128
ariaLabelledby: string;
40384129
panelClass = 'class-one class-two';

0 commit comments

Comments
 (0)