From d8076c6c809bff554a2d4f8a0e9678b504209242 Mon Sep 17 00:00:00 2001 From: Ruslan Lekhman Date: Tue, 7 Apr 2026 00:06:42 -0600 Subject: [PATCH] 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 ``, the implementation creates an Angular embedded view and inserts a wrapper `
` 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 --- goldens/material/autocomplete/index.api.md | 20 +- .../autocomplete-custom-trigger-example.css | 23 ++ .../autocomplete-custom-trigger-example.html | 36 +++ .../autocomplete-custom-trigger-example.ts | 84 +++++++ .../material/autocomplete/index.ts | 1 + src/material/autocomplete/BUILD.bazel | 1 + .../autocomplete/autocomplete-module.ts | 3 + .../autocomplete-selected-trigger.ts | 39 ++++ .../autocomplete/autocomplete-trigger.ts | 118 +++++++++- src/material/autocomplete/autocomplete.scss | 21 ++ .../autocomplete/autocomplete.spec.ts | 207 ++++++++++++++++++ src/material/autocomplete/autocomplete.ts | 15 +- src/material/autocomplete/public-api.ts | 1 + 13 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.css create mode 100644 src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.html create mode 100644 src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.ts create mode 100644 src/material/autocomplete/autocomplete-selected-trigger.ts diff --git a/goldens/material/autocomplete/index.api.md b/goldens/material/autocomplete/index.api.md index 7e7d11c6f811..f6f66c10e261 100644 --- a/goldens/material/autocomplete/index.api.md +++ b/goldens/material/autocomplete/index.api.md @@ -38,6 +38,9 @@ export const MAT_AUTOCOMPLETE_DEFAULT_OPTIONS: InjectionToken ScrollStrategy>; +// @public +export const MAT_AUTOCOMPLETE_SELECTED_TRIGGER: InjectionToken; + // @public export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any; @@ -55,6 +58,7 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy { _classList: string | string[]; readonly closed: EventEmitter; protected _color: ThemePalette; + customTrigger: MatAutocompleteSelectedTrigger | undefined; // (undocumented) protected _defaults: MatAutocompleteDefaultOptions; disableRipple: boolean; @@ -102,7 +106,7 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy { _syncParentProperties(): void; template: TemplateRef; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -131,7 +135,7 @@ export class MatAutocompleteModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public @@ -154,6 +158,16 @@ export class MatAutocompleteSelectedEvent { source: MatAutocomplete; } +// @public +export class MatAutocompleteSelectedTrigger { + // (undocumented) + templateRef: TemplateRef; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy { constructor(...args: unknown[]); @@ -164,6 +178,8 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, AfterViewIn closePanel(): void; connectedTo: MatAutocompleteOrigin; // (undocumented) + _handleBlur(): void; + // (undocumented) _handleClick(): void; // (undocumented) _handleFocus(): void; diff --git a/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.css b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.css new file mode 100644 index 000000000000..dea4358abf31 --- /dev/null +++ b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.css @@ -0,0 +1,23 @@ +.example-form { + min-width: 150px; + max-width: 500px; + width: 100%; +} + +.example-full-width { + width: 100%; +} + +.example-option-img { + vertical-align: middle; + margin-right: 8px; +} + +.example-trigger-flag { + vertical-align: middle; + margin-right: 4px; +} + +.mat-autocomplete-selected-trigger { + top: 15px; +} diff --git a/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.html b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.html new file mode 100644 index 000000000000..b8eeefea893f --- /dev/null +++ b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.html @@ -0,0 +1,36 @@ +
+ + State + + + + + + {{ state?.name }} + + @for (state of filteredStates | async; track state.name) { + + + {{ state.name }} | + Population: {{ state.population }} + + } + + + +
+ + + Disable Input? + +
diff --git a/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.ts b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.ts new file mode 100644 index 000000000000..54303cfd282b --- /dev/null +++ b/src/components-examples/material/autocomplete/autocomplete-custom-trigger/autocomplete-custom-trigger-example.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {AsyncPipe} from '@angular/common'; +import {Component} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatSlideToggleModule} from '@angular/material/slide-toggle'; +import {Observable} from 'rxjs'; +import {map, startWith} from 'rxjs/operators'; + +export interface State { + flag: string; + name: string; + population: string; +} + +/** @title Autocomplete with custom selected-value template */ +@Component({ + selector: 'autocomplete-custom-trigger-example', + templateUrl: 'autocomplete-custom-trigger-example.html', + styleUrl: 'autocomplete-custom-trigger-example.css', + imports: [ + AsyncPipe, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatSlideToggleModule, + ReactiveFormsModule, + ], +}) +export class AutocompleteCustomTriggerExample { + stateCtrl = new FormControl(null); + filteredStates = this.stateCtrl.valueChanges.pipe( + startWith(null), + map(value => { + const name = typeof value === 'string' ? value : (value?.name ?? ''); + return name ? this._filterStates(name) : this.states.slice(); + }), + ); + + states: State[] = [ + { + name: 'Arkansas', + population: '2.978M', + // https://commons.wikimedia.org/wiki/File:Flag_of_Arkansas.svg + flag: 'https://upload.wikimedia.org/wikipedia/commons/9/9d/Flag_of_Arkansas.svg', + }, + { + name: 'California', + population: '39.14M', + // https://commons.wikimedia.org/wiki/File:Flag_of_California.svg + flag: 'https://upload.wikimedia.org/wikipedia/commons/0/01/Flag_of_California.svg', + }, + { + name: 'Florida', + population: '20.27M', + // https://commons.wikimedia.org/wiki/File:Flag_of_Florida.svg + flag: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Flag_of_Florida.svg', + }, + { + name: 'Texas', + population: '27.47M', + // https://commons.wikimedia.org/wiki/File:Flag_of_Texas.svg + flag: 'https://upload.wikimedia.org/wikipedia/commons/f/f7/Flag_of_Texas.svg', + }, + ]; + + displayFn(state: State | null): string { + return state?.name ?? ''; + } + + private _filterStates(value: string): State[] { + const filterValue = value.toLowerCase(); + return this.states.filter(state => state.name.toLowerCase().includes(filterValue)); + } +} diff --git a/src/components-examples/material/autocomplete/index.ts b/src/components-examples/material/autocomplete/index.ts index 18aad314a477..03355f8c4adf 100644 --- a/src/components-examples/material/autocomplete/index.ts +++ b/src/components-examples/material/autocomplete/index.ts @@ -1,4 +1,5 @@ export {AutocompleteAutoActiveFirstOptionExample} from './autocomplete-auto-active-first-option/autocomplete-auto-active-first-option-example'; +export {AutocompleteCustomTriggerExample} from './autocomplete-custom-trigger/autocomplete-custom-trigger-example'; export {AutocompleteDisplayExample} from './autocomplete-display/autocomplete-display-example'; export {AutocompleteFilterExample} from './autocomplete-filter/autocomplete-filter-example'; export {AutocompleteOptgroupExample} from './autocomplete-optgroup/autocomplete-optgroup-example'; diff --git a/src/material/autocomplete/BUILD.bazel b/src/material/autocomplete/BUILD.bazel index 44dea63a861f..4768b62824e6 100644 --- a/src/material/autocomplete/BUILD.bazel +++ b/src/material/autocomplete/BUILD.bazel @@ -67,6 +67,7 @@ ng_project( "autocomplete.ts", "autocomplete-module.ts", "autocomplete-origin.ts", + "autocomplete-selected-trigger.ts", "autocomplete-trigger.ts", "index.ts", "public-api.ts", diff --git a/src/material/autocomplete/autocomplete-module.ts b/src/material/autocomplete/autocomplete-module.ts index 4344e1f21e47..cc6e7c57d4ae 100644 --- a/src/material/autocomplete/autocomplete-module.ts +++ b/src/material/autocomplete/autocomplete-module.ts @@ -12,6 +12,7 @@ import {BidiModule} from '@angular/cdk/bidi'; import {CdkScrollableModule} from '@angular/cdk/scrolling'; import {OverlayModule} from '@angular/cdk/overlay'; import {MatAutocomplete} from './autocomplete'; +import {MatAutocompleteSelectedTrigger} from './autocomplete-selected-trigger'; import {MatAutocompleteTrigger} from './autocomplete-trigger'; import {MatAutocompleteOrigin} from './autocomplete-origin'; @@ -22,6 +23,7 @@ import {MatAutocompleteOrigin} from './autocomplete-origin'; MatAutocomplete, MatAutocompleteTrigger, MatAutocompleteOrigin, + MatAutocompleteSelectedTrigger, ], exports: [ CdkScrollableModule, @@ -30,6 +32,7 @@ import {MatAutocompleteOrigin} from './autocomplete-origin'; BidiModule, MatAutocompleteTrigger, MatAutocompleteOrigin, + MatAutocompleteSelectedTrigger, ], }) export class MatAutocompleteModule {} diff --git a/src/material/autocomplete/autocomplete-selected-trigger.ts b/src/material/autocomplete/autocomplete-selected-trigger.ts new file mode 100644 index 000000000000..d4c43bb86fa6 --- /dev/null +++ b/src/material/autocomplete/autocomplete-selected-trigger.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Directive, InjectionToken, TemplateRef, inject} from '@angular/core'; + +/** + * Injection token that references the `MatAutocompleteSelectedTrigger`. + * @docs-private + */ +export const MAT_AUTOCOMPLETE_SELECTED_TRIGGER = new InjectionToken( + 'MatAutocompleteSelectedTrigger', +); + +/** + * Used to provide a custom template for the selected option display in `mat-autocomplete`, + * similar to `mat-select-trigger` for `mat-select`. Place inside ``: + * + * ```html + * + * {{ value }} + * + * ``` + * + * The `$implicit` template context variable is the raw selected value. + */ +@Directive({ + selector: 'ng-template[matAutocompleteSelectedTrigger]', + providers: [ + {provide: MAT_AUTOCOMPLETE_SELECTED_TRIGGER, useExisting: MatAutocompleteSelectedTrigger}, + ], +}) +export class MatAutocompleteSelectedTrigger { + readonly templateRef = inject(TemplateRef); +} diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index cd38ee392f15..7fe6d954f7c4 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -29,6 +29,7 @@ import { ChangeDetectorRef, Directive, ElementRef, + EmbeddedViewRef, EnvironmentInjector, InjectionToken, Injector, @@ -112,7 +113,7 @@ export const MAT_AUTOCOMPLETE_SCROLL_STRATEGY = new InjectionToken<() => ScrollS // Note: we use `focusin`, as opposed to `focus`, in order to open the panel // a little earlier. This avoids issues where IE delays the focusing of the input. '(focusin)': '_handleFocus()', - '(blur)': '_onTouched()', + '(blur)': '_handleBlur()', '(input)': '_handleInput($event)', '(keydown)': '_handleKeydown($event)', '(click)': '_handleClick()', @@ -189,6 +190,12 @@ export class MatAutocompleteTrigger */ private _pendingAutoselectedOption: MatOption | null = null; + /** Embedded view rendered by the custom `mat-autocomplete-trigger` template, if any. */ + private _customTriggerView: EmbeddedViewRef | null = null; + private _customTriggerWrapper: HTMLElement | null = null; + /** The last value passed to _syncCustomTrigger so it can be restored after a no-op blur. */ + private _customTriggerValue: any = null; + /** Stream of keyboard events that can close the panel. */ private readonly _closeKeyEventStream = new Subject(); @@ -273,6 +280,7 @@ export class MatAutocompleteTrigger this._destroyPanel(); this._closeKeyEventStream.complete(); this._clearFromModal(); + this._clearCustomTrigger(); } /** Whether or not the autocomplete panel is open. */ @@ -434,7 +442,14 @@ export class MatAutocompleteTrigger // Implemented as part of ControlValueAccessor. writeValue(value: any): void { - Promise.resolve(null).then(() => this._assignOptionValue(value)); + Promise.resolve(null).then(() => { + if (this._componentDestroyed) { + return; + } + + this._assignOptionValue(value); + this._syncCustomTrigger(value); + }); } // Implemented as part of ControlValueAccessor. @@ -513,6 +528,7 @@ export class MatAutocompleteTrigger if (this._previousValue !== value) { this._previousValue = value; this._pendingAutoselectedOption = null; + this._clearCustomTrigger(); // If selection is required we don't write to the CVA while the user is typing. // At the end of the selection either the user will have picked something @@ -522,6 +538,7 @@ export class MatAutocompleteTrigger } if (!value) { + this._customTriggerValue = null; this._clearPreviousSelectedOption(null, false); } else if (this.panelOpen && !this.autocomplete.requireSelection) { // Note that we don't reset this when `requireSelection` is enabled, @@ -551,6 +568,10 @@ export class MatAutocompleteTrigger } _handleFocus(): void { + // Always clear the custom trigger on focus so the user can edit the value, + // regardless of whether the panel is about to open. _clearCustomTrigger is a no-op + // when no custom trigger is active, so this is safe to call unconditionally. + this._clearCustomTrigger(); if (!this._canOpenOnNextFocus) { this._canOpenOnNextFocus = true; } else if (this._canOpen()) { @@ -560,7 +581,31 @@ export class MatAutocompleteTrigger } } + _handleBlur(): void { + this._onTouched(); + // If the user focused and blurred without making a new selection, restore the custom trigger. + // Defer so any pending option-selection events (mousedown → click on option) run first: + // if a new value was selected, _syncCustomTrigger will have already set _customTriggerWrapper + // before this callback runs, and we skip the restore. + if (this._customTriggerValue != null && this._customTriggerValue !== '') { + Promise.resolve().then(() => { + if (!this._componentDestroyed && !this._customTriggerWrapper) { + // Only restore the trigger if the input still shows the selected value's display text. + // If the user typed something different and blurred, don't restore. + const currentInput = this._element.nativeElement.value; + const displayValue = String(this._getDisplayValue(this._customTriggerValue) ?? ''); + if (currentInput === displayValue) { + this._syncCustomTrigger(this._customTriggerValue); + } + } + }); + } + } + _handleClick(): void { + // Clear the custom trigger on click so the user can immediately start editing, + // even when the input is already focused (focusin would not fire in that case). + this._clearCustomTrigger(); if (this._canOpen() && !this.panelOpen) { this._openPanelInternal(); } @@ -689,6 +734,67 @@ export class MatAutocompleteTrigger return autocomplete && autocomplete.displayWith ? autocomplete.displayWith(value) : value; } + /** + * Renders the custom trigger template as an overlay over the input when a value is selected. + * All template root nodes are wrapped in a single library-created div so that: + * - Templates with multiple root nodes (e.g. `` + text) are handled correctly. + * - `display: flex; align-items: center` works regardless of root node types. + * The input text is hidden via inline `color: transparent` which has higher specificity + * than any MDC class selector. Hides the overlay and restores the input when value is null. + */ + private _syncCustomTrigger(value: any): void { + const customTrigger = this.autocomplete?.customTrigger; + if (!customTrigger) return; + + // Only show the custom trigger for a real selected value — not for null or empty string, + // which both represent "no selection" (e.g. initial FormControl('') or after clearing). + if (value != null && value !== '') { + this._customTriggerValue = value; + this._clearCustomTrigger(); + this._customTriggerView = this._viewContainerRef.createEmbeddedView( + customTrigger.templateRef, + {$implicit: value}, + ); + this._customTriggerView.detectChanges(); + + // Wrap all root nodes in a single div so positioning and flex layout work correctly + // regardless of how many root nodes the template has or what types they are. + this._customTriggerWrapper = this._renderer.createElement('div') as HTMLElement; + this._renderer.addClass(this._customTriggerWrapper, 'mat-autocomplete-selected-trigger'); + for (const node of this._customTriggerView.rootNodes) { + this._renderer.appendChild(this._customTriggerWrapper, node); + } + + const input = this._element.nativeElement; + const parent = input.parentNode; + if (parent) { + this._renderer.insertBefore(parent, this._customTriggerWrapper, input.nextSibling); + // Use inline styles to override MDC's higher-specificity color rules. + this._renderer.setStyle(input, 'color', 'transparent'); + this._renderer.setStyle(input, 'caret-color', 'transparent'); + } + } else { + this._customTriggerValue = null; + this._clearCustomTrigger(); + } + } + + /** Destroys the custom trigger overlay and restores normal input text rendering. */ + private _clearCustomTrigger(): void { + this._customTriggerView?.destroy(); + this._customTriggerView = null; + if (this._customTriggerWrapper) { + const parent = this._customTriggerWrapper.parentNode; + if (parent) { + this._renderer.removeChild(parent, this._customTriggerWrapper); + } + this._customTriggerWrapper = null; + } + const input = this._element.nativeElement; + this._renderer.removeStyle(input, 'color'); + this._renderer.removeStyle(input, 'caret-color'); + } + private _assignOptionValue(value: any): void { const toDisplay = this._getDisplayValue(value); @@ -721,6 +827,7 @@ export class MatAutocompleteTrigger private _setValueAndClose(event: MatOptionSelectionChange | null): void { const panel = this.autocomplete; const toSelect = event ? event.source : this._pendingAutoselectedOption; + let didChange = false; if (toSelect) { this._clearPreviousSelectedOption(toSelect); @@ -731,6 +838,7 @@ export class MatAutocompleteTrigger this._onChange(toSelect.value); panel._emitSelectEvent(toSelect); this._element.nativeElement.focus(); + didChange = true; } else if ( panel.requireSelection && this._element.nativeElement.value !== this._valueOnAttach @@ -738,9 +846,15 @@ export class MatAutocompleteTrigger this._clearPreviousSelectedOption(null); this._assignOptionValue(null); this._onChange(null); + didChange = true; } this.closePanel(); + + // After the panel closes, sync the custom trigger overlay if the value changed. + if (didChange) { + this._syncCustomTrigger(toSelect ? toSelect.value : null); + } } /** diff --git a/src/material/autocomplete/autocomplete.scss b/src/material/autocomplete/autocomplete.scss index d2ee490bfa08..8e3f82c339d1 100644 --- a/src/material/autocomplete/autocomplete.scss +++ b/src/material/autocomplete/autocomplete.scss @@ -74,3 +74,24 @@ div.mat-mdc-autocomplete-panel.mat-mdc-autocomplete-hidden, mat-autocomplete { display: none; } + +// Applied to the library-created wrapper div that holds the `mat-autocomplete-trigger` template +// content when an option is selected. Positions it absolutely within the form-field infix +// (which has `position: relative`) so it overlays the input whose text is hidden via inline +// `color: transparent`. +.mat-autocomplete-selected-trigger { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + // Allow clicks to fall through to the underlying input so the user can re-focus and retype. + pointer-events: none; + + // Mirror the disabled appearance of the underlying input. + input:disabled + & { + opacity: 0.38; // the canonical Material Design disabled-state opacity + } +} diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index 4d86cb58c10e..b5b382dc4cc2 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -57,6 +57,7 @@ import { MatAutocompleteDefaultOptions, MatAutocompleteOrigin, MatAutocompleteSelectedEvent, + MatAutocompleteSelectedTrigger, MatAutocompleteTrigger, getMatAutocompleteMissingPanelError, } from './index'; @@ -3998,6 +3999,183 @@ describe('MatAutocomplete', () => { .toContain(panelId); }); }); + + describe('custom selected-value trigger (mat-autocomplete-trigger)', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(AutocompleteWithCustomTrigger); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css('input'))!.nativeElement; + }); + + it('should render the custom trigger template after an option is selected', waitForAsync(async () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + const customTrigger = fixture.nativeElement.querySelector('.custom-trigger-display'); + expect(customTrigger).toBeTruthy('Expected custom trigger element to be in the DOM.'); + })); + + it('should expose the selected value as $implicit template context', waitForAsync(async () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + const customTrigger = fixture.nativeElement.querySelector('.custom-trigger-display'); + expect(customTrigger.textContent.trim()).toBe('Alabama'); + })); + + it('should hide the custom trigger overlay when the user starts typing', waitForAsync(async () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.custom-trigger-display')).toBeTruthy(); + + typeInElement(input, 'A'); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.custom-trigger-display')) + .withContext('Expected custom trigger to be removed after typing.') + .toBeFalsy(); + })); + + it('should set color: transparent on the input when the custom trigger is active', waitForAsync(async () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + expect(input.style.color).toBe('transparent'); + })); + + it('should restore input color when typing after a selection', waitForAsync(async () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + + const option = getOverlayHost(fixture)!.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + await new Promise(r => setTimeout(r)); + fixture.detectChanges(); + + typeInElement(input, 'A'); + fixture.detectChanges(); + + expect(input.style.color).not.toBe('transparent'); + })); + + it('should show the custom trigger when value is set programmatically via writeValue', fakeAsync(() => { + fixture.componentInstance.trigger.writeValue('Alabama'); + tick(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.custom-trigger-display')).toBeTruthy(); + expect(input.style.color).toBe('transparent'); + })); + + it('should clear the custom trigger when value is set to null programmatically', fakeAsync(() => { + fixture.componentInstance.trigger.writeValue('Alabama'); + tick(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.writeValue(null); + tick(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.custom-trigger-display')).toBeFalsy(); + expect(input.style.color).not.toBe('transparent'); + })); + + it('should not show the custom trigger while the panel is still open during navigation', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + flush(); + + // Keyboard navigate to first option; with autoSelectActiveOption this calls _assignOptionValue. + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + // Panel is still open — custom trigger must not be visible yet. + expect(fixture.nativeElement.querySelector('.custom-trigger-display')) + .withContext('Expected custom trigger to stay hidden while panel is open.') + .toBeFalsy(); + })); + + it('should restore the custom trigger on blur when input still shows the selected display value', waitForAsync(async () => { + // Select an option so the custom trigger appears. + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + const options = fixture.debugElement.queryAll(By.css('mat-option')); + options[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(input.style.color).toBe('transparent'); + + // Click input to clear the custom trigger for editing. + input.click(); + fixture.detectChanges(); + + expect(input.style.color).not.toBe('transparent'); + + // Blur without typing (input still holds the selected option's display text). + input.dispatchEvent(new Event('blur')); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(input.style.color) + .withContext( + 'Expected custom trigger to be restored after blur with matching display value.', + ) + .toBe('transparent'); + })); + + it('should clean up the custom trigger wrapper when the component is destroyed', waitForAsync(async () => { + // Select an option so a wrapper div is inserted into the DOM. + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + const options = fixture.debugElement.queryAll(By.css('mat-option')); + options[0].nativeElement.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('.custom-trigger-display')).toBeTruthy(); + + // Destroying the fixture must not throw and must remove the wrapper from the DOM. + expect(() => fixture.destroy()).not.toThrow(); + expect(document.querySelector('.custom-trigger-display')) + .withContext('Expected wrapper to be removed from DOM after destroy.') + .toBeFalsy(); + })); + }); }); const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` @@ -4605,3 +4783,32 @@ class AutocompleteInsideAModal { class AutocompleteWithoutOptions { @ViewChild(MatAutocompleteTrigger, {static: true}) trigger!: MatAutocompleteTrigger; } + +@Component({ + template: ` + + + + + + + {{ val }} + + @for (state of states; track state) { + {{ state }} + } + + `, + imports: [ + MatAutocomplete, + MatAutocompleteTrigger, + MatAutocompleteSelectedTrigger, + MatInputModule, + MatOption, + ], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class AutocompleteWithCustomTrigger { + @ViewChild(MatAutocompleteTrigger, {static: true}) trigger!: MatAutocompleteTrigger; + states = ['Alabama', 'California', 'Florida']; +} diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index 44dec71748ae..309ebd7a2ca5 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -11,6 +11,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + ContentChild, ContentChildren, ElementRef, EventEmitter, @@ -25,6 +26,9 @@ import { booleanAttribute, inject, } from '@angular/core'; +import {_IdGenerator, ActiveDescendantKeyManager} from '@angular/cdk/a11y'; +import {Platform} from '@angular/cdk/platform'; +import {Subscription} from 'rxjs'; import { _animationsDisabled, MAT_OPTGROUP, @@ -33,9 +37,10 @@ import { MatOption, ThemePalette, } from '../core'; -import {_IdGenerator, ActiveDescendantKeyManager} from '@angular/cdk/a11y'; -import {Platform} from '@angular/cdk/platform'; -import {Subscription} from 'rxjs'; +import { + MAT_AUTOCOMPLETE_SELECTED_TRIGGER, + MatAutocompleteSelectedTrigger, +} from './autocomplete-selected-trigger'; /** Event object that is emitted when an autocomplete option is selected. */ export class MatAutocompleteSelectedEvent { @@ -157,6 +162,10 @@ export class MatAutocomplete implements AfterContentInit, OnDestroy { /** Reference to all option groups within the autocomplete. */ @ContentChildren(MAT_OPTGROUP, {descendants: true}) optionGroups!: QueryList; + /** Custom template for rendering the selected option in the trigger area. */ + @ContentChild(MAT_AUTOCOMPLETE_SELECTED_TRIGGER) + customTrigger: MatAutocompleteSelectedTrigger | undefined; + /** Aria label of the autocomplete. */ @Input('aria-label') ariaLabel!: string; diff --git a/src/material/autocomplete/public-api.ts b/src/material/autocomplete/public-api.ts index 26bd6e438659..26b525eb55f1 100644 --- a/src/material/autocomplete/public-api.ts +++ b/src/material/autocomplete/public-api.ts @@ -9,6 +9,7 @@ export * from './autocomplete-module'; export * from './autocomplete'; export * from './autocomplete-origin'; +export * from './autocomplete-selected-trigger'; export * from './autocomplete-trigger'; // Re-export these since they're required to be used together with `mat-autocomplete`.