diff --git a/package-lock.json b/package-lock.json
index 88161a020..39c5f9e82 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,7 +23,6 @@
"crypto-js": "^4.2.0",
"debounce": "^2.2.0",
"markdown-it": "^14.1.0",
- "p-debounce": "^4.0.0",
"p-queue": "^8.0.1",
"qrcode": "^1.5.4",
"v-click-outside": "^3.2.0",
@@ -10112,15 +10111,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/p-debounce": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-4.0.0.tgz",
- "integrity": "sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
diff --git a/package.json b/package.json
index fc795acf7..e137d9ca1 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,6 @@
"crypto-js": "^4.2.0",
"debounce": "^2.2.0",
"markdown-it": "^14.1.0",
- "p-debounce": "^4.0.0",
"p-queue": "^8.0.1",
"qrcode": "^1.5.4",
"v-click-outside": "^3.2.0",
diff --git a/playwright/support/sections/QuestionSection.ts b/playwright/support/sections/QuestionSection.ts
index 2b6c44acd..1c565f53d 100644
--- a/playwright/support/sections/QuestionSection.ts
+++ b/playwright/support/sections/QuestionSection.ts
@@ -19,10 +19,10 @@ export class QuestionSection {
name: /title of/i,
})
this.newAnswerInput = this.section.getByRole('textbox', {
- name: 'Add a new answer',
+ name: 'Add a new answer option',
})
this.answerInputs = this.section.getByRole('textbox', {
- name: /Answer number \d+/i,
+ name: /The text of option \d+/i,
})
}
diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue
index d0c50220e..c355b59be 100644
--- a/src/components/Questions/AnswerInput.vue
+++ b/src/components/Questions/AnswerInput.vue
@@ -11,28 +11,59 @@
class="question__item__pseudoInput" />
-
-
-
-
-
-
-
- {{ t('forms', 'Delete answer') }}
-
-
+ @keydown.enter.prevent="focusNextInput"
+ @compositionstart="onCompositionEnd"
+ @compositionend="onCompositionEnd" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -40,13 +71,13 @@
import { showError } from '@nextcloud/dialogs'
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
-import pDebounce from 'p-debounce'
-// eslint-disable-next-line import/no-unresolved, n/no-missing-import
+import debounce from 'debounce'
import PQueue from 'p-queue'
-import NcActions from '@nextcloud/vue/components/NcActions'
-import NcActionButton from '@nextcloud/vue/components/NcActionButton'
-import IconClose from 'vue-material-design-icons/Close.vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
+import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
+import IconDelete from 'vue-material-design-icons/Delete.vue'
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
@@ -57,11 +88,12 @@ export default {
name: 'AnswerInput',
components: {
- IconClose,
+ IconArrowDown,
+ IconArrowUp,
IconCheckboxBlankOutline,
+ IconDelete,
IconRadioboxBlank,
- NcActions,
- NcActionButton,
+ NcButton,
},
props: {
@@ -83,6 +115,10 @@ export default {
},
isDropdown: {
type: Boolean,
+ default: false,
+ },
+ maxIndex: {
+ type: Number,
required: true,
},
maxOptionLength: {
@@ -95,10 +131,27 @@ export default {
return {
queue: null,
debounceOnInput: null,
+ isIMEComposing: false,
}
},
computed: {
+ ariaLabel() {
+ if (this.answer.local) {
+ return t('forms', 'Add a new answer option')
+ }
+ return t('forms', 'The text of option {index}', {
+ index: this.index + 1,
+ })
+ },
+
+ placeholder() {
+ if (this.answer.local) {
+ return t('forms', 'Add a new answer option')
+ }
+ return t('forms', 'Answer number {index}', { index: this.index + 1 })
+ },
+
pseudoIcon() {
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
},
@@ -108,8 +161,8 @@ export default {
this.queue = new PQueue({ concurrency: 1 })
// As data instead of method, to have a separate debounce per AnswerInput
- this.debounceOnInput = pDebounce(() => {
- return this.queue.add(() => this.onInput())
+ this.debounceOnInput = debounce((event) => {
+ return this.queue.add(() => this.onInput(event))
}, 500)
},
@@ -122,29 +175,34 @@ export default {
* Focus the input
*/
focus() {
- this.$refs.input.focus()
+ this.$refs.input?.focus()
},
/**
* Option changed, processing the data
+ *
+ * @param {InputEvent} event The input event that triggered adding a new entry
*/
- async onInput() {
- // clone answer
- const answer = Object.assign({}, this.answer)
- answer.text = this.$refs.input.value
+ async onInput({ target, isComposing }) {
+ 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)
-
- // 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('update:answer', answer.id, newAnswer)
- } else {
- await this.updateAnswer(answer)
- this.$emit('update:answer', answer.id, answer)
+ if (this.answer.local) {
+ // 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)
+ } else {
+ await this.updateAnswer(answer)
+ this.$emit('update:answer', this.index, answer)
+ }
}
},
@@ -152,7 +210,9 @@ export default {
* Request a new answer
*/
focusNextInput() {
- this.$emit('focus-next', this.index)
+ if (this.index <= this.maxIndex) {
+ this.$emit('focus-next', this.index)
+ }
},
/**
@@ -162,6 +222,10 @@ export default {
* @param {Event} e the event
*/
async deleteEntry(e) {
+ if (this.answer.local) {
+ return
+ }
+
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
return
}
@@ -169,7 +233,13 @@ export default {
// Dismiss delete key action
e.preventDefault()
- this.$emit('delete', this.answer.id)
+ // do this in queue to prevent race conditions between PATCH and DELETE
+ this.queue.add(() => {
+ this.$emit('delete', this.answer.id)
+ // Prevent any patch requests
+ this.queue.pause()
+ this.queue.clear()
+ })
},
/**
@@ -196,7 +266,7 @@ export default {
// Was synced once, this is now up to date with the server
delete answer.local
- return Object.assign({}, answer, OcsResponse2Data(response)[0])
+ return OcsResponse2Data(response)[0]
} catch (error) {
logger.error('Error while saving answer', { answer, error })
showError(t('forms', 'Error while saving the answer'))
@@ -233,6 +303,45 @@ export default {
logger.error('Error while saving answer', { answer, error })
showError(t('forms', 'Error while saving the answer'))
}
+ return answer
+ },
+
+ /**
+ * Reorder option but keep focus on the button
+ */
+ onMoveDown() {
+ this.$emit('move-down')
+ if (this.index < this.maxIndex - 1) {
+ this.$nextTick(() => this.$refs.buttonDown.$el.focus())
+ } else {
+ this.$nextTick(() => this.$refs.buttonUp.$el.focus())
+ }
+ },
+ onMoveUp() {
+ this.$emit('move-up')
+ if (this.index > 1) {
+ this.$nextTick(() => this.$refs.buttonUp.$el.focus())
+ } else {
+ this.$nextTick(() => this.$refs.buttonDown.$el.focus())
+ }
+ },
+
+ /**
+ * Handle composition start event for IME inputs
+ */
+ onCompositionStart() {
+ this.isIMEComposing = true
+ },
+
+ /**
+ * Handle composition end event for IME inputs
+ * @param {CompositionEvent} event The input event that triggered adding a new entry
+ */
+ onCompositionEnd({ target, isComposing }) {
+ this.isIMEComposing = false
+ if (!isComposing) {
+ this.onInput({ target, isComposing })
+ }
},
},
}
@@ -243,24 +352,42 @@ export default {
position: relative;
display: inline-flex;
min-height: var(--default-clickable-area);
+ width: 100%;
&__pseudoInput {
color: var(--color-primary-element);
- margin-inline-start: -2px;
+ margin-inline-start: calc(-1 * var(--default-grid-baseline));
z-index: 1;
}
+ .option__actions {
+ display: flex;
+ position: absolute;
+ gap: var(--default-grid-baseline);
+ inset-inline-end: 16px;
+ height: var(--default-clickable-area);
+ }
+
+ .option__actions-button {
+ margin-block: auto;
+
+ &:last-of-type {
+ margin-inline: 5px;
+ }
+ }
+
.question__input {
width: calc(100% - var(--default-clickable-area));
position: relative;
inset-inline-start: -12px;
+ margin-block: 0 !important;
margin-inline-end: -12px !important;
&--shifted {
- inset-inline-start: -34px;
- inset-block-start: 1px;
- margin-inline-end: -34px !important;
- padding-inline-start: 36px !important;
+ inset-inline-start: calc(-1 * var(--default-clickable-area));
+ padding-inline-start: calc(
+ var(--default-clickable-area) + var(--default-grid-baseline)
+ ) !important;
}
}
}
diff --git a/src/components/Questions/QuestionDropdown.vue b/src/components/Questions/QuestionDropdown.vue
index 195627bee..fd36b4f26 100644
--- a/src/components/Questions/QuestionDropdown.vue
+++ b/src/components/Questions/QuestionDropdown.vue
@@ -40,39 +40,27 @@
-
+
-
- -
-
-
-
+
@@ -83,10 +71,6 @@
@@ -332,6 +154,7 @@ export default {
.question__content {
display: flex;
flex-direction: column;
+ gap: var(--default-grid-baseline);
}
.question__item {
diff --git a/src/components/Questions/QuestionMultiple.vue b/src/components/Questions/QuestionMultiple.vue
index d14c5561e..02d34ee43 100644
--- a/src/components/Questions/QuestionMultiple.vue
+++ b/src/components/Questions/QuestionMultiple.vue
@@ -117,51 +117,40 @@
-
-
-
+
+
+
+
+
+
+
+
@@ -173,10 +162,7 @@