Skip to content

Commit fec44d7

Browse files
feat(ui5-textarea): implement composition handling (#13422)
1 parent 6d2f572 commit fec44d7

3 files changed

Lines changed: 232 additions & 1 deletion

File tree

packages/main/cypress/specs/TextArea.cy.tsx

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,3 +965,192 @@ describe("Validation inside a form", () => {
965965
.should("have.been.calledOnce");
966966
});
967967
});
968+
969+
describe("TextArea Composition", () => {
970+
it("should handle Korean composition correctly", () => {
971+
cy.mount(
972+
<TextArea
973+
id="textarea-composition-korean"
974+
placeholder="Type in Korean ..."
975+
/>
976+
);
977+
978+
cy.get("[ui5-textarea]")
979+
.as("textarea")
980+
.realClick();
981+
982+
cy.get("@textarea")
983+
.shadow()
984+
.find("textarea")
985+
.as("nativeTextarea")
986+
.focus();
987+
988+
cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });
989+
990+
cy.get("@textarea").should("have.prop", "_isComposing", true);
991+
992+
cy.get("@nativeTextarea").trigger("compositionupdate", { data: "사랑" });
993+
994+
cy.get("@textarea").should("have.prop", "_isComposing", true);
995+
996+
cy.get("@nativeTextarea").trigger("compositionend", { data: "사랑" });
997+
998+
cy.get("@nativeTextarea")
999+
.invoke("val", "사랑")
1000+
.trigger("input", { inputType: "insertCompositionText" });
1001+
1002+
cy.get("@textarea").should("have.prop", "_isComposing", false);
1003+
1004+
cy.get("@textarea").should("have.attr", "value", "사랑");
1005+
});
1006+
1007+
it("should handle Japanese composition correctly", () => {
1008+
cy.mount(
1009+
<TextArea
1010+
id="textarea-composition-japanese"
1011+
placeholder="Type in Japanese ..."
1012+
/>
1013+
);
1014+
1015+
cy.get("[ui5-textarea]")
1016+
.as("textarea")
1017+
.realClick();
1018+
1019+
cy.get("@textarea")
1020+
.shadow()
1021+
.find("textarea")
1022+
.as("nativeTextarea")
1023+
.focus();
1024+
1025+
cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });
1026+
1027+
cy.get("@textarea").should("have.prop", "_isComposing", true);
1028+
1029+
cy.get("@nativeTextarea").trigger("compositionupdate", { data: "ありがとう" });
1030+
1031+
cy.get("@textarea").should("have.prop", "_isComposing", true);
1032+
1033+
cy.get("@nativeTextarea").trigger("compositionend", { data: "ありがとう" });
1034+
1035+
cy.get("@nativeTextarea")
1036+
.invoke("val", "ありがとう")
1037+
.trigger("input", { inputType: "insertCompositionText" });
1038+
1039+
cy.get("@textarea").should("have.prop", "_isComposing", false);
1040+
1041+
cy.get("@textarea").should("have.attr", "value", "ありがとう");
1042+
});
1043+
1044+
it("should handle Chinese composition correctly", () => {
1045+
cy.mount(
1046+
<TextArea
1047+
id="textarea-composition-chinese"
1048+
placeholder="Type in Chinese ..."
1049+
/>
1050+
);
1051+
1052+
cy.get("[ui5-textarea]")
1053+
.as("textarea")
1054+
.realClick();
1055+
1056+
cy.get("@textarea")
1057+
.shadow()
1058+
.find("textarea")
1059+
.as("nativeTextarea")
1060+
.focus();
1061+
1062+
cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });
1063+
1064+
cy.get("@textarea").should("have.prop", "_isComposing", true);
1065+
1066+
cy.get("@nativeTextarea").trigger("compositionupdate", { data: "谢谢" });
1067+
1068+
cy.get("@textarea").should("have.prop", "_isComposing", true);
1069+
1070+
cy.get("@nativeTextarea").trigger("compositionend", { data: "谢谢" });
1071+
1072+
cy.get("@nativeTextarea")
1073+
.invoke("val", "谢谢")
1074+
.trigger("input", { inputType: "insertCompositionText" });
1075+
1076+
cy.get("@textarea").should("have.prop", "_isComposing", false);
1077+
1078+
cy.get("@textarea").should("have.attr", "value", "谢谢");
1079+
});
1080+
1081+
it("should not revert value on Escape during composition", () => {
1082+
cy.mount(
1083+
<TextArea
1084+
id="textarea-composition-escape"
1085+
value="initial"
1086+
/>
1087+
);
1088+
1089+
cy.get("[ui5-textarea]")
1090+
.as("textarea")
1091+
.realClick();
1092+
1093+
cy.get("@textarea")
1094+
.shadow()
1095+
.find("textarea")
1096+
.as("nativeTextarea")
1097+
.focus();
1098+
1099+
cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });
1100+
1101+
cy.get("@textarea").should("have.prop", "_isComposing", true);
1102+
1103+
cy.get("@nativeTextarea").trigger("compositionupdate", { data: "테스트" });
1104+
1105+
cy.get("@nativeTextarea")
1106+
.invoke("val", "initial테스트")
1107+
.trigger("input", { inputType: "insertCompositionText" });
1108+
1109+
cy.get("@nativeTextarea").trigger("keydown", { key: "Escape", keyCode: 27 });
1110+
1111+
cy.get("@textarea").should("have.attr", "value", "initial테스트");
1112+
1113+
cy.get("@nativeTextarea").trigger("compositionend", { data: "테스트" });
1114+
1115+
cy.get("@textarea").should("have.prop", "_isComposing", false);
1116+
1117+
cy.get("@textarea").should("have.attr", "value", "initial테스트");
1118+
});
1119+
1120+
it("should revert value on Escape after composition ends", () => {
1121+
cy.mount(
1122+
<TextArea
1123+
id="textarea-composition-escape-after"
1124+
value="initial"
1125+
/>
1126+
);
1127+
1128+
cy.get("[ui5-textarea]")
1129+
.as("textarea")
1130+
.realClick();
1131+
1132+
cy.get("@textarea")
1133+
.shadow()
1134+
.find("textarea")
1135+
.as("nativeTextarea")
1136+
.focus();
1137+
1138+
cy.get("@nativeTextarea").trigger("compositionstart", { data: "" });
1139+
1140+
cy.get("@nativeTextarea").trigger("compositionupdate", { data: "완료" });
1141+
1142+
cy.get("@nativeTextarea")
1143+
.invoke("val", "initial완료")
1144+
.trigger("input", { inputType: "insertCompositionText" });
1145+
1146+
cy.get("@nativeTextarea").trigger("compositionend", { data: "완료" });
1147+
1148+
cy.get("@textarea").should("have.prop", "_isComposing", false);
1149+
1150+
cy.get("@textarea").should("have.attr", "value", "initial완료");
1151+
1152+
cy.get("@nativeTextarea").realPress("Escape");
1153+
1154+
cy.get("@textarea").should("have.attr", "value", "initial");
1155+
});
1156+
});

packages/main/src/TextArea.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type I18nBundle from "@ui5/webcomponents-base/dist/i18nBundle.js";
1818
import { isEscape } from "@ui5/webcomponents-base/dist/Keys.js";
1919
import type { IFormInputElement } from "@ui5/webcomponents-base/dist/features/InputElementsFormSupport.js";
2020
import type Popover from "./Popover.js";
21+
import type InputComposition from "./features/InputComposition.js";
2122

2223
import TextAreaTemplate from "./TextAreaTemplate.js";
2324

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

330+
/**
331+
* Indicates whether IME composition is currently active
332+
* @default false
333+
* @private
334+
*/
335+
@property({ type: Boolean, noAttribute: true })
336+
_isComposing = false;
337+
329338
/**
330339
* Defines the value state message that will be displayed as pop up under the component.
331340
* The value state message slot should contain only one root element.
@@ -347,9 +356,11 @@ class TextArea extends UI5Element implements IFormInputElement {
347356
_keyDown?: boolean;
348357
previousValue: string;
349358
valueStatePopover?: Popover;
359+
_composition?: InputComposition;
350360

351361
@i18n("@ui5/webcomponents")
352362
static i18nBundle: I18nBundle;
363+
static composition: typeof InputComposition;
353364

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

388399
onEnterDOM() {
389400
ResizeHandler.register(this, this._fnOnResize);
401+
this._enableComposition();
390402
}
391403

392404
onExitDOM() {
393405
ResizeHandler.deregister(this, this._fnOnResize);
406+
this._composition?.removeEventListeners();
394407
}
395408

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

442+
if (this._isComposing) {
443+
return;
444+
}
445+
429446
if (isEscape(e)) {
430447
const nativeTextArea = this.getInputDomRef();
431448

@@ -575,6 +592,31 @@ class TextArea extends UI5Element implements IFormInputElement {
575592
};
576593
}
577594

595+
_enableComposition() {
596+
if (this._composition) {
597+
return;
598+
}
599+
600+
const setup = (FeatureClass: typeof InputComposition) => {
601+
this._composition = new FeatureClass({
602+
getInputEl: () => this.getInputDomRef(),
603+
updateCompositionState: (isComposing: boolean) => {
604+
this._isComposing = isComposing;
605+
},
606+
});
607+
this._composition.addEventListeners();
608+
};
609+
610+
if (TextArea.composition) {
611+
setup(TextArea.composition);
612+
} else {
613+
import("./features/InputComposition.js").then(CompositionModule => {
614+
TextArea.composition = CompositionModule.default;
615+
setup(CompositionModule.default);
616+
});
617+
}
618+
}
619+
578620
get classes() {
579621
return {
580622
root: {

packages/main/src/features/InputComposition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export interface CompositionComponent {
2-
getInputEl: () => HTMLInputElement | null;
2+
getInputEl: () => HTMLInputElement | HTMLTextAreaElement | null;
33
updateCompositionState: (isComposing: boolean) => void;
44
}
55

0 commit comments

Comments
 (0)