diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index a8bce97965e5..1bc33c6b512c 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -144,6 +144,9 @@ export class MatAutocompleteTrigger /** Value of the input element when the panel was attached (even if there are no options). */ private _valueOnAttach: string | number | null; + /** Value on the previous keydown event. */ + private _valueOnLastKeydown: string | null; + /** Strategy that is used to position the panel. */ private _positionStrategy: FlexibleConnectedPositionStrategy; @@ -285,13 +288,7 @@ export class MatAutocompleteTrigger /** Opens the autocomplete suggestion panel. */ openPanel(): void { - this._attachOverlay(); - this._floatLabel(); - // Add aria-owns attribute when the autocomplete becomes visible. - if (this._trackedModal) { - const panelId = this.autocomplete.id; - addAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - } + this._openPanelInternal(); } /** Closes the autocomplete suggestion panel. */ @@ -461,6 +458,8 @@ export class MatAutocompleteTrigger event.preventDefault(); } + this._valueOnLastKeydown = this._element.nativeElement.value; + if (this.activeOption && keyCode === ENTER && this.panelOpen && !hasModifier) { this.activeOption._selectViaInteraction(); this._resetActiveItem(); @@ -472,7 +471,7 @@ export class MatAutocompleteTrigger if (keyCode === TAB || (isArrowKey && !hasModifier && this.panelOpen)) { this.autocomplete._keyManager.onKeydown(event); } else if (isArrowKey && this._canOpen()) { - this.openPanel(); + this._openPanelInternal(this._valueOnLastKeydown); } if (isArrowKey || this.autocomplete._keyManager.activeItem !== prevActiveItem) { @@ -480,7 +479,7 @@ export class MatAutocompleteTrigger if (this.autocomplete.autoSelectActiveOption && this.activeOption) { if (!this._pendingAutoselectedOption) { - this._valueBeforeAutoSelection = this._element.nativeElement.value; + this._valueBeforeAutoSelection = this._valueOnLastKeydown; } this._pendingAutoselectedOption = this.activeOption; @@ -523,7 +522,7 @@ export class MatAutocompleteTrigger const selectedOption = this.autocomplete.options?.find(option => option.selected); if (selectedOption) { - const display = this.autocomplete.displayWith?.(selectedOption) ?? selectedOption.value; + const display = this._getDisplayValue(selectedOption.value); if (value !== display) { selectedOption.deselect(false); @@ -532,7 +531,14 @@ export class MatAutocompleteTrigger } if (this._canOpen() && this._document.activeElement === event.target) { - this.openPanel(); + // When the `input` event fires, the input's value will have already changed. This means + // that if we take the `this._element.nativeElement.value` directly, it'll be one keystroke + // behind. This can be a problem when the user selects a value, changes a character while + // the input still has focus and then clicks away (see #28432). To work around it, we + // capture the value in `keydown` so we can use it here. + const valueOnAttach = this._valueOnLastKeydown ?? this._element.nativeElement.value; + this._valueOnLastKeydown = null; + this._openPanelInternal(valueOnAttach); } } } @@ -542,14 +548,14 @@ export class MatAutocompleteTrigger this._canOpenOnNextFocus = true; } else if (this._canOpen()) { this._previousValue = this._element.nativeElement.value; - this._attachOverlay(); + this._attachOverlay(this._previousValue); this._floatLabel(true); } } _handleClick(): void { if (this._canOpen() && !this.panelOpen) { - this.openPanel(); + this._openPanelInternal(); } } @@ -657,11 +663,14 @@ export class MatAutocompleteTrigger } } + /** Given a value, returns the string that should be shown within the input. */ + private _getDisplayValue(value: T): T | string { + const autocomplete = this.autocomplete; + return autocomplete && autocomplete.displayWith ? autocomplete.displayWith(value) : value; + } + private _assignOptionValue(value: any): void { - const toDisplay = - this.autocomplete && this.autocomplete.displayWith - ? this.autocomplete.displayWith(value) - : value; + const toDisplay = this._getDisplayValue(value); if (value == null) { this._clearPreviousSelectedOption(null, false); @@ -733,7 +742,17 @@ export class MatAutocompleteTrigger }); } - private _attachOverlay(): void { + private _openPanelInternal(valueOnAttach = this._element.nativeElement.value) { + this._attachOverlay(valueOnAttach); + this._floatLabel(); + // Add aria-owns attribute when the autocomplete becomes visible. + if (this._trackedModal) { + const panelId = this.autocomplete.id; + addAriaReferencedId(this._trackedModal, 'aria-owns', panelId); + } + } + + private _attachOverlay(valueOnAttach: string): void { if (!this.autocomplete && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw getMatAutocompleteMissingPanelError(); } @@ -759,7 +778,8 @@ export class MatAutocompleteTrigger if (overlayRef && !overlayRef.hasAttached()) { overlayRef.attach(this._portal); - this._valueOnAttach = this._element.nativeElement.value; + this._valueOnAttach = valueOnAttach; + this._valueOnLastKeydown = null; this._closingActionsSubscription = this._subscribeToClosingActions(); } diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index e4150c61b7d7..9632f858a588 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -2780,6 +2780,48 @@ describe('MDC-based MatAutocomplete', () => { expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); })); + + it('should clear the value if requireSelection is enabled and the user edits the input before clicking away', fakeAsync(() => { + const input = fixture.nativeElement.querySelector('input'); + const {stateCtrl, trigger} = fixture.componentInstance; + fixture.componentInstance.requireSelection = true; + fixture.detectChanges(); + tick(); + + // Simulate opening the input and clicking the first option. + trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + (overlayContainerElement.querySelector('mat-option') as HTMLElement).click(); + tick(); + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe('Alabama'); + expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); + + // Simulate pressing backspace while focus is still on the input. + dispatchFakeEvent(input, 'keydown'); + input.value = 'Alabam'; + fixture.detectChanges(); + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(trigger.panelOpen).toBe(true); + expect(input.value).toBe('Alabam'); + expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'}); + + // Simulate clicking away. + input.blur(); + dispatchFakeEvent(document, 'click'); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(false); + expect(input.value).toBe(''); + expect(stateCtrl.value).toBe(null); + })); }); describe('panel closing', () => {