Skip to content

Commit 3968de0

Browse files
committed
feat(material/autocomplete): add MatAutocompleteSelectedTrigger for custom selected-value display
Add an `ng-template[matAutocompleteSelectedTrigger]` directive that let consumers render arbitrary HTML in the trigger area after an option is selected, analogous to `mat-select-trigger` for `mat-select`. Since autocomplete writes to a native `<input>`, the implementation creates an Angular embedded view and inserts a wrapper `<div>` as a DOM sibling, hiding the input text via inline `color: transparent`. Clearing occurs on focus/click; the trigger is restored on blur when the input still contains the selected value's display text. close #32931 Signed-off-by: Ruslan Lekhman <lekhman112@gmail.com>
1 parent 0f5b0ee commit 3968de0

File tree

13 files changed

+556
-7
lines changed

13 files changed

+556
-7
lines changed

goldens/material/autocomplete/index.api.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export const MAT_AUTOCOMPLETE_DEFAULT_OPTIONS: InjectionToken<MatAutocompleteDef
3838
// @public
3939
export const MAT_AUTOCOMPLETE_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>;
4040

41+
// @public
42+
export const MAT_AUTOCOMPLETE_SELECTED_TRIGGER: InjectionToken<MatAutocompleteSelectedTrigger>;
43+
4144
// @public
4245
export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any;
4346

@@ -55,6 +58,7 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy {
5558
_classList: string | string[];
5659
readonly closed: EventEmitter<void>;
5760
protected _color: ThemePalette;
61+
customTrigger: MatAutocompleteSelectedTrigger | undefined;
5862
// (undocumented)
5963
protected _defaults: MatAutocompleteDefaultOptions;
6064
disableRipple: boolean;
@@ -102,7 +106,7 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy {
102106
_syncParentProperties(): void;
103107
template: TemplateRef<any>;
104108
// (undocumented)
105-
static ɵcmp: i0.ɵɵComponentDeclaration<MatAutocomplete, "mat-autocomplete", ["matAutocomplete"], { "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "displayWith": { "alias": "displayWith"; "required": false; }; "autoActiveFirstOption": { "alias": "autoActiveFirstOption"; "required": false; }; "autoSelectActiveOption": { "alias": "autoSelectActiveOption"; "required": false; }; "requireSelection": { "alias": "requireSelection"; "required": false; }; "panelWidth": { "alias": "panelWidth"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "classList": { "alias": "class"; "required": false; }; "hideSingleSelectionIndicator": { "alias": "hideSingleSelectionIndicator"; "required": false; }; }, { "optionSelected": "optionSelected"; "opened": "opened"; "closed": "closed"; "optionActivated": "optionActivated"; }, ["options", "optionGroups"], ["*"], true, never>;
109+
static ɵcmp: i0.ɵɵComponentDeclaration<MatAutocomplete, "mat-autocomplete", ["matAutocomplete"], { "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "displayWith": { "alias": "displayWith"; "required": false; }; "autoActiveFirstOption": { "alias": "autoActiveFirstOption"; "required": false; }; "autoSelectActiveOption": { "alias": "autoSelectActiveOption"; "required": false; }; "requireSelection": { "alias": "requireSelection"; "required": false; }; "panelWidth": { "alias": "panelWidth"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "classList": { "alias": "class"; "required": false; }; "hideSingleSelectionIndicator": { "alias": "hideSingleSelectionIndicator"; "required": false; }; }, { "optionSelected": "optionSelected"; "opened": "opened"; "closed": "closed"; "optionActivated": "optionActivated"; }, ["customTrigger", "options", "optionGroups"], ["*"], true, never>;
106110
// (undocumented)
107111
static ɵfac: i0.ɵɵFactoryDeclaration<MatAutocomplete, never>;
108112
}
@@ -131,7 +135,7 @@ export class MatAutocompleteModule {
131135
// (undocumented)
132136
static ɵinj: i0.ɵɵInjectorDeclaration<MatAutocompleteModule>;
133137
// (undocumented)
134-
static ɵmod: i0.ɵɵNgModuleDeclaration<MatAutocompleteModule, never, [typeof i2.OverlayModule, typeof MatOptionModule, typeof MatAutocomplete, typeof MatAutocompleteTrigger, typeof MatAutocompleteOrigin], [typeof i1.CdkScrollableModule, typeof MatAutocomplete, typeof MatOptionModule, typeof i2$1.BidiModule, typeof MatAutocompleteTrigger, typeof MatAutocompleteOrigin]>;
138+
static ɵmod: i0.ɵɵNgModuleDeclaration<MatAutocompleteModule, never, [typeof i2.OverlayModule, typeof MatOptionModule, typeof MatAutocomplete, typeof MatAutocompleteTrigger, typeof MatAutocompleteOrigin, typeof MatAutocompleteSelectedTrigger], [typeof i1.CdkScrollableModule, typeof MatAutocomplete, typeof MatOptionModule, typeof i2$1.BidiModule, typeof MatAutocompleteTrigger, typeof MatAutocompleteOrigin, typeof MatAutocompleteSelectedTrigger]>;
135139
}
136140

137141
// @public
@@ -154,6 +158,16 @@ export class MatAutocompleteSelectedEvent {
154158
source: MatAutocomplete;
155159
}
156160

161+
// @public
162+
export class MatAutocompleteSelectedTrigger {
163+
// (undocumented)
164+
readonly templateRef: TemplateRef<any>;
165+
// (undocumented)
166+
static ɵdir: i0.ɵɵDirectiveDeclaration<MatAutocompleteSelectedTrigger, "ng-template[matAutocompleteSelectedTrigger]", never, {}, {}, never, never, true, never>;
167+
// (undocumented)
168+
static ɵfac: i0.ɵɵFactoryDeclaration<MatAutocompleteSelectedTrigger, never>;
169+
}
170+
157171
// @public
158172
export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {
159173
constructor(...args: unknown[]);
@@ -164,6 +178,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewIn
164178
closePanel(): void;
165179
connectedTo: MatAutocompleteOrigin;
166180
// (undocumented)
181+
_handleBlur(): void;
182+
// (undocumented)
167183
_handleClick(): void;
168184
// (undocumented)
169185
_handleFocus(): void;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.example-form {
2+
min-width: 150px;
3+
max-width: 500px;
4+
width: 100%;
5+
}
6+
7+
.example-full-width {
8+
width: 100%;
9+
}
10+
11+
.example-option-img {
12+
vertical-align: middle;
13+
margin-right: 8px;
14+
}
15+
16+
.example-trigger-flag {
17+
vertical-align: middle;
18+
margin-right: 4px;
19+
}
20+
21+
.mat-autocomplete-selected-trigger {
22+
top: 15px;
23+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<form class="example-form">
2+
<mat-form-field class="example-full-width">
3+
<mat-label>State</mat-label>
4+
<input
5+
matInput
6+
aria-label="State"
7+
[matAutocomplete]="auto"
8+
[formControl]="stateCtrl" />
9+
<mat-autocomplete #auto="matAutocomplete" [displayWith]="displayFn">
10+
<!--
11+
matAutocompleteSelectedTrigger replaces the plain text with a custom HTML template
12+
after an option is selected. The let-state binding exposes the raw selected value.
13+
Click the field or start typing to clear the display and re-open the panel.
14+
-->
15+
<ng-template matAutocompleteSelectedTrigger let-state>
16+
<img class="example-trigger-flag" [src]="state?.flag" [alt]="state?.name" height="20" />
17+
{{ state?.name }}
18+
</ng-template>
19+
@for (state of filteredStates | async; track state.name) {
20+
<mat-option [value]="state">
21+
<img alt="" class="example-option-img" [src]="state.flag" height="25" />
22+
<span>{{ state.name }}</span> |
23+
<small>Population: {{ state.population }}</small>
24+
</mat-option>
25+
}
26+
</mat-autocomplete>
27+
</mat-form-field>
28+
29+
<br />
30+
31+
<mat-slide-toggle
32+
[checked]="stateCtrl.disabled"
33+
(change)="stateCtrl.disabled ? stateCtrl.enable() : stateCtrl.disable()">
34+
Disable Input?
35+
</mat-slide-toggle>
36+
</form>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {AsyncPipe} from '@angular/common';
10+
import {Component} from '@angular/core';
11+
import {FormControl, ReactiveFormsModule} from '@angular/forms';
12+
import {MatAutocompleteModule} from '@angular/material/autocomplete';
13+
import {MatFormFieldModule} from '@angular/material/form-field';
14+
import {MatInputModule} from '@angular/material/input';
15+
import {MatSlideToggleModule} from '@angular/material/slide-toggle';
16+
import {map, startWith} from 'rxjs/operators';
17+
18+
export interface State {
19+
flag: string;
20+
name: string;
21+
population: string;
22+
}
23+
24+
/** @title Autocomplete with custom selected-value template */
25+
@Component({
26+
selector: 'autocomplete-custom-trigger-example',
27+
templateUrl: 'autocomplete-custom-trigger-example.html',
28+
styleUrl: 'autocomplete-custom-trigger-example.css',
29+
imports: [
30+
AsyncPipe,
31+
MatAutocompleteModule,
32+
MatFormFieldModule,
33+
MatInputModule,
34+
MatSlideToggleModule,
35+
ReactiveFormsModule,
36+
],
37+
})
38+
export class AutocompleteCustomTriggerExample {
39+
stateCtrl = new FormControl<State | string | null>(null);
40+
filteredStates = this.stateCtrl.valueChanges.pipe(
41+
startWith(null),
42+
map(value => {
43+
const name = typeof value === 'string' ? value : (value?.name ?? '');
44+
return name ? this._filterStates(name) : this.states.slice();
45+
}),
46+
);
47+
48+
states: State[] = [
49+
{
50+
name: 'Arkansas',
51+
population: '2.978M',
52+
// https://commons.wikimedia.org/wiki/File:Flag_of_Arkansas.svg
53+
flag: 'https://upload.wikimedia.org/wikipedia/commons/9/9d/Flag_of_Arkansas.svg',
54+
},
55+
{
56+
name: 'California',
57+
population: '39.14M',
58+
// https://commons.wikimedia.org/wiki/File:Flag_of_California.svg
59+
flag: 'https://upload.wikimedia.org/wikipedia/commons/0/01/Flag_of_California.svg',
60+
},
61+
{
62+
name: 'Florida',
63+
population: '20.27M',
64+
// https://commons.wikimedia.org/wiki/File:Flag_of_Florida.svg
65+
flag: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Flag_of_Florida.svg',
66+
},
67+
{
68+
name: 'Texas',
69+
population: '27.47M',
70+
// https://commons.wikimedia.org/wiki/File:Flag_of_Texas.svg
71+
flag: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Flag_of_Texas.svg',
72+
},
73+
];
74+
75+
displayFn(state: State | null): string {
76+
return state?.name ?? '';
77+
}
78+
79+
private _filterStates(value: string): State[] {
80+
const filterValue = value.toLowerCase();
81+
return this.states.filter(state => state.name.toLowerCase().includes(filterValue));
82+
}
83+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {AutocompleteAutoActiveFirstOptionExample} from './autocomplete-auto-active-first-option/autocomplete-auto-active-first-option-example';
2+
export {AutocompleteCustomTriggerExample} from './autocomplete-custom-trigger/autocomplete-custom-trigger-example';
23
export {AutocompleteDisplayExample} from './autocomplete-display/autocomplete-display-example';
34
export {AutocompleteFilterExample} from './autocomplete-filter/autocomplete-filter-example';
45
export {AutocompleteOptgroupExample} from './autocomplete-optgroup/autocomplete-optgroup-example';

src/material/autocomplete/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ ng_project(
6767
"autocomplete.ts",
6868
"autocomplete-module.ts",
6969
"autocomplete-origin.ts",
70+
"autocomplete-selected-trigger.ts",
7071
"autocomplete-trigger.ts",
7172
"index.ts",
7273
"public-api.ts",

src/material/autocomplete/autocomplete-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {BidiModule} from '@angular/cdk/bidi';
1212
import {CdkScrollableModule} from '@angular/cdk/scrolling';
1313
import {OverlayModule} from '@angular/cdk/overlay';
1414
import {MatAutocomplete} from './autocomplete';
15+
import {MatAutocompleteSelectedTrigger} from './autocomplete-selected-trigger';
1516
import {MatAutocompleteTrigger} from './autocomplete-trigger';
1617
import {MatAutocompleteOrigin} from './autocomplete-origin';
1718

@@ -22,6 +23,7 @@ import {MatAutocompleteOrigin} from './autocomplete-origin';
2223
MatAutocomplete,
2324
MatAutocompleteTrigger,
2425
MatAutocompleteOrigin,
26+
MatAutocompleteSelectedTrigger,
2527
],
2628
exports: [
2729
CdkScrollableModule,
@@ -30,6 +32,7 @@ import {MatAutocompleteOrigin} from './autocomplete-origin';
3032
BidiModule,
3133
MatAutocompleteTrigger,
3234
MatAutocompleteOrigin,
35+
MatAutocompleteSelectedTrigger,
3336
],
3437
})
3538
export class MatAutocompleteModule {}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Directive, InjectionToken, TemplateRef, inject} from '@angular/core';
10+
11+
/**
12+
* Injection token that references the `MatAutocompleteSelectedTrigger`.
13+
* @docs-private
14+
*/
15+
export const MAT_AUTOCOMPLETE_SELECTED_TRIGGER = new InjectionToken<MatAutocompleteSelectedTrigger>(
16+
'MatAutocompleteSelectedTrigger',
17+
);
18+
19+
/**
20+
* Used to provide a custom template for the selected option display in `mat-autocomplete`,
21+
* similar to `mat-select-trigger` for `mat-select`. Place inside `<mat-autocomplete>`:
22+
*
23+
* ```html
24+
* <mat-autocomplete>
25+
* <ng-template matAutocompleteSelectedTrigger let-value>{{ value }}</ng-template>
26+
* </mat-autocomplete>
27+
* ```
28+
*
29+
* The `$implicit` template context variable is the raw selected value.
30+
*/
31+
@Directive({
32+
selector: 'ng-template[matAutocompleteSelectedTrigger]',
33+
providers: [
34+
{provide: MAT_AUTOCOMPLETE_SELECTED_TRIGGER, useExisting: MatAutocompleteSelectedTrigger},
35+
],
36+
})
37+
export class MatAutocompleteSelectedTrigger {
38+
readonly templateRef = inject(TemplateRef);
39+
}

0 commit comments

Comments
 (0)