Skip to content

Commit a4403f7

Browse files
committed
feat(material/autocomplete): add input to require selection from the panel
Adds the `requireSelection` input to the autocomplete, which when enabled will clear the input value if the user doesn't select an option from the list. Fixes #3334.
1 parent 7c8a796 commit a4403f7

File tree

14 files changed

+369
-26
lines changed

14 files changed

+369
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.example-form {
2+
min-width: 150px;
3+
max-width: 500px;
4+
width: 100%;
5+
}
6+
7+
.example-full-width {
8+
width: 100%;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<form class="example-form">
2+
<mat-form-field class="example-full-width">
3+
<mat-label>Number</mat-label>
4+
<input type="text"
5+
placeholder="Pick one"
6+
aria-label="Number"
7+
matInput
8+
[formControl]="myControl"
9+
[matAutocomplete]="auto">
10+
<mat-autocomplete requireSelection #auto="matAutocomplete">
11+
<mat-option *ngFor="let option of filteredOptions | async" [value]="option">
12+
{{option}}
13+
</mat-option>
14+
</mat-autocomplete>
15+
</mat-form-field>
16+
</form>
17+
18+
Control value: {{myControl.value}}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {Component, OnInit} from '@angular/core';
2+
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
3+
import {Observable} from 'rxjs';
4+
import {map, startWith} from 'rxjs/operators';
5+
import {NgFor, AsyncPipe} from '@angular/common';
6+
import {MatAutocompleteModule} from '@angular/material/autocomplete';
7+
import {MatInputModule} from '@angular/material/input';
8+
import {MatFormFieldModule} from '@angular/material/form-field';
9+
10+
/**
11+
* @title Require an autocomplete option to be selected.
12+
*/
13+
@Component({
14+
selector: 'autocomplete-require-selection-example',
15+
templateUrl: 'autocomplete-require-selection-example.html',
16+
styleUrls: ['autocomplete-require-selection-example.css'],
17+
standalone: true,
18+
imports: [
19+
FormsModule,
20+
MatFormFieldModule,
21+
MatInputModule,
22+
MatAutocompleteModule,
23+
ReactiveFormsModule,
24+
NgFor,
25+
AsyncPipe,
26+
],
27+
})
28+
export class AutocompleteRequireSelectionExample implements OnInit {
29+
myControl = new FormControl('');
30+
options: string[] = ['One', 'Two', 'Three', 'Three', 'Four'];
31+
filteredOptions: Observable<string[]>;
32+
33+
ngOnInit() {
34+
this.filteredOptions = this.myControl.valueChanges.pipe(
35+
startWith(''),
36+
map(value => this._filter(value || '')),
37+
);
38+
}
39+
40+
private _filter(value: string): string[] {
41+
const filterValue = value.toLowerCase();
42+
43+
return this.options.filter(option => option.toLowerCase().includes(filterValue));
44+
}
45+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ export {AutocompleteOptgroupExample} from './autocomplete-optgroup/autocomplete-
55
export {AutocompleteOverviewExample} from './autocomplete-overview/autocomplete-overview-example';
66
export {AutocompletePlainInputExample} from './autocomplete-plain-input/autocomplete-plain-input-example';
77
export {AutocompleteSimpleExample} from './autocomplete-simple/autocomplete-simple-example';
8+
export {AutocompleteRequireSelectionExample} from './autocomplete-require-selection/autocomplete-require-selection-example';
89
export {AutocompleteHarnessExample} from './autocomplete-harness/autocomplete-harness-example';

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
<mat-label>State</mat-label>
1111
<input matInput [matAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
1212
</mat-form-field>
13-
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn"
13+
<mat-autocomplete #reactiveAuto="matAutocomplete"
14+
[displayWith]="displayFn"
1415
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator"
15-
[autoActiveFirstOption]="reactiveAutoActiveFirstOption">
16+
[autoActiveFirstOption]="reactiveAutoActiveFirstOption"
17+
[requireSelection]="reactiveRequireSelection">
1618
<mat-option *ngFor="let state of tempStates; let index = index" [value]="state"
1719
[disabled]="reactiveIsStateDisabled(state.index)">
1820
<span>{{ state.name }}</span>
@@ -45,6 +47,11 @@
4547
Automatically activate first option
4648
</mat-checkbox>
4749
</p>
50+
<p>
51+
<mat-checkbox [(ngModel)]="reactiveRequireSelection">
52+
Require Selection
53+
</mat-checkbox>
54+
</p>
4855

4956
</mat-card>
5057

@@ -60,7 +67,8 @@
6067
(ngModelChange)="tdStates = filterStates(currentState)" [disabled]="tdDisabled">
6168
<mat-autocomplete #tdAuto="matAutocomplete"
6269
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator"
63-
[autoActiveFirstOption]="templateAutoActiveFirstOption">
70+
[autoActiveFirstOption]="templateAutoActiveFirstOption"
71+
[requireSelection]="templateRequireSelection">
6472
<mat-option *ngFor="let state of tdStates" [value]="state.name"
6573
[disabled]="templateIsStateDisabled(state.index)">
6674
<span>{{ state.name }}</span>
@@ -89,6 +97,11 @@
8997
Automatically activate first option
9098
</mat-checkbox>
9199
</p>
100+
<p>
101+
<mat-checkbox [(ngModel)]="templateRequireSelection">
102+
Require Selection
103+
</mat-checkbox>
104+
</p>
92105
<p>
93106
<label for="template-disable-state-options">Disable States</label>
94107
<select [(ngModel)]="templateDisableStateOption" id="template-disable-state-options">

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export class AutocompleteDemo {
5959
tdStates: State[];
6060

6161
tdDisabled = false;
62-
hideSingleSelectionIndicators = false;
6362
reactiveStatesTheme: ThemePalette = 'primary';
6463
templateStatesTheme: ThemePalette = 'primary';
6564

@@ -69,6 +68,9 @@ export class AutocompleteDemo {
6968
{value: 'warn', name: 'Warn'},
7069
];
7170

71+
reactiveRequireSelection = false;
72+
templateRequireSelection = false;
73+
7274
reactiveHideSingleSelectionIndicator = false;
7375
templateHideSingleSelectionIndicator = false;
7476

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ export abstract class _MatAutocompleteTriggerBase
110110
/** Old value of the native input. Used to work around issues with the `input` event on IE. */
111111
private _previousValue: string | number | null;
112112

113+
/** Value of the input element when the panel was opened. */
114+
private _valueOnOpen: string | number | null;
115+
113116
/** Strategy that is used to position the panel. */
114117
private _positionStrategy: FlexibleConnectedPositionStrategy;
115118

@@ -561,7 +564,7 @@ export abstract class _MatAutocompleteTriggerBase
561564
// of the available options,
562565
// - if a valid string is entered after an invalid one.
563566
if (this.panelOpen) {
564-
this.autocomplete.opened.emit();
567+
this._emitOpened();
565568
} else {
566569
this.autocomplete.closed.emit();
567570
}
@@ -578,6 +581,15 @@ export abstract class _MatAutocompleteTriggerBase
578581
);
579582
}
580583

584+
/**
585+
* Emits the opened event once it's known that the panel will be shown and stores
586+
* the state of the trigger right before the opening sequence was finished.
587+
*/
588+
private _emitOpened() {
589+
this._valueOnOpen = this._element.nativeElement.value;
590+
this.autocomplete.opened.emit();
591+
}
592+
581593
/** Destroys the autocomplete suggestion panel. */
582594
private _destroyPanel(): void {
583595
if (this._overlayRef) {
@@ -616,14 +628,28 @@ export abstract class _MatAutocompleteTriggerBase
616628
* stemmed from the user.
617629
*/
618630
private _setValueAndClose(event: MatOptionSelectionChange | null): void {
631+
const panel = this.autocomplete;
619632
const toSelect = event ? event.source : this._pendingAutoselectedOption;
620633

621634
if (toSelect) {
622635
this._clearPreviousSelectedOption(toSelect);
623636
this._assignOptionValue(toSelect.value);
637+
// TODO(crisbeto): this should wait until the animation is done, otherwise the value
638+
// gets reset while the panel is still animating which looks glitchy. It'll likely break
639+
// some tests to change it at this point.
624640
this._onChange(toSelect.value);
625-
this.autocomplete._emitSelectEvent(toSelect);
641+
panel._emitSelectEvent(toSelect);
626642
this._element.nativeElement.focus();
643+
} else if (panel.requireSelection && this._element.nativeElement.value !== this._valueOnOpen) {
644+
this._clearPreviousSelectedOption(null);
645+
this._assignOptionValue(null);
646+
// Wait for the animation to finish before clearing the form control value, otherwise
647+
// the options might change while the animation is running which looks glitchy.
648+
if (panel._animationDone) {
649+
panel._animationDone.pipe(take(1)).subscribe(() => this._onChange(null));
650+
} else {
651+
this._onChange(null);
652+
}
627653
}
628654

629655
this.closePanel();
@@ -633,13 +659,13 @@ export abstract class _MatAutocompleteTriggerBase
633659
* Clear any previous selected option and emit a selection change event for this option
634660
*/
635661
private _clearPreviousSelectedOption(skip: _MatOptionBase | null, emitEvent?: boolean) {
636-
if (this.autocomplete && this.autocomplete.options) {
637-
this.autocomplete.options.forEach(option => {
638-
if (option !== skip && option.selected) {
639-
option.deselect(emitEvent);
640-
}
641-
});
642-
}
662+
// Null checks are necessary here, because the autocomplete
663+
// or its options may not have been assigned yet.
664+
this.autocomplete?.options?.forEach(option => {
665+
if (option !== skip && option.selected) {
666+
option.deselect(emitEvent);
667+
}
668+
});
643669
}
644670

645671
private _attachOverlay(): void {
@@ -683,7 +709,7 @@ export abstract class _MatAutocompleteTriggerBase
683709
// We need to do an extra `panelOpen` check in here, because the
684710
// autocomplete won't be shown if there are no options.
685711
if (this.panelOpen && wasOpen !== this.panelOpen) {
686-
this.autocomplete.opened.emit();
712+
this._emitOpened();
687713
}
688714
}
689715

src/material/autocomplete/autocomplete.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
[attr.aria-label]="ariaLabel || null"
88
[attr.aria-labelledby]="_getPanelAriaLabelledby(formFieldId)"
99
[@panelAnimation]="isOpen ? 'visible' : 'hidden'"
10+
(@panelAnimation.done)="_animationDone.next($event)"
1011
#panel>
1112
<ng-content></ng-content>
1213
</div>

src/material/autocomplete/autocomplete.md

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ defined by a `mat-option` tag. Set each option's value property to whatever you'
77
of the text input to be when that option is selected.
88

99
<!-- example({"example":"autocomplete-simple",
10-
"file":"autocomplete-simple-example.html",
10+
"file":"autocomplete-simple-example.html",
1111
"region":"mat-autocomplete"}) -->
1212

13-
Next, create the input and set the `matAutocomplete` input to refer to the template reference we assigned
14-
to the autocomplete. Let's assume you're using the `formControl` directive from `ReactiveFormsModule` to
13+
Next, create the input and set the `matAutocomplete` input to refer to the template reference we assigned
14+
to the autocomplete. Let's assume you're using the `formControl` directive from `ReactiveFormsModule` to
1515
track the value of the input.
1616

1717
> Note: It is possible to use template-driven forms instead, if you prefer. We use reactive forms
@@ -25,7 +25,7 @@ panel instance into a local template variable (here we called it "auto"), and bi
2525
to the input's `matAutocomplete` property.
2626

2727
<!-- example({"example":"autocomplete-simple",
28-
"file":"autocomplete-simple-example.html",
28+
"file":"autocomplete-simple-example.html",
2929
"region":"input"}) -->
3030

3131
### Adding a custom filter
@@ -61,6 +61,22 @@ desired display value. Then bind it to the autocomplete's `displayWith` property
6161

6262
<!-- example(autocomplete-display) -->
6363

64+
### Require an option to be selected
65+
66+
By default, the autocomplete will accept the value that the user typed into the input field.
67+
Instead, if you want to instead ensure that an option from the autocomplete was selected, you can
68+
enable the `requireSelection` input on `mat-autocomplete`. This will change the behavior of
69+
the autocomplete in the following ways:
70+
1. If the user opens the autocomplete, changes its value, but doesn't select anything, the
71+
autocomplete value will be reset back to `null`.
72+
2. If the user opens and closes the autocomplete without changing the value, the old value will
73+
be preserved.
74+
75+
This behavior can be configured globally using the `MAT_AUTOCOMPLETE_DEFAULT_OPTIONS`
76+
injection token.
77+
78+
<!-- example(autocomplete-require-selection) -->
79+
6480
### Automatically highlighting the first option
6581

6682
If your use case requires for the first autocomplete option to be highlighted when the user opens
@@ -112,7 +128,7 @@ autocomplete is attached to using the `matAutocompleteOrigin` directive together
112128
### Option groups
113129
`mat-option` can be collected into groups using the `mat-optgroup` element:
114130
<!-- example({"example":"autocomplete-optgroup",
115-
"file":"autocomplete-optgroup-example.html",
131+
"file":"autocomplete-optgroup-example.html",
116132
"region":"mat-autocomplete"}) -->
117133

118134
### Accessibility
@@ -132,4 +148,4 @@ navigation though the autocomplete options.
132148

133149
By default, `MatAutocomplete` displays a checkmark to identify the selected item. While you can hide
134150
the checkmark indicator via `hideSingleSelectionIndicator`, this makes the component less accessible
135-
by making it harder or impossible for users to visually identify selected items.
151+
by making it harder or impossible for users to visually identify selected items.

0 commit comments

Comments
 (0)