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 @@
-
    + - -
  1. - -
  2. -
+ @@ -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 @@