Skip to content

Commit e6dd00a

Browse files
committed
fix: require explicit intent for option creation (IME safe)
Signed-off-by: don9x2E <revan@kakao.com>
1 parent 7357139 commit e6dd00a

3 files changed

Lines changed: 76 additions & 19 deletions

File tree

playwright/e2e/ime-input.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test.beforeEach(async ({ page }) => {
1717
})
1818

1919
test(
20-
'IME input does not trigger new option',
20+
'IME input requires explicit intent to create new option',
2121
{
2222
annotation: {
2323
type: 'issue',
@@ -75,7 +75,12 @@ test(
7575
await client.send('Input.insertText', {
7676
text: 'さ',
7777
})
78-
// so there were 4 inputs but those should only result in one new option
78+
// Committing composition text alone must not create a new option.
79+
await expect(question.answerInputs).toHaveCount(0)
80+
await expect(question.newAnswerInput).toHaveValue('さ')
81+
82+
// Explicit intent (Enter) creates exactly one option.
83+
await question.newAnswerInput.press('Enter')
7984
await expect(question.answerInputs).toHaveCount(1)
8085
await expect(question.answerInputs).toHaveValue('さ')
8186
},

src/components/Questions/AnswerInput.vue

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
dir="auto"
2222
@input="debounceOnInput"
2323
@keydown.delete="deleteEntry"
24-
@keydown.enter.prevent="focusNextInput"
24+
@keydown.enter.prevent="onEnter"
2525
@compositionstart="onCompositionStart"
2626
@compositionend="onCompositionEnd" />
2727

@@ -64,6 +64,17 @@
6464
</template>
6565
</NcButton>
6666
</div>
67+
<div v-else class="option__actions">
68+
<NcButton
69+
:aria-label="t('forms', 'Add a new answer option')"
70+
variant="tertiary"
71+
:disabled="isIMEComposing || !canCreateLocalAnswer"
72+
@click="createLocalAnswer">
73+
<template #icon>
74+
<IconPlus :size="20" />
75+
</template>
76+
</NcButton>
77+
</div>
6778
</li>
6879
</template>
6980

@@ -98,6 +109,7 @@ export default {
98109
IconCheckboxBlankOutline,
99110
IconDelete,
100111
IconDragIndicator,
112+
IconPlus,
101113
IconRadioboxBlank,
102114
IconTableColumn,
103115
IconTableRow,
@@ -167,6 +179,10 @@ export default {
167179
},
168180
169181
computed: {
182+
canCreateLocalAnswer() {
183+
return !!this.answer.text?.trim()
184+
},
185+
170186
ariaLabel() {
171187
if (this.answer.local) {
172188
if (this.optionType === OptionType.Column) {
@@ -266,30 +282,65 @@ export default {
266282
* @param {InputEvent} event The input event that triggered adding a new entry
267283
*/
268284
async onInput({ target, isComposing }) {
285+
if (this.answer.local) {
286+
this.$set(this.answer, 'text', target.value)
287+
return
288+
}
289+
269290
if (!isComposing && !this.isIMEComposing && target.value !== '') {
270291
// clone answer
271292
const answer = { ...this.answer }
272293
answer.text = this.$refs.input.value
273294
274-
if (this.answer.local) {
275-
// Dispatched for creation. Marked as synced
276-
this.$set(this.answer, 'local', false)
277-
const newAnswer = await this.createAnswer(answer)
295+
await this.updateAnswer(answer)
278296
279-
// Forward changes, but use current answer.text to avoid erasing
280-
// any in-between changes while creating the answer
281-
newAnswer.text = this.$refs.input.value
297+
// Forward changes, but use current answer.text to avoid erasing
298+
// any in-between changes while updating the answer
299+
answer.text = this.$refs.input.value
300+
this.$emit('update:answer', this.index, answer)
301+
}
302+
},
282303
283-
this.$emit('create-answer', this.index, newAnswer)
284-
} else {
285-
await this.updateAnswer(answer)
304+
/**
305+
* Handle Enter key: create local answer or move focus
306+
*
307+
* @param {KeyboardEvent} e the keydown event
308+
*/
309+
onEnter(e) {
310+
if (this.answer.local) {
311+
this.createLocalAnswer(e)
312+
return
313+
}
314+
this.focusNextInput(e)
315+
},
286316
287-
// Forward changes, but use current answer.text to avoid erasing
288-
// any in-between changes while updating the answer
289-
answer.text = this.$refs.input.value
290-
this.$emit('update:answer', this.index, answer)
291-
}
317+
/**
318+
* Create a new local answer option from the current input
319+
*
320+
* @param {Event} e the triggering event
321+
*/
322+
async createLocalAnswer(e) {
323+
if (this.isIMEComposing || e?.isComposing) {
324+
return
325+
}
326+
327+
const value = this.$refs.input?.value ?? ''
328+
if (!value.trim()) {
329+
return
292330
}
331+
332+
const answer = { ...this.answer }
333+
answer.text = value
334+
335+
// Dispatched for creation. Marked as synced
336+
this.$set(this.answer, 'local', false)
337+
const newAnswer = await this.createAnswer(answer)
338+
339+
// Forward changes, but use current answer.text to avoid erasing
340+
// any in-between changes while creating the answer
341+
newAnswer.text = this.$refs.input.value
342+
343+
this.$emit('create-answer', this.index, newAnswer)
293344
},
294345
295346
/**

src/mixins/QuestionMultipleMixin.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ export default defineComponent({
176176
*/
177177
onCreateAnswer(index: number, answer: FormsOption): void {
178178
this.$nextTick(() => {
179-
this.$nextTick(() => this.focusIndex(index, answer.optionType))
179+
// Move focus to the newly appended empty local option.
180+
this.$nextTick(() => this.focusIndex(index + 1, answer.optionType))
180181
})
181182
this.updateOptions([...this.options, answer])
182183
},

0 commit comments

Comments
 (0)