diff --git a/packages/main/cypress/specs/Input.cy.tsx b/packages/main/cypress/specs/Input.cy.tsx index ee14e9829d84..2e0fe702a76c 100644 --- a/packages/main/cypress/specs/Input.cy.tsx +++ b/packages/main/cypress/specs/Input.cy.tsx @@ -1964,7 +1964,7 @@ describe("Input general interaction", () => { .should("be.focused"); cy.get("@inputEl") - .realType("a"); + .realType("A"); cy.get("@inputEl") .should("have.value", "Adam D"); @@ -1985,7 +1985,7 @@ describe("Input general interaction", () => { ); cy.get("#input-custom-flat").shadow().find("input").as("input"); - cy.get("@input").click().realType("a"); + cy.get("@input").click().realType("A"); cy.get("@input").should("have.value", "Albania"); cy.get("@input").then($input => { @@ -3227,4 +3227,203 @@ describe("Input built-in filtering", () => { .eq(1) .should("have.attr", "hidden"); }); + + describe("Typeahead capitalization handling", () => { + it("should preserve user's typed capitalization during typeahead and use original suggestion capitalization when accepted", () => { + cy.mount( + + + + + + ); + + cy.get("#capitalization-test") + .shadow() + .find("input") + .as("input"); + + // Type lowercase 'a' - should show 'apple' with user's lowercase 'a' + cy.get("@input") + .realClick() + .realType("a"); + + cy.get("@input") + .should("have.value", "apple"); + + // Verify text selection (typeahead highlighting) + cy.get("@input") + .then($input => { + const input = $input[0] as HTMLInputElement; + expect(input.selectionStart).to.equal(1); + expect(input.selectionEnd).to.equal(5); + }); + + // Press Enter to accept - should use original suggestion capitalization "Apple" + cy.realPress("Enter"); + + cy.get("@input") + .should("have.value", "Apple"); + }); + + it("should preserve uppercase typed letters during typeahead", () => { + cy.mount( + + + + + ); + + cy.get("#capitalization-test-upper") + .shadow() + .find("input") + .as("input"); + + // Type uppercase 'A' - should show 'Apple' with user's uppercase 'A' + cy.get("@input") + .realClick() + .realType("A"); + + cy.get("@input") + .should("have.value", "Apple"); + + // Press Enter to accept - should use original suggestion capitalization "apple" + cy.realPress("Enter"); + + cy.get("@input") + .should("have.value", "apple"); + }); + + it("should match suggestions regardless of capitalization and use original on Enter", () => { + cy.mount( + + + + + ); + + cy.get("#exact-match-test") + .shadow() + .find("input") + .as("input"); + + // Type "Ap" matching suggestion "ap" + cy.get("@input") + .realClick() + .realType("Ap"); + + // During typing, user's capitalization is preserved + cy.get("@input") + .should("have.value", "Ap"); + + // Press Enter - should use original suggestion capitalization "ap" + cy.realPress("Enter"); + + cy.get("@input") + .should("have.value", "ap"); + }); + + it("should preserve user's typed capitalization through multiple characters", () => { + cy.mount( + + + + + ); + + cy.get("#multi-char-test") + .shadow() + .find("input") + .as("input"); + + // Type "bAn" with mixed capitalization + cy.get("@input") + .realClick() + .realType("bAn"); + + // Should show suggestion with user's typed capitalization + cy.get("@input") + .should("have.value", "bAnANA"); + + // Press Enter - should use original suggestion capitalization "BANANA" + cy.realPress("Enter"); + + cy.get("@input") + .should("have.value", "BANANA"); + }); + + it("should work with selection-change event and preserve original capitalization", () => { + const onChangeSpy = cy.spy().as("onChange"); + const onSelectionChangeSpy = cy.spy().as("onSelectionChange"); + + cy.mount( + + + + + ); + + cy.get("#selection-change-test") + .shadow() + .find("input") + .as("input"); + + // Type lowercase 'o' + cy.get("@input") + .realClick() + .realType("o"); + + cy.get("@input") + .should("have.value", "orange"); + + // Press Enter to trigger selection-change + cy.realPress("Enter"); + + // Value should be original suggestion capitalization + cy.get("@input") + .should("have.value", "Orange"); + + // Verify both events were called + cy.get("@onChange").should("have.been.calledOnce"); + cy.get("@onSelectionChange").should("have.been.calledOnce"); + }); + + it("should clear matched item on Escape and restore typed value", () => { + cy.mount( + + + + ); + + cy.get("#escape-test") + .shadow() + .find("input") + .as("input"); + + // Type 'a' to trigger typeahead + cy.get("@input") + .realClick() + .realType("a"); + + cy.get("@input") + .should("have.value", "apple"); + + // Press Escape to cancel autocomplete + cy.realPress("Escape"); + + cy.get("@input") + .should("have.value", "a"); + + // Now press Enter - should not select anything + cy.realPress("Enter"); + + cy.get("@input") + .should("have.value", "a"); + }); + }); }); diff --git a/packages/main/cypress/specs/Input.mobile.cy.tsx b/packages/main/cypress/specs/Input.mobile.cy.tsx index 60c97f9e96da..31be35f278d8 100644 --- a/packages/main/cypress/specs/Input.mobile.cy.tsx +++ b/packages/main/cypress/specs/Input.mobile.cy.tsx @@ -272,7 +272,7 @@ describe("Typeahead", () => { .ui5ResponsivePopoverOpened(); cy.get("#myInput2").shadow().find(".ui5-input-inner-phone").should("be.focused"); - cy.get("#myInput2").shadow().find(".ui5-input-inner-phone").realType("c"); + cy.get("#myInput2").shadow().find(".ui5-input-inner-phone").realType("C"); cy.get("#myInput2").shadow().find(".ui5-input-inner-phone").should("have.value", "Cozy"); }); @@ -313,7 +313,7 @@ describe("Typeahead", () => { .ui5ResponsivePopoverOpened(); cy.get("#input-custom-flat").shadow().find(".ui5-input-inner-phone").should("be.focused"); - cy.get("#input-custom-flat").shadow().find(".ui5-input-inner-phone").realType("a"); + cy.get("#input-custom-flat").shadow().find(".ui5-input-inner-phone").realType("A"); cy.get("#input-custom-flat").shadow().find(".ui5-input-inner-phone").should("have.value", "Albania"); }); }); diff --git a/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx b/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx index 67726bb4707d..e6503a754606 100644 --- a/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.mobile.cy.tsx @@ -197,7 +197,7 @@ describe("Typeahead", () => { .find("[ui5-input]") .as("respPopoverInput") .realClick() - .realType("c"); + .realType("C"); cy.get("@respPopoverInput") .should("have.value", "Cosy"); diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index 277705c8672e..1d2304846cc4 100644 --- a/packages/main/cypress/specs/MultiInput.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.cy.tsx @@ -575,11 +575,78 @@ describe("MultiInput tokens", () => { .realClick(); cy.get("@input") - .type("b"); + .type("B"); cy.get("[ui5-multi-input]") .should("have.attr", "value", "Bulgaria"); }); + + it("should not select multiple suggestions when switching between typed values", () => { + cy.mount( + + + Bulgaria + + + Canada + + + Germany + + + Austria + + + ); + + cy.get("[ui5-multi-input]") + .shadow() + .find("input") + .as("input"); + + cy.get("@input") + .realClick() + .realType("Bul"); + + // Wait for popover to open + cy.get("[ui5-multi-input]") + .shadow() + .find("[ui5-responsive-popover]") + .ui5ResponsivePopoverOpened(); + + // Bulgaria is first item (index 0), check it's selected + cy.get("[ui5-multi-input]") + .find("[ui5-suggestion-item-custom]") + .eq(0) + .should("have.prop", "selected", true); + + // Other items should not be selected + cy.get("[ui5-multi-input]") + .find("[ui5-suggestion-item-custom]") + .eq(1) + .should("have.prop", "selected", false); + + // Clear and type "Cana" + cy.get("@input") + .clear() + .realType("Cana"); + + // Canada is second item (index 1), check it's selected + cy.get("[ui5-multi-input]") + .find("[ui5-suggestion-item-custom]") + .eq(1) + .should("have.prop", "selected", true); + + // Bulgaria (index 0) should NOT be selected anymore + cy.get("[ui5-multi-input]") + .find("[ui5-suggestion-item-custom]") + .eq(0) + .should("have.prop", "selected", false); + }); }); describe("MultiInput Truncated Token", () => { diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index 335175c39cba..115142055ac7 100644 --- a/packages/main/src/Input.ts +++ b/packages/main/src/Input.ts @@ -637,6 +637,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _clearIconClicked?: boolean; _focusedAfterClear: boolean; _changeToBeFired?: boolean; // used to wait change event firing after suggestion item selection + _matchedSuggestionItem?: IInputSuggestionItemSelectable; // stores the original matched suggestion for preserving case _performTextSelection?: boolean; _isLatestValueFromSuggestions: boolean; _isChangeTriggeredBySuggestion: boolean; @@ -801,6 +802,8 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement this._handleTypeAhead(item); } this._selectMatchingItem(item); + } else { + this._matchedSuggestionItem = undefined; } } } @@ -1027,9 +1030,13 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement // if a group item is focused, this is false const suggestionItemPressed = !!(this.Suggestions?.onEnter(e)); const innerInput = this.getInputDOMRefSync()!; - const matchingItem = this._selectableItems.find(item => { - return item.text === this.value; - }); + + let matchingItem = this._matchedSuggestionItem; + if (!matchingItem) { + matchingItem = this._selectableItems.find(item => { + return item.text?.toLowerCase() === this.value.toLowerCase(); + }); + } if (matchingItem) { const itemText = matchingItem.text || ""; @@ -1090,6 +1097,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement const isAutoCompleted = innerInput.selectionEnd! - innerInput.selectionStart! > 0; this.isTyping = false; + this._matchedSuggestionItem = undefined; if (this.value !== this.previousValue && this.value !== this.lastConfirmedValue && !this.open) { this.value = this.lastConfirmedValue ? this.lastConfirmedValue : this.previousValue; @@ -1325,6 +1333,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _selectMatchingItem(item: IInputSuggestionItemSelectable) { item.selected = true; + this._matchedSuggestionItem = item; } _filterItems(value: string) { @@ -1374,11 +1383,15 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } _handleTypeAhead(item: IInputSuggestionItemSelectable) { - const value = item.text ? item.text : ""; + const suggestionText = item.text ? item.text : ""; + const typedValue = this.typedInValue; - this.value = value; - this._performTextSelection = true; + // Preserve the user's typed input case during typing + if (suggestionText.toLowerCase().startsWith(typedValue.toLowerCase())) { + this.value = typedValue + suggestionText.substring(typedValue.length); + } + this._performTextSelection = true; this._shouldAutocomplete = false; } @@ -1527,7 +1540,17 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement return; } - const itemText = item.text || ""; + let originalItem = item; + if (this._matchedSuggestionItem) { + const matchedText = this._matchedSuggestionItem.text?.toLowerCase() || ""; + const itemText = item.text?.toLowerCase() || ""; + // Only use matched item if keyboard navigation or if it's the same item (case-insensitive) + if (keyboardUsed || matchedText === itemText) { + originalItem = this._matchedSuggestionItem; + } + } + + const itemText = originalItem.text || ""; const fireChange = keyboardUsed ? this.valueBeforeItemSelection !== itemText : this.previousValue !== itemText; @@ -1549,6 +1572,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement } this.valueBeforeSelectionStart = ""; + this._matchedSuggestionItem = undefined; this.isTyping = false; this.open = false; @@ -1563,6 +1587,11 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement this.value = itemValue || ""; this._performTextSelection = true; + + // Update the matched item when navigating with arrows to preserve correct case on Enter + if (!this._isGroupItem(item)) { + this._matchedSuggestionItem = item as IInputSuggestionItemSelectable; + } } fireEventByAction(action: INPUT_ACTIONS, e: InputEvent) { diff --git a/packages/main/src/Token.ts b/packages/main/src/Token.ts index 352119238981..fca069913e6d 100644 --- a/packages/main/src/Token.ts +++ b/packages/main/src/Token.ts @@ -13,7 +13,7 @@ import { } from "@ui5/webcomponents-base/dist/Keys.js"; import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js"; import i18n from "@ui5/webcomponents-base/dist/decorators/i18n.js"; -import { TOKEN_ARIA_DELETABLE, TOKEN_ARIA_LABEL, TOKEN_ARIA_REMOVE } from "./generated/i18n/i18n-defaults.js"; +import { TOKEN_ARIA_DELETE, TOKEN_ARIA_DELETABLE, TOKEN_ARIA_LABEL } from "./generated/i18n/i18n-defaults.js"; import type { IIcon } from "./Icon.js"; import type { IToken } from "./MultiInput.js"; @@ -195,7 +195,7 @@ class Token extends UI5Element implements IToken { } get tokenDeletableText() { - return Token.i18nBundle.getText(TOKEN_ARIA_REMOVE); + return Token.i18nBundle.getText(TOKEN_ARIA_DELETE); } get textDom() { diff --git a/packages/main/src/features/InputSuggestions.ts b/packages/main/src/features/InputSuggestions.ts index d15b871f92c1..ad625d42c8c8 100644 --- a/packages/main/src/features/InputSuggestions.ts +++ b/packages/main/src/features/InputSuggestions.ts @@ -387,7 +387,7 @@ class Suggestions { _deselectItems() { const items = this._getItems(); items.forEach(item => { - if (item.hasAttribute("ui5-suggestion-item")) { + if (item.hasAttribute("ui5-suggestion-item") || item.hasAttribute("ui5-suggestion-item-custom")) { (item as SuggestionItem).selected = false; } diff --git a/packages/main/src/i18n/messagebundle.properties b/packages/main/src/i18n/messagebundle.properties index dabc09a707dd..7ec2adfba0a5 100644 --- a/packages/main/src/i18n/messagebundle.properties +++ b/packages/main/src/i18n/messagebundle.properties @@ -617,7 +617,7 @@ DATETIME_PICKER_TIME_BUTTON=Time TOKEN_ARIA_DELETABLE=Deletable #XACT: ARIA announcement for token removal -TOKEN_ARIA_REMOVE=Remove +TOKEN_ARIA_DELETE=Delete #XACT: ARIA announcement for token label TOKEN_ARIA_LABEL=Token diff --git a/packages/main/test/pages/Input.html b/packages/main/test/pages/Input.html index 12b97241c8c0..e0c9cca883a2 100644 --- a/packages/main/test/pages/Input.html +++ b/packages/main/test/pages/Input.html @@ -609,8 +609,17 @@

Input Composition

Check Validity -

+

Capitalization suggestions

+ + + + + + + +

+