Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions packages/main/cypress/specs/TextArea.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -965,3 +965,192 @@ describe("Validation inside a form", () => {
.should("have.been.calledOnce");
});
});

describe("TextArea Composition", () => {
it("should handle Korean composition correctly", () => {
cy.mount(
<TextArea
id="textarea-composition-korean"
placeholder="Type in Korean ..."
/>
);

cy.get("[ui5-textarea]")
.as("textarea")
.realClick();

cy.get("@textarea")
.shadow()
.find("textarea")
.as("nativeTextarea")
.focus();

cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionupdate", { data: "사랑" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionend", { data: "사랑" });

cy.get("@nativeTextarea")
.invoke("val", "사랑")
.trigger("input", { inputType: "insertCompositionText" });

cy.get("@textarea").should("have.prop", "_isComposing", false);

cy.get("@textarea").should("have.attr", "value", "사랑");
});

it("should handle Japanese composition correctly", () => {
cy.mount(
<TextArea
id="textarea-composition-japanese"
placeholder="Type in Japanese ..."
/>
);

cy.get("[ui5-textarea]")
.as("textarea")
.realClick();

cy.get("@textarea")
.shadow()
.find("textarea")
.as("nativeTextarea")
.focus();

cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionupdate", { data: "ありがとう" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionend", { data: "ありがとう" });

cy.get("@nativeTextarea")
.invoke("val", "ありがとう")
.trigger("input", { inputType: "insertCompositionText" });

cy.get("@textarea").should("have.prop", "_isComposing", false);

cy.get("@textarea").should("have.attr", "value", "ありがとう");
});

it("should handle Chinese composition correctly", () => {
cy.mount(
<TextArea
id="textarea-composition-chinese"
placeholder="Type in Chinese ..."
/>
);

cy.get("[ui5-textarea]")
.as("textarea")
.realClick();

cy.get("@textarea")
.shadow()
.find("textarea")
.as("nativeTextarea")
.focus();

cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionupdate", { data: "谢谢" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionend", { data: "谢谢" });

cy.get("@nativeTextarea")
.invoke("val", "谢谢")
.trigger("input", { inputType: "insertCompositionText" });

cy.get("@textarea").should("have.prop", "_isComposing", false);

cy.get("@textarea").should("have.attr", "value", "谢谢");
});

it("should not revert value on Escape during composition", () => {
cy.mount(
<TextArea
id="textarea-composition-escape"
value="initial"
/>
);

cy.get("[ui5-textarea]")
.as("textarea")
.realClick();

cy.get("@textarea")
.shadow()
.find("textarea")
.as("nativeTextarea")
.focus();

cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });

cy.get("@textarea").should("have.prop", "_isComposing", true);

cy.get("@nativeTextarea").trigger("compositionupdate", { data: "테스트" });

cy.get("@nativeTextarea")
.invoke("val", "initial테스트")
.trigger("input", { inputType: "insertCompositionText" });

cy.get("@nativeTextarea").trigger("keydown", { key: "Escape", keyCode: 27 });

cy.get("@textarea").should("have.attr", "value", "initial테스트");

cy.get("@nativeTextarea").trigger("compositionend", { data: "테스트" });

cy.get("@textarea").should("have.prop", "_isComposing", false);

cy.get("@textarea").should("have.attr", "value", "initial테스트");
});

it("should revert value on Escape after composition ends", () => {
cy.mount(
<TextArea
id="textarea-composition-escape-after"
value="initial"
/>
);

cy.get("[ui5-textarea]")
.as("textarea")
.realClick();

cy.get("@textarea")
.shadow()
.find("textarea")
.as("nativeTextarea")
.focus();

cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });

cy.get("@nativeTextarea").trigger("compositionupdate", { data: "완료" });

cy.get("@nativeTextarea")
.invoke("val", "initial완료")
.trigger("input", { inputType: "insertCompositionText" });

cy.get("@nativeTextarea").trigger("compositionend", { data: "완료" });

cy.get("@textarea").should("have.prop", "_isComposing", false);

cy.get("@textarea").should("have.attr", "value", "initial완료");

cy.get("@nativeTextarea").realPress("Escape");

cy.get("@textarea").should("have.attr", "value", "initial");
});
});
42 changes: 42 additions & 0 deletions packages/main/src/TextArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
import { isEscape } from "@ui5/webcomponents-base/dist/Keys.js";
import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
import type Popover from "./Popover.js";
import type InputComposition from "./features/InputComposition.js";

import TextAreaTemplate from "./TextAreaTemplate.js";

Expand Down Expand Up @@ -326,6 +327,14 @@ class TextArea extends UI5Element implements IFormInputElement {
@property({ type: Number })
_width?: number;

/**
* Indicates whether IME composition is currently active
* @default false
* @private
*/
@property({ type: Boolean, noAttribute: true })
_isComposing = false;

/**
* Defines the value state message that will be displayed as pop up under the component.
* The value state message slot should contain only one root element.
Expand All @@ -347,9 +356,11 @@ class TextArea extends UI5Element implements IFormInputElement {
_keyDown?: boolean;
previousValue: string;
valueStatePopover?: Popover;
_composition?: InputComposition;

@i18n("@ui5/webcomponents")
static i18nBundle: I18nBundle;
static composition: typeof InputComposition;

get formValidityMessage() {
if (this.formValidity.valueMissing) {
Expand Down Expand Up @@ -387,10 +398,12 @@ class TextArea extends UI5Element implements IFormInputElement {

onEnterDOM() {
ResizeHandler.register(this, this._fnOnResize);
this._enableComposition();
}

onExitDOM() {
ResizeHandler.deregister(this, this._fnOnResize);
this._composition?.removeEventListeners();
}

onBeforeRendering() {
Expand Down Expand Up @@ -426,6 +439,10 @@ class TextArea extends UI5Element implements IFormInputElement {
_onkeydown(e: KeyboardEvent) {
this._keyDown = true;

if (this._isComposing) {
return;
}

if (isEscape(e)) {
const nativeTextArea = this.getInputDomRef();

Expand Down Expand Up @@ -575,6 +592,31 @@ class TextArea extends UI5Element implements IFormInputElement {
};
}

_enableComposition() {
if (this._composition) {
return;
}

const setup = (FeatureClass: typeof InputComposition) => {
this._composition = new FeatureClass({
getInputEl: () => this.getInputDomRef(),
updateCompositionState: (isComposing: boolean) => {
this._isComposing = isComposing;
},
});
this._composition.addEventListeners();
};

if (TextArea.composition) {
setup(TextArea.composition);
} else {
import("./features/InputComposition.js").then(CompositionModule => {
TextArea.composition = CompositionModule.default;
setup(CompositionModule.default);
});
}
}

get classes() {
return {
root: {
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/features/InputComposition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export interface CompositionComponent {
getInputEl: () => HTMLInputElement | null;
getInputEl: () => HTMLInputElement | HTMLTextAreaElement | null;
updateCompositionState: (isComposing: boolean) => void;
}

Expand Down
Loading