Skip to content
Merged
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
9 changes: 7 additions & 2 deletions playwright/e2e/ime-input.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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('さ')
},
Expand Down
104 changes: 87 additions & 17 deletions src/components/Questions/AnswerInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@
class="question__item__pseudoInput" />
<input
ref="input"
v-model="localText"
:aria-label="ariaLabel"
:placeholder="placeholder"
:value="answer.text"
class="question__input"
:class="{ 'question__input--shifted': !isDropdown }"
:maxlength="maxOptionLength"
type="text"
dir="auto"
@input="debounceOnInput"
@keydown.delete="deleteEntry"
@keydown.enter.prevent="focusNextInput"
@keydown.enter.prevent="onEnter"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd" />

Expand Down Expand Up @@ -64,6 +64,17 @@
</template>
</NcButton>
</div>
<div v-else class="option__actions">
<NcButton
:aria-label="t('forms', 'Add a new answer option')"
variant="tertiary"
:disabled="isIMEComposing || !canCreateLocalAnswer"
@click="createLocalAnswer">
<template #icon>
<IconPlus :size="20" />
</template>
</NcButton>
</div>
</li>
</template>

Expand All @@ -79,6 +90,7 @@ import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import IconDelete from 'vue-material-design-icons/TrashCanOutline.vue'
import IconDragIndicator from '../Icons/IconDragIndicator.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'

import NcActions from '@nextcloud/vue/components/NcActions'
Expand All @@ -98,6 +110,7 @@ export default {
IconCheckboxBlankOutline,
IconDelete,
IconDragIndicator,
IconPlus,
IconRadioboxBlank,
NcActions,
NcActionButton,
Expand Down Expand Up @@ -140,10 +153,18 @@ export default {
queue: null,
debounceOnInput: null,
isIMEComposing: false,
localText: this.answer?.text ?? '',
}
},

computed: {
canCreateLocalAnswer() {
if (this.answer.local) {
return !!this.localText?.trim()
}
return !!this.answer.text?.trim()
},

ariaLabel() {
if (this.answer.local) {
return t('forms', 'Add a new answer option')
Expand All @@ -169,6 +190,17 @@ export default {
},
},

watch: {
// Keep localText in sync when the parent replaces/updates the answer prop
answer: {
handler(newVal) {
this.localText = newVal?.text ?? ''
},

deep: true,
},
},

created() {
this.queue = new PQueue({ concurrency: 1 })

Expand Down Expand Up @@ -196,34 +228,72 @@ export default {
* @param {InputEvent} event The input event that triggered adding a new entry
*/
async onInput({ target, isComposing }) {
if (this.answer.local) {
this.localText = target.value
return
}

if (!isComposing && !this.isIMEComposing && target.value !== '') {
// clone answer
const answer = Object.assign({}, 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 updating the answer
answer.text = this.$refs.input.value
this.$emit('update:answer', this.index, 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
/**
* 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)
},

this.$emit('create-answer', this.index, newAnswer)
} else {
await this.updateAnswer(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
}

// 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)
}
const value = this.localText ?? ''
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.localText = ''

this.$emit('create-answer', this.index, newAnswer)
},

/**
* Request a new answer
*
* @param {Event} e the triggering event
*/
focusNextInput(e) {
if (this.isIMEComposing || e?.isComposing) {
Expand Down
2 changes: 1 addition & 1 deletion src/mixins/QuestionMultipleMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export default defineComponent({
*/
onCreateAnswer(index: number, answer: FormsOption): void {
this.$nextTick(() => {
this.$nextTick(() => this.focusIndex(index))
this.$nextTick(() => this.focusIndex(index + 1))
})
this.updateOptions([...this.options, answer])
},
Expand Down