Skip to content

Commit

Permalink
feat(material/autocomplete): add input to require selection from the …
Browse files Browse the repository at this point in the history
…panel (#27423)

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.
  • Loading branch information
crisbeto authored Jul 11, 2023
1 parent ff2a3cc commit af1a041
Show file tree
Hide file tree
Showing 14 changed files with 369 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.example-form {
min-width: 150px;
max-width: 500px;
width: 100%;
}

.example-full-width {
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<form class="example-form">
<mat-form-field class="example-full-width">
<mat-label>Number</mat-label>
<input type="text"
placeholder="Pick one"
aria-label="Number"
matInput
[formControl]="myControl"
[matAutocomplete]="auto">
<mat-autocomplete requireSelection #auto="matAutocomplete">
<mat-option *ngFor="let option of filteredOptions | async" [value]="option">
{{option}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>

Control value: {{myControl.value}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {Component, OnInit} from '@angular/core';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {Observable} from 'rxjs';
import {map, startWith} from 'rxjs/operators';
import {NgFor, AsyncPipe} from '@angular/common';
import {MatAutocompleteModule} from '@angular/material/autocomplete';
import {MatInputModule} from '@angular/material/input';
import {MatFormFieldModule} from '@angular/material/form-field';

/**
* @title Require an autocomplete option to be selected.
*/
@Component({
selector: 'autocomplete-require-selection-example',
templateUrl: 'autocomplete-require-selection-example.html',
styleUrls: ['autocomplete-require-selection-example.css'],
standalone: true,
imports: [
FormsModule,
MatFormFieldModule,
MatInputModule,
MatAutocompleteModule,
ReactiveFormsModule,
NgFor,
AsyncPipe,
],
})
export class AutocompleteRequireSelectionExample implements OnInit {
myControl = new FormControl('');
options: string[] = ['One', 'Two', 'Three', 'Three', 'Four'];
filteredOptions: Observable<string[]>;

ngOnInit() {
this.filteredOptions = this.myControl.valueChanges.pipe(
startWith(''),
map(value => this._filter(value || '')),
);
}

private _filter(value: string): string[] {
const filterValue = value.toLowerCase();

return this.options.filter(option => option.toLowerCase().includes(filterValue));
}
}
1 change: 1 addition & 0 deletions src/components-examples/material/autocomplete/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export {AutocompleteOptgroupExample} from './autocomplete-optgroup/autocomplete-
export {AutocompleteOverviewExample} from './autocomplete-overview/autocomplete-overview-example';
export {AutocompletePlainInputExample} from './autocomplete-plain-input/autocomplete-plain-input-example';
export {AutocompleteSimpleExample} from './autocomplete-simple/autocomplete-simple-example';
export {AutocompleteRequireSelectionExample} from './autocomplete-require-selection/autocomplete-require-selection-example';
export {AutocompleteHarnessExample} from './autocomplete-harness/autocomplete-harness-example';
19 changes: 16 additions & 3 deletions src/dev-app/autocomplete/autocomplete-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
<mat-label>State</mat-label>
<input matInput [matAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
</mat-form-field>
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn"
<mat-autocomplete #reactiveAuto="matAutocomplete"
[displayWith]="displayFn"
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator"
[autoActiveFirstOption]="reactiveAutoActiveFirstOption">
[autoActiveFirstOption]="reactiveAutoActiveFirstOption"
[requireSelection]="reactiveRequireSelection">
<mat-option *ngFor="let state of tempStates; let index = index" [value]="state"
[disabled]="reactiveIsStateDisabled(state.index)">
<span>{{ state.name }}</span>
Expand Down Expand Up @@ -45,6 +47,11 @@
Automatically activate first option
</mat-checkbox>
</p>
<p>
<mat-checkbox [(ngModel)]="reactiveRequireSelection">
Require Selection
</mat-checkbox>
</p>

</mat-card>

Expand All @@ -60,7 +67,8 @@
(ngModelChange)="tdStates = filterStates(currentState)" [disabled]="tdDisabled">
<mat-autocomplete #tdAuto="matAutocomplete"
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator"
[autoActiveFirstOption]="templateAutoActiveFirstOption">
[autoActiveFirstOption]="templateAutoActiveFirstOption"
[requireSelection]="templateRequireSelection">
<mat-option *ngFor="let state of tdStates" [value]="state.name"
[disabled]="templateIsStateDisabled(state.index)">
<span>{{ state.name }}</span>
Expand Down Expand Up @@ -89,6 +97,11 @@
Automatically activate first option
</mat-checkbox>
</p>
<p>
<mat-checkbox [(ngModel)]="templateRequireSelection">
Require Selection
</mat-checkbox>
</p>
<p>
<label for="template-disable-state-options">Disable States</label>
<select [(ngModel)]="templateDisableStateOption" id="template-disable-state-options">
Expand Down
4 changes: 3 additions & 1 deletion src/dev-app/autocomplete/autocomplete-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ export class AutocompleteDemo {
tdStates: State[];

tdDisabled = false;
hideSingleSelectionIndicators = false;
reactiveStatesTheme: ThemePalette = 'primary';
templateStatesTheme: ThemePalette = 'primary';

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

reactiveRequireSelection = false;
templateRequireSelection = false;

reactiveHideSingleSelectionIndicator = false;
templateHideSingleSelectionIndicator = false;

Expand Down
46 changes: 36 additions & 10 deletions src/material/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ export abstract class _MatAutocompleteTriggerBase
/** Old value of the native input. Used to work around issues with the `input` event on IE. */
private _previousValue: string | number | null;

/** Value of the input element when the panel was opened. */
private _valueOnOpen: string | number | null;

/** Strategy that is used to position the panel. */
private _positionStrategy: FlexibleConnectedPositionStrategy;

Expand Down Expand Up @@ -565,7 +568,7 @@ export abstract class _MatAutocompleteTriggerBase
// of the available options,
// - if a valid string is entered after an invalid one.
if (this.panelOpen) {
this.autocomplete.opened.emit();
this._emitOpened();
} else {
this.autocomplete.closed.emit();
}
Expand All @@ -582,6 +585,15 @@ export abstract class _MatAutocompleteTriggerBase
);
}

/**
* Emits the opened event once it's known that the panel will be shown and stores
* the state of the trigger right before the opening sequence was finished.
*/
private _emitOpened() {
this._valueOnOpen = this._element.nativeElement.value;
this.autocomplete.opened.emit();
}

/** Destroys the autocomplete suggestion panel. */
private _destroyPanel(): void {
if (this._overlayRef) {
Expand Down Expand Up @@ -620,14 +632,28 @@ export abstract class _MatAutocompleteTriggerBase
* stemmed from the user.
*/
private _setValueAndClose(event: MatOptionSelectionChange | null): void {
const panel = this.autocomplete;
const toSelect = event ? event.source : this._pendingAutoselectedOption;

if (toSelect) {
this._clearPreviousSelectedOption(toSelect);
this._assignOptionValue(toSelect.value);
// TODO(crisbeto): this should wait until the animation is done, otherwise the value
// gets reset while the panel is still animating which looks glitchy. It'll likely break
// some tests to change it at this point.
this._onChange(toSelect.value);
this.autocomplete._emitSelectEvent(toSelect);
panel._emitSelectEvent(toSelect);
this._element.nativeElement.focus();
} else if (panel.requireSelection && this._element.nativeElement.value !== this._valueOnOpen) {
this._clearPreviousSelectedOption(null);
this._assignOptionValue(null);
// Wait for the animation to finish before clearing the form control value, otherwise
// the options might change while the animation is running which looks glitchy.
if (panel._animationDone) {
panel._animationDone.pipe(take(1)).subscribe(() => this._onChange(null));
} else {
this._onChange(null);
}
}

this.closePanel();
Expand All @@ -637,13 +663,13 @@ export abstract class _MatAutocompleteTriggerBase
* Clear any previous selected option and emit a selection change event for this option
*/
private _clearPreviousSelectedOption(skip: _MatOptionBase | null, emitEvent?: boolean) {
if (this.autocomplete && this.autocomplete.options) {
this.autocomplete.options.forEach(option => {
if (option !== skip && option.selected) {
option.deselect(emitEvent);
}
});
}
// Null checks are necessary here, because the autocomplete
// or its options may not have been assigned yet.
this.autocomplete?.options?.forEach(option => {
if (option !== skip && option.selected) {
option.deselect(emitEvent);
}
});
}

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

Expand Down
1 change: 1 addition & 0 deletions src/material/autocomplete/autocomplete.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
[attr.aria-label]="ariaLabel || null"
[attr.aria-labelledby]="_getPanelAriaLabelledby(formFieldId)"
[@panelAnimation]="isOpen ? 'visible' : 'hidden'"
(@panelAnimation.done)="_animationDone.next($event)"
#panel>
<ng-content></ng-content>
</div>
Expand Down
28 changes: 22 additions & 6 deletions src/material/autocomplete/autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ defined by a `mat-option` tag. Set each option's value property to whatever you'
of the text input to be when that option is selected.

<!-- example({"example":"autocomplete-simple",
"file":"autocomplete-simple-example.html",
"file":"autocomplete-simple-example.html",
"region":"mat-autocomplete"}) -->

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

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

<!-- example({"example":"autocomplete-simple",
"file":"autocomplete-simple-example.html",
"file":"autocomplete-simple-example.html",
"region":"input"}) -->

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

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

### Require an option to be selected

By default, the autocomplete will accept the value that the user typed into the input field.
Instead, if you want to instead ensure that an option from the autocomplete was selected, you can
enable the `requireSelection` input on `mat-autocomplete`. This will change the behavior of
the autocomplete in the following ways:
1. If the user opens the autocomplete, changes its value, but doesn't select anything, the
autocomplete value will be reset back to `null`.
2. If the user opens and closes the autocomplete without changing the value, the old value will
be preserved.

This behavior can be configured globally using the `MAT_AUTOCOMPLETE_DEFAULT_OPTIONS`
injection token.

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

### Automatically highlighting the first option

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

### Accessibility
Expand All @@ -132,4 +148,4 @@ navigation though the autocomplete options.

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

0 comments on commit af1a041

Please sign in to comment.