diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index 7734b4d81319..2406d0e60cee 100644 --- a/packages/main/cypress/specs/MultiInput.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.cy.tsx @@ -1502,6 +1502,145 @@ describe("Keyboard handling", () => { cy.get("@changeSpy") .should("have.been.calledOnce"); }); + + it("should focus last token on ArrowLeft at start of input, keep suggestions open, and not fire change event", () => { + cy.mount( + + + + + + ); + + const changeSpy = cy.stub().as("changeSpy"); + + cy.get("[ui5-multi-input]") + .then(multiInput => { + multiInput[0].addEventListener("ui5-change", changeSpy); + }); + + cy.get("[ui5-multi-input]") + .shadow() + .find("input") + .as("input"); + + cy.get("@input") + .realClick(); + + cy.realType("a"); + + cy.get("[ui5-multi-input]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + cy.realPress("ArrowLeft"); // cursor: pos 1 → pos 0 + cy.realPress("ArrowLeft"); // cursor at pos 0 → focuses last token + + cy.get("[ui5-token]") + .should("have.length", 1); + + cy.get("[ui5-token]") + .should("be.focused"); + + cy.get("[ui5-multi-input]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + cy.get("@changeSpy") + .should("not.have.been.called"); + }); + + it("should fire change event when returning from tokenizer to input via ArrowRight and pressing Tab", () => { + cy.mount( + + + + + + ); + + const changeSpy = cy.stub().as("changeSpy"); + + cy.get("[ui5-multi-input]") + .then(multiInput => { + multiInput[0].addEventListener("ui5-change", changeSpy); + }); + + cy.get("[ui5-multi-input]") + .shadow() + .find("input") + .as("input"); + + cy.get("@input") + .realClick(); + + cy.realType("a"); + + // focus last token + cy.realPress("ArrowLeft"); + cy.realPress("ArrowLeft"); + + cy.get("[ui5-token]") + .should("be.focused"); + + // return to input + cy.realPress("ArrowRight"); + + cy.get("[ui5-multi-input]") + .should("be.focused"); + + cy.realPress("Tab"); + + cy.get("@changeSpy") + .should("have.been.calledOnce"); + }); + + it("should fire change event when returning from tokenizer to input via Tab and pressing Enter", () => { + cy.mount( + + + + + + ); + + const changeSpy = cy.stub().as("changeSpy"); + + cy.get("[ui5-multi-input]") + .then(multiInput => { + multiInput[0].addEventListener("ui5-change", changeSpy); + }); + + cy.get("[ui5-multi-input]") + .shadow() + .find("input") + .as("input"); + + cy.get("@input") + .realClick(); + + cy.realType("b"); + + // focus last token + cy.realPress("ArrowLeft"); + cy.realPress("ArrowLeft"); + + cy.get("[ui5-token]") + .should("be.focused"); + + // return to input + cy.realPress("Tab"); + + cy.get("[ui5-multi-input]") + .should("be.focused"); + + cy.realPress("Enter"); + + cy.get("@changeSpy") + .should("have.been.calledOnce"); + }); }); describe("MultiInput Composition", () => { diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index 9a5f995204d6..2b16598d01f6 100644 --- a/packages/main/src/Input.ts +++ b/packages/main/src/Input.ts @@ -631,8 +631,8 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement previousValue: string; firstRendering: boolean; typedInValue: string; - lastConfirmedValue: string - isTyping: boolean + lastConfirmedValue: string; + isTyping: boolean; _handleResizeBound: ResizeObserverCallback; _shouldAutocomplete?: boolean; _enterKeyDown?: boolean; diff --git a/packages/main/src/MultiInput.ts b/packages/main/src/MultiInput.ts index d5a99d692714..bb455b1d33e4 100644 --- a/packages/main/src/MultiInput.ts +++ b/packages/main/src/MultiInput.ts @@ -13,7 +13,8 @@ import { isHome, isEnd, isDown, - + isEnter, + isTabNext, } from "@ui5/webcomponents-base/dist/Keys.js"; import { isPhone } from "@ui5/webcomponents-base/dist/Device.js"; import type { ITabbable } from "@ui5/webcomponents-base/dist/delegate/ItemNavigation.js"; @@ -153,6 +154,8 @@ class MultiInput extends Input implements IFormInputElement { _skipOpenSuggestions: boolean; _valueHelpIconPressed: boolean; + _focusInTokenizer: boolean; + _returningFromTokenizer: boolean; get formValidityMessage() { return MultiInput.i18nBundle.getText(FORM_MIXED_TEXTFIELD_REQUIRED); @@ -188,6 +191,8 @@ class MultiInput extends Input implements IFormInputElement { // Prevent suggestions' opening. this._skipOpenSuggestions = false; this._valueHelpIconPressed = false; + this._focusInTokenizer = false; + this._returningFromTokenizer = false; } valueHelpPress() { @@ -225,6 +230,10 @@ class MultiInput extends Input implements IFormInputElement { if (!this.contains(e.relatedTarget as HTMLElement) && !this.shadowRoot!.contains(e.relatedTarget as HTMLElement)) { this.tokenizer._tokens.forEach(token => { token.selected = false; }); } + if (this.shadowRoot!.contains(e.relatedTarget as HTMLElement)) { + this._returningFromTokenizer = true; + } + this._focusInTokenizer = false; } valueHelpMouseUp() { @@ -248,6 +257,7 @@ class MultiInput extends Input implements IFormInputElement { _onkeydown(e: KeyboardEvent) { !this._isComposing && super._onkeydown(e); + this._isKeyNavigation = true; const target = e.target as HTMLInputElement; const isHomeInBeginning = isHome(e) && target.selectionStart === 0; @@ -269,9 +279,16 @@ class MultiInput extends Input implements IFormInputElement { this._skipOpenSuggestions = false; + if ((isEnter(e) || isTabNext(e)) && this.previousValue !== this.value) { + this._handleChange(); + return; + } + if (isShow(e)) { this.valueHelpPress(); } + + this._isKeyNavigation = false; } _onTokenizerKeydown(e: KeyboardEvent) { @@ -281,6 +298,7 @@ class MultiInput extends Input implements IFormInputElement { const lastTokenIndex = this.tokens.length - 1; if (e.target === this.tokens[lastTokenIndex] && this.tokens[lastTokenIndex] === document.activeElement) { + this._returningFromTokenizer = true; setTimeout(() => { this.focus(); }, 0); @@ -296,9 +314,25 @@ class MultiInput extends Input implements IFormInputElement { // selectionStart property applies only to inputs of types text, search, URL, tel, and password if (((cursorPosition === null && !this.value) || cursorPosition === 0) && lastToken) { e.preventDefault(); - lastToken.focus(); - this.tokenizer._itemNav.setCurrentItem(lastToken); + this._focusToken(lastToken); + } + } + + _focusToken(tokenToFocus: IToken) { + this._focusInTokenizer = true; + tokenToFocus.focus(); + this.tokenizer._itemNav.setCurrentItem(tokenToFocus); + } + + /** + * @override + */ + _handleChange() { + if (this._focusInTokenizer) { + return; } + + super._handleChange(); } _handleBackspace(e: KeyboardEvent) { @@ -308,8 +342,7 @@ class MultiInput extends Input implements IFormInputElement { // Only move focus to the last token if the input is empty if (!this.value && lastToken) { e.preventDefault(); - lastToken.focus(); - this.tokenizer._itemNav.setCurrentItem(lastToken); + this._focusToken(lastToken); } } @@ -319,9 +352,7 @@ class MultiInput extends Input implements IFormInputElement { if (firstToken) { e.preventDefault(); - - firstToken.focus(); - this.tokenizer._itemNav.setCurrentItem(firstToken); + this._focusToken(firstToken); } } @@ -347,7 +378,15 @@ class MultiInput extends Input implements IFormInputElement { const inputDomRef = this.getInputDOMRef(); if (e.target === inputDomRef) { - super._onfocusin(e); + if (this._returningFromTokenizer) { + this._returningFromTokenizer = false; + this.focused = true; + this.open = true; + this._inputIconFocused = false; + this._focusedAfterClear = false; + } else { + super._onfocusin(e); + } } }