diff --git a/playwright/e2e/ime-input.spec.ts b/playwright/e2e/ime-input.spec.ts index 6bb78a914..c0ef6aa54 100644 --- a/playwright/e2e/ime-input.spec.ts +++ b/playwright/e2e/ime-input.spec.ts @@ -17,7 +17,7 @@ test.beforeEach(async ({ page }) => { }) test( - 'IME input does not trigger new option', + 'IME input requires explicit intent to create new option', { annotation: { type: 'issue', @@ -75,7 +75,12 @@ test( await client.send('Input.insertText', { text: 'さ', }) - // so there were 4 inputs but those should only result in one new option + // Committing composition text alone must not create a new option. + await expect(question.answerInputs).toHaveCount(0) + await expect(question.newAnswerInput).toHaveValue('さ') + + // Explicit intent (Enter) creates exactly one option. + await question.newAnswerInput.press('Enter') await expect(question.answerInputs).toHaveCount(1) await expect(question.answerInputs).toHaveValue('さ') }, diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 426536ba3..9caa02b2c 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -21,7 +21,7 @@ dir="auto" @input="debounceOnInput" @keydown.delete="deleteEntry" - @keydown.enter.prevent="focusNextInput" + @keydown.enter.prevent="onEnter" @compositionstart="onCompositionStart" @compositionend="onCompositionEnd" /> @@ -64,6 +64,17 @@ +
+ + + +
@@ -98,6 +109,7 @@ export default { IconCheckboxBlankOutline, IconDelete, IconDragIndicator, + IconPlus, IconRadioboxBlank, IconTableColumn, IconTableRow, @@ -167,6 +179,10 @@ export default { }, computed: { + canCreateLocalAnswer() { + return !!this.$refs.input?.value?.trim() + }, + ariaLabel() { if (this.answer.local) { if (this.optionType === OptionType.Column) { @@ -266,30 +282,65 @@ export default { * @param {InputEvent} event The input event that triggered adding a new entry */ async onInput({ target, isComposing }) { + if (this.answer.local) { + this.$set(this.answer, 'text', target.value) + return + } + if (!isComposing && !this.isIMEComposing && target.value !== '') { // clone answer const answer = { ...this.answer } answer.text = this.$refs.input.value - if (this.answer.local) { - // Dispatched for creation. Marked as synced - this.$set(this.answer, 'local', false) - const newAnswer = await this.createAnswer(answer) + await this.updateAnswer(answer) - // Forward changes, but use current answer.text to avoid erasing - // any in-between changes while creating the answer - newAnswer.text = this.$refs.input.value + // Forward changes, but use current answer.text to avoid erasing + // any in-between changes while updating the answer + answer.text = this.$refs.input.value + this.$emit('update:answer', this.index, answer) + } + }, - this.$emit('create-answer', this.index, newAnswer) - } else { - await this.updateAnswer(answer) + /** + * Handle Enter key: create local answer or move focus + * + * @param {KeyboardEvent} e the keydown event + */ + onEnter(e) { + if (this.answer.local) { + this.createLocalAnswer(e) + return + } + this.focusNextInput(e) + }, - // Forward changes, but use current answer.text to avoid erasing - // any in-between changes while updating the answer - answer.text = this.$refs.input.value - this.$emit('update:answer', this.index, answer) - } + /** + * Create a new local answer option from the current input + * + * @param {Event} e the triggering event + */ + async createLocalAnswer(e) { + if (this.isIMEComposing || e?.isComposing) { + return + } + + const value = this.$refs.input?.value ?? '' + if (!value.trim()) { + return } + + const answer = { ...this.answer } + answer.text = value + + // Dispatched for creation. Marked as synced + this.$set(this.answer, 'local', false) + const newAnswer = await this.createAnswer(answer) + + // Forward changes, but use current answer.text to avoid erasing + // any in-between changes while creating the answer + newAnswer.text = this.$refs.input.value + + this.$emit('create-answer', this.index, newAnswer) }, /** diff --git a/src/mixins/QuestionMultipleMixin.ts b/src/mixins/QuestionMultipleMixin.ts index 0b3a882da..c3df7e78d 100644 --- a/src/mixins/QuestionMultipleMixin.ts +++ b/src/mixins/QuestionMultipleMixin.ts @@ -176,7 +176,8 @@ export default defineComponent({ */ onCreateAnswer(index: number, answer: FormsOption): void { this.$nextTick(() => { - this.$nextTick(() => this.focusIndex(index, answer.optionType)) + // Move focus to the newly appended empty local option. + this.$nextTick(() => this.focusIndex(index + 1, answer.optionType)) }) this.updateOptions([...this.options, answer]) },