From 3bfd0f143a74930488b3527b1fd25030ae69748b Mon Sep 17 00:00:00 2001 From: aleksandar-terziev Date: Wed, 10 Sep 2025 23:26:00 +0300 Subject: [PATCH] feat(ui5-input,ui5-multi-input,ui5-combobox,ui5-multi-combobox): composition handling --- packages/main/cypress/specs/ComboBox.cy.tsx | 190 ++++++++++++++++-- packages/main/cypress/specs/Input.cy.tsx | 167 +++++++++++++++ .../main/cypress/specs/MultiComboBox.cy.tsx | 181 ++++++++++++++++- packages/main/cypress/specs/MultiInput.cy.tsx | 164 +++++++++++++++ packages/main/src/ComboBox.ts | 47 ++++- packages/main/src/Input.ts | 46 ++++- packages/main/src/MultiComboBox.ts | 60 +++++- packages/main/src/MultiInput.ts | 2 +- .../main/src/features/InputComposition.ts | 40 ++++ packages/main/test/pages/ComboBox.html | 37 ++++ packages/main/test/pages/Input.html | 30 +++ packages/main/test/pages/MultiComboBox.html | 41 ++++ packages/main/test/pages/MultiInput.html | 29 +++ 13 files changed, 1016 insertions(+), 18 deletions(-) create mode 100644 packages/main/src/features/InputComposition.ts diff --git a/packages/main/cypress/specs/ComboBox.cy.tsx b/packages/main/cypress/specs/ComboBox.cy.tsx index 34e5e9a6f12a..7c59ff1f61a1 100644 --- a/packages/main/cypress/specs/ComboBox.cy.tsx +++ b/packages/main/cypress/specs/ComboBox.cy.tsx @@ -398,25 +398,25 @@ describe("General Interaction", () => { it("should not render ComboBox items list when no items are present", () => { cy.mount( - - {/* No ComboBox items */} - - ); + + {/* No ComboBox items */} + + ); cy.get("[ui5-combobox]") - .as("combo") - .shadow() - .find("ui5-responsive-popover") - .as("popover") - .should("have.attr", "open"); + .as("combo") + .shadow() + .find("ui5-responsive-popover") + .as("popover") + .should("have.attr", "open"); cy.get("@popover") - .find(".ui5-responsive-popover-header.ui5-valuestatemessage-root") - .should("exist"); + .find(".ui5-responsive-popover-header.ui5-valuestatemessage-root") + .should("exist"); cy.get("@popover") - .find("ui5-list") - .should("not.exist"); + .find("ui5-list") + .should("not.exist"); }); }); @@ -2744,3 +2744,167 @@ describe("Scrolling", () => { .should("be.visible"); }); }); + +describe("ComboBox Composition", () => { + it("should handle Korean composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "사랑" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "사랑" }); + + cy.get("@nativeInput") + .invoke("val", "사랑") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@combobox").should("have.prop", "_isComposing", false); + + cy.get("@combobox").should("have.attr", "value", "사랑"); + + cy.get("@combobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@combobox") + .realPress("Enter"); + + cy.get("@combobox") + .should("have.attr", "value", "사랑"); + }); + + it("should handle Japanese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "ありがとう" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "ありがとう" }); + + cy.get("@nativeInput") + .invoke("val", "ありがとう") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@combobox").should("have.prop", "_isComposing", false); + + cy.get("@combobox").should("have.attr", "value", "ありがとう"); + + cy.get("@combobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@combobox") + .realPress("Enter"); + + cy.get("@combobox") + .should("have.attr", "value", "ありがとう"); + }); + + it("should handle Chinese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-combobox]") + .as("combobox") + .realClick(); + + cy.get("@combobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "谢谢" }); + + cy.get("@combobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "谢谢" }); + + cy.get("@nativeInput") + .invoke("val", "谢谢") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@combobox").should("have.prop", "_isComposing", false); + + cy.get("@combobox").should("have.attr", "value", "谢谢"); + + cy.get("@combobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@combobox") + .realPress("Enter"); + + cy.get("@combobox") + .should("have.attr", "value", "谢谢"); + }); +}); diff --git a/packages/main/cypress/specs/Input.cy.tsx b/packages/main/cypress/specs/Input.cy.tsx index fe92163a7559..4081a16b5e9f 100644 --- a/packages/main/cypress/specs/Input.cy.tsx +++ b/packages/main/cypress/specs/Input.cy.tsx @@ -2630,3 +2630,170 @@ describe("Property open", () => { .ui5ResponsivePopoverClosed(); }); }); + +describe("Input Composition", () => { + it("should handle Korean composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "사랑" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "사랑" }); + + cy.get("@nativeInput") + .invoke("val", "사랑") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@input").should("have.prop", "_isComposing", false); + + cy.get("@input").should("have.attr", "value", "사랑"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@input") + .realPress("Enter"); + + cy.get("@input") + .should("have.attr", "value", "사랑"); + }); + + it("should handle Japanese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "ありがとう" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "ありがとう" }); + + cy.get("@nativeInput") + .invoke("val", "ありがとう") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@input").should("have.prop", "_isComposing", false); + + cy.get("@input").should("have.attr", "value", "ありがとう"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@input") + .realPress("Enter"); + + cy.get("@input") + .should("have.attr", "value", "ありがとう"); + }); + + it("should handle Chinese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-input]") + .as("input") + .realClick(); + + cy.get("@input") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "谢谢" }); + + cy.get("@input").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "谢谢" }); + + cy.get("@nativeInput") + .invoke("val", "谢谢") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@input").should("have.prop", "_isComposing", false); + + cy.get("@input").should("have.attr", "value", "谢谢"); + + cy.get("@input") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@input") + .realPress("Enter"); + + cy.get("@input") + .should("have.attr", "value", "谢谢"); + }); +}); diff --git a/packages/main/cypress/specs/MultiComboBox.cy.tsx b/packages/main/cypress/specs/MultiComboBox.cy.tsx index 5c1b87e8ebba..151533319f83 100644 --- a/packages/main/cypress/specs/MultiComboBox.cy.tsx +++ b/packages/main/cypress/specs/MultiComboBox.cy.tsx @@ -3905,4 +3905,183 @@ describe("Keyboard Handling", () => { .find("ui5-responsive-popover") .should("not.have.attr", "open") }); -}); \ No newline at end of file +}); + +describe("MultiComboBox Composition", () => { + it("should handle Korean composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multicombobox") + .realClick(); + + cy.get("@multicombobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "사랑" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "사랑" }); + + cy.get("@nativeInput") + .invoke("val", "사랑") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", false); + + cy.get("@multicombobox").should("have.attr", "value", "사랑"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multicombobox") + .realPress("Enter"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .should("have.length", 1); + + cy.get("@multicombobox").should("have.attr", "value", ""); + }); + + it("should handle Japanese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multicombobox") + .realClick(); + + cy.get("@multicombobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "ありがとう" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "ありがとう" }); + + cy.get("@nativeInput") + .invoke("val", "ありがとう") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", false); + + cy.get("@multicombobox").should("have.attr", "value", "ありがとう"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multicombobox") + .realPress("Enter"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .should("have.length", 1); + + cy.get("@multicombobox").should("have.attr", "value", ""); + }); + + it("should handle Chinese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-combobox]") + .as("multicombobox") + .realClick(); + + cy.get("@multicombobox") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "谢谢" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "谢谢" }); + + cy.get("@nativeInput") + .invoke("val", "谢谢") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multicombobox").should("have.prop", "_isComposing", false); + + cy.get("@multicombobox").should("have.attr", "value", "谢谢"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multicombobox") + .realPress("Enter"); + + cy.get("@multicombobox") + .shadow() + .find("[ui5-tokenizer]") + .find("[ui5-token]") + .should("have.length", 1); + + cy.get("@multicombobox").should("have.attr", "value", ""); + }); +}); diff --git a/packages/main/cypress/specs/MultiInput.cy.tsx b/packages/main/cypress/specs/MultiInput.cy.tsx index 92e11bf9d033..905ab146d9cc 100644 --- a/packages/main/cypress/specs/MultiInput.cy.tsx +++ b/packages/main/cypress/specs/MultiInput.cy.tsx @@ -1335,3 +1335,167 @@ describe("Keyboard handling", () => { .should("have.attr", "value-state", "None"); }); }); + +describe("MultiInput Composition", () => { + it("should handle Korean composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-input]") + .as("multiinput") + .realClick(); + + cy.get("@multiinput") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "사랑" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "사랑" }); + + cy.get("@nativeInput") + .invoke("val", "사랑") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", false); + + cy.get("@multiinput").should("have.attr", "value", "사랑"); + + cy.get("@multiinput") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multiinput") + .realPress("Enter"); + + cy.get("@multiinput").should("have.attr", "value", "사랑"); + }); + + it("should handle Japanese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-input]") + .as("multiinput") + .realClick(); + + cy.get("@multiinput") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "ありがとう" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "ありがとう" }); + + cy.get("@nativeInput") + .invoke("val", "ありがとう") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", false); + + cy.get("@multiinput").should("have.attr", "value", "ありがとう"); + + cy.get("@multiinput") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multiinput") + .realPress("Enter"); + + cy.get("@multiinput").should("have.attr", "value", "ありがとう"); + }); + + it("should handle Chinese composition correctly", () => { + cy.mount( + + + + + + + ); + + cy.get("[ui5-multi-input]") + .as("multiinput") + .realClick(); + + cy.get("@multiinput") + .shadow() + .find("input") + .as("nativeInput") + .focus(); + + cy.get("@nativeInput").trigger("compositionstart", { data: "" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionupdate", { data: "谢谢" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", true); + + cy.get("@nativeInput").trigger("compositionend", { data: "谢谢" }); + + cy.get("@nativeInput") + .invoke("val", "谢谢") + .trigger("input", { inputType: "insertCompositionText" }); + + cy.get("@multiinput").should("have.prop", "_isComposing", false); + + cy.get("@multiinput").should("have.attr", "value", "谢谢"); + + cy.get("@multiinput") + .shadow() + .find("[ui5-responsive-popover]") + .as("popover") + .ui5ResponsivePopoverOpened(); + + cy.get("@multiinput") + .realPress("Enter"); + + cy.get("@multiinput").should("have.attr", "value", "谢谢"); + }); +}); diff --git a/packages/main/src/ComboBox.ts b/packages/main/src/ComboBox.ts index 9c0d0ce0ddcf..9b7a691a254c 100644 --- a/packages/main/src/ComboBox.ts +++ b/packages/main/src/ComboBox.ts @@ -89,6 +89,7 @@ import type ComboBoxFilter from "./types/ComboBoxFilter.js"; import PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; import type Input from "./Input.js"; import type { InputEventDetail } from "./Input.js"; +import type InputComposition from "./features/InputComposition.js"; const SKIP_ITEMS_SIZE = 10; @@ -405,6 +406,14 @@ class ComboBox extends UI5Element implements IFormInputElement { @property({ type: Array }) _linksListenersArray: Array<(args: any) => void> = []; + /** + * Indicates whether IME composition is currently active + * @default false + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _isComposing = false; + /** * Defines the component items. * @public @@ -449,8 +458,10 @@ class ComboBox extends UI5Element implements IFormInputElement { _selectedItemText = ""; _userTypedValue = ""; _valueStateLinks: Array = []; + _composition?: InputComposition; @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; + static composition: typeof InputComposition; get formValidityMessage() { return ComboBox.i18nBundle.getText(FORM_TEXTFIELD_REQUIRED); @@ -529,8 +540,13 @@ class ComboBox extends UI5Element implements IFormInputElement { } } + onEnterDOM() { + this._enableComposition(); + } + onExitDOM() { this._removeLinksEventListeners(); + this._composition?.removeEventListeners(); } _focusin(e: FocusEvent) { @@ -705,7 +721,7 @@ class ComboBox extends UI5Element implements IFormInputElement { this._clearFocus(); // autocomplete - if (shouldAutocomplete && !isAndroid()) { + if (shouldAutocomplete && !this._isComposing && !isAndroid()) { this._handleTypeAhead(value, value); } @@ -1322,6 +1338,35 @@ class ComboBox extends UI5Element implements IFormInputElement { announce(valueStateText, InvisibleMessageMode.Polite); } } + /** + * Enables IME composition handling. + * Dynamically loads the InputComposition feature and sets up event listeners. + * @private + */ + _enableComposition() { + if (this._composition) { + return; + } + + const setup = (InputCompositionClass: typeof InputComposition) => { + this._composition = new InputCompositionClass({ + getInputEl: () => this.inner, + updateCompositionState: (isComposing: boolean) => { + this._isComposing = isComposing; + }, + }); + this._composition.addEventListeners(); + }; + + if (ComboBox.composition) { + setup(ComboBox.composition); + } else { + import("./features/InputComposition.js").then(CompositionModule => { + ComboBox.composition = CompositionModule.default; + setup(CompositionModule.default); + }); + } + } get _headerTitleText() { return ComboBox.i18nBundle.getText(INPUT_SUGGESTIONS_TITLE); diff --git a/packages/main/src/Input.ts b/packages/main/src/Input.ts index 8447bbbd2d01..59d8317b20ff 100644 --- a/packages/main/src/Input.ts +++ b/packages/main/src/Input.ts @@ -99,6 +99,7 @@ import SuggestionsCss from "./generated/themes/Suggestions.css.js"; import type { ListItemClickEventDetail, ListSelectionChangeEventDetail } from "./List.js"; import type ResponsivePopover from "./ResponsivePopover.js"; import type InputKeyHint from "./types/InputKeyHint.js"; +import type InputComposition from "./features/InputComposition.js"; /** * Interface for components that represent a suggestion item, usable in `ui5-input` @@ -566,6 +567,14 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement @property({ type: Array }) _linksListenersArray: Array<(args: any) => void> = []; + /** + * Indicates whether IME composition is currently active + * @default false + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _isComposing = false; + /** * Defines the suggestion items. * @@ -628,8 +637,10 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement _isLatestValueFromSuggestions: boolean; _isChangeTriggeredBySuggestion: boolean; _valueStateLinks: Array; + _composition?: InputComposition; @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; + static composition: typeof InputComposition; /** * Indicates whether link navigation is being handled. @@ -707,12 +718,14 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement onEnterDOM() { ResizeHandler.register(this, this._handleResizeBound); registerUI5Element(this, this._updateAssociatedLabelsTexts.bind(this)); + this._enableComposition(); } onExitDOM() { ResizeHandler.deregister(this, this._handleResizeBound); deregisterUI5Element(this); this._removeLinksEventListeners(); + this._composition?.removeEventListeners(); } _highlightSuggestionItem(item: SuggestionItem) { @@ -776,7 +789,9 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement if (this._shouldAutocomplete && !isAndroid() && !autoCompletedChars && !this._isKeyNavigation) { const item = this._getFirstMatchingItem(value); if (item) { - this._handleTypeAhead(item); + if (!this._isComposing) { + this._handleTypeAhead(item); + } this._selectMatchingItem(item); } } @@ -1345,6 +1360,7 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement // Set initial focus to the native input if (isPhone()) { (this.getInputDOMRef())!.focus(); + this._composition?.addEventListeners(); } this._handlePickerAfterOpen(); @@ -1422,6 +1438,34 @@ class Input extends UI5Element implements SuggestionComponent, IFormInputElement }); } } + /** + * Enables IME composition handling. + * Dynamically loads the InputComposition feature and sets up event listeners. + * @private + */ + _enableComposition() { + if (this._composition) { + return; + } + const setup = (FeatureClass: typeof InputComposition) => { + this._composition = new FeatureClass({ + getInputEl: () => this.getInputDOMRefSync(), + updateCompositionState: (isComposing: boolean) => { + this._isComposing = isComposing; + }, + }); + this._composition.addEventListeners(); + }; + + if (Input.composition) { + setup(Input.composition); + } else { + import("./features/InputComposition.js").then(CompositionModule => { + Input.composition = CompositionModule.default; + setup(CompositionModule.default); + }); + } + } acceptSuggestion(item: IInputSuggestionItemSelectable, keyboardUsed: boolean) { if (this._isGroupItem(item)) { diff --git a/packages/main/src/MultiComboBox.ts b/packages/main/src/MultiComboBox.ts index cf45801924c9..50838865898f 100644 --- a/packages/main/src/MultiComboBox.ts +++ b/packages/main/src/MultiComboBox.ts @@ -113,6 +113,7 @@ import Input from "./Input.js"; import type { InputEventDetail } from "./Input.js"; import type PopoverHorizontalAlign from "./types/PopoverHorizontalAlign.js"; import SuggestionItem from "./SuggestionItem.js"; +import type InputComposition from "./features/InputComposition.js"; /** * Interface for components that may be slotted inside a `ui5-multi-combobox` as items @@ -479,6 +480,14 @@ class MultiComboBox extends UI5Element implements IFormInputElement { @property({ type: Array }) _linksListenersArray: Array<(args: any) => void> = []; + /** + * Indicates whether IME composition is currently active + * @default false + * @private + */ + @property({ type: Boolean, noAttribute: true }) + _isComposing = false; + /** * Defines the component items. * @public @@ -533,8 +542,11 @@ class MultiComboBox extends UI5Element implements IFormInputElement { _itemsBeforeOpen: Array; selectedItems: Array; _valueStateLinks: Array; + _composition?: InputComposition; + _suppressNextLiveChange: boolean; // prevent unwanted live change events during IME composition @i18n("@ui5/webcomponents") static i18nBundle: I18nBundle; + static composition: typeof InputComposition; get formValidityMessage() { return MultiComboBox.i18nBundle.getText(FORM_MIXED_TEXTFIELD_REQUIRED); @@ -584,15 +596,18 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this._lastValue = this.getAttribute("value") || ""; this.currentItemIdx = -1; this._valueStateLinks = []; + this._suppressNextLiveChange = false; } onEnterDOM() { ResizeHandler.register(this, this._handleResizeBound); + this._enableComposition(); } onExitDOM() { ResizeHandler.deregister(this, this._handleResizeBound); this._removeLinksEventListeners(); + this._composition?.removeEventListeners(); } _handleResize() { @@ -683,6 +698,12 @@ class MultiComboBox extends UI5Element implements IFormInputElement { } _inputLiveChange(e: InputEvent) { + // This ensures proper input clearing after Enter-based token creation during composition + if (this._suppressNextLiveChange) { + this._suppressNextLiveChange = false; + return; + } + const input = e.target as HTMLInputElement; const value: string = input.value; const filteredItems: Array = this._filterItems(value); @@ -1349,6 +1370,11 @@ class MultiComboBox extends UI5Element implements IFormInputElement { this._previouslySelectedItems = this._getSelectedItems(); matchingItem.selected = true; this.value = ""; + // during composition prevent _inputLiveChange for proper input clearing + if (this._isComposing) { + this._suppressNextLiveChange = true; + } + const changePrevented = this.fireSelectionChange(); if (changePrevented) { @@ -1728,7 +1754,10 @@ class MultiComboBox extends UI5Element implements IFormInputElement { // Keep the original typed in text intact this.valueBeforeAutoComplete = value; - item && this._handleTypeAhead(item, value); + // Prevent typeahead during composition to avoid interfering with the composition process + if (!this._isComposing && item) { + this._handleTypeAhead(item, value); + } } if (this._shouldFilterItems) { @@ -1897,6 +1926,35 @@ class MultiComboBox extends UI5Element implements IFormInputElement { } } } + /** + * Enables IME composition handling. + * Dynamically loads the InputComposition feature and sets up event listeners. + * @private + */ + _enableComposition() { + if (this._composition) { + return; + } + + const setup = (InputCompositionClass: typeof InputComposition) => { + this._composition = new InputCompositionClass({ + getInputEl: () => this._innerInput, + updateCompositionState: (isComposing: boolean) => { + this._isComposing = isComposing; + }, + }); + this._composition.addEventListeners(); + }; + + if (MultiComboBox.composition) { + setup(MultiComboBox.composition); + } else { + import("./features/InputComposition.js").then(CompositionModule => { + MultiComboBox.composition = CompositionModule.default; + setup(CompositionModule.default); + }); + } + } get editable() { return !this.readonly; diff --git a/packages/main/src/MultiInput.ts b/packages/main/src/MultiInput.ts index 7a8dadc06522..ee87cc09e75b 100644 --- a/packages/main/src/MultiInput.ts +++ b/packages/main/src/MultiInput.ts @@ -220,7 +220,7 @@ class MultiInput extends Input implements IFormInputElement { } _onkeydown(e: KeyboardEvent) { - super._onkeydown(e); + !this._isComposing && super._onkeydown(e); const target = e.target as HTMLInputElement; const isHomeInBeginning = isHome(e) && target.selectionStart === 0; diff --git a/packages/main/src/features/InputComposition.ts b/packages/main/src/features/InputComposition.ts new file mode 100644 index 000000000000..17a883145174 --- /dev/null +++ b/packages/main/src/features/InputComposition.ts @@ -0,0 +1,40 @@ +export interface CompositionComponent { + getInputEl: () => HTMLInputElement | null; + updateCompositionState: (isComposing: boolean) => void; +} + +export default class InputComposition { + _component: CompositionComponent; + + constructor(component: CompositionComponent) { + this._component = component; + } + + _onComposition = () => { + this._component.updateCompositionState(true); + }; + + _onCompositionEnd = () => { + this._component.updateCompositionState(false); + }; + + addEventListeners() { + const el = this._component.getInputEl(); + if (!el) { + return; + } + el.addEventListener("compositionstart", this._onComposition); + el.addEventListener("compositionupdate", this._onComposition); + el.addEventListener("compositionend", this._onCompositionEnd); + } + + removeEventListeners() { + const el = this._component.getInputEl(); + if (!el) { + return; + } + el.removeEventListener("compositionstart", this._onComposition); + el.removeEventListener("compositionupdate", this._onComposition); + el.removeEventListener("compositionend", this._onCompositionEnd); + } +} diff --git a/packages/main/test/pages/ComboBox.html b/packages/main/test/pages/ComboBox.html index 8ee4c6dfb0c3..29bf7c4db79c 100644 --- a/packages/main/test/pages/ComboBox.html +++ b/packages/main/test/pages/ComboBox.html @@ -308,6 +308,43 @@

ComboBox in Compact

+
+

ComboBox Composition

+ +
+ ComboBox - Korean suggestions + + + + + + + +
+ +
+ ComboBox - Japanese suggestions + + + + + + + +
+ +
+ ComboBox - Chinese suggestions + + + + + + + +
+
+
diff --git a/packages/main/test/pages/Input.html b/packages/main/test/pages/Input.html index 22811f18cf15..4e18da99c108 100644 --- a/packages/main/test/pages/Input.html +++ b/packages/main/test/pages/Input.html @@ -534,6 +534,36 @@

Input - open suggestions picker

Input with just accessible description +
+
+

Input Composition

+ Input Composition with Korean Suggestions +
+ + + + + + +
+ Input Composition with Japanese Suggestions +
+ + + + + + +
+ Input Composition with Chinese Suggestions +
+ + + + + + +
+
+

MultiComboBox Composition

+ +
+ MultiComboBox Composition Korean + +
+ + + + + + +
+ +
+ MultiComboBox Composition Japanese + +
+ + + + + + +
+ +
+ MultiComboBox Composition Chinese + +
+ + + + + + +
+ +
+
MultiComboBox with items diff --git a/packages/main/test/pages/MultiInput.html b/packages/main/test/pages/MultiInput.html index c7d16c221764..cfbd08f60c7c 100644 --- a/packages/main/test/pages/MultiInput.html +++ b/packages/main/test/pages/MultiInput.html @@ -302,6 +302,35 @@

Tokens + Suggestions

+
+

Composition

+ + Korean + + + + + + + + Japanese + + + + + + + + Chinese + + + + + + + +
+

Suggestions + showing wrapping