1111 class =" question__item__pseudoInput" />
1212 <input
1313 ref =" input"
14- :aria-label =" t('forms', 'Answer number {index}', { index: index + 1 }) "
15- :placeholder =" t('forms', 'Answer number {index}', { index: index + 1 }) "
14+ :aria-label =" ariaLabel "
15+ :placeholder =" placeholder "
1616 :value =" answer.text"
1717 class =" question__input"
1818 :class =" { 'question__input--shifted': !isDropdown }"
1919 :maxlength =" maxOptionLength"
20- minlength =" 1"
2120 type =" text"
2221 dir =" auto"
2322 @input =" debounceOnInput"
2423 @keydown.delete =" deleteEntry"
25- @keydown.enter.prevent =" focusNextInput" />
26-
27- <!-- Delete answer -->
28- <NcActions >
29- <NcActionButton @click =" deleteEntry" >
30- <template #icon >
31- <IconClose :size =" 20" />
32- </template >
33- {{ t('forms', 'Delete answer') }}
34- </NcActionButton >
35- </NcActions >
24+ @keydown.enter.prevent =" focusNextInput"
25+ @compositionstart =" onCompositionEnd"
26+ @compositionend =" onCompositionEnd" />
27+
28+ <!-- Actions for reordering and deleting the option -->
29+ <div class =" option__actions" >
30+ <template v-if =" ! answer .local " >
31+ <NcButton
32+ ref =" buttonUp"
33+ class =" option__actions-button"
34+ :aria-label =" t('forms', 'Move option up')"
35+ :disabled =" index === 0"
36+ size =" small"
37+ type =" tertiary"
38+ @click =" onMoveUp" >
39+ <template #icon >
40+ <IconArrowUp :size =" 20" />
41+ </template >
42+ </NcButton >
43+ <NcButton
44+ ref =" buttonDown"
45+ class =" option__actions-button"
46+ :aria-label =" t('forms', 'Move option down')"
47+ :disabled =" index === maxIndex"
48+ size =" small"
49+ type =" tertiary"
50+ @click =" onMoveDown" >
51+ <template #icon >
52+ <IconArrowDown :size =" 20" />
53+ </template >
54+ </NcButton >
55+ <NcButton
56+ class =" option__actions-button"
57+ :aria-label =" t('forms', 'Delete answer')"
58+ size =" small"
59+ type =" tertiary"
60+ @click =" deleteEntry" >
61+ <template #icon >
62+ <IconDelete :size =" 20" />
63+ </template >
64+ </NcButton >
65+ </template >
66+ </div >
3667 </li >
3768</template >
3869
3970<script >
4071import { showError } from ' @nextcloud/dialogs'
4172import { generateOcsUrl } from ' @nextcloud/router'
4273import axios from ' @nextcloud/axios'
43- import pDebounce from ' p-debounce'
44- // eslint-disable-next-line import/no-unresolved, n/no-missing-import
74+ import debounce from ' debounce'
4575import PQueue from ' p-queue'
4676
47- import NcActions from ' @nextcloud/vue/components/NcActions'
48- import NcActionButton from ' @nextcloud/vue/components/NcActionButton'
49- import IconClose from ' vue-material-design-icons/Close.vue'
77+ import NcButton from ' @nextcloud/vue/components/NcButton'
78+ import IconArrowDown from ' vue-material-design-icons/ArrowDown.vue'
79+ import IconArrowUp from ' vue-material-design-icons/ArrowUp.vue'
80+ import IconDelete from ' vue-material-design-icons/Delete.vue'
5081import IconCheckboxBlankOutline from ' vue-material-design-icons/CheckboxBlankOutline.vue'
5182import IconRadioboxBlank from ' vue-material-design-icons/RadioboxBlank.vue'
5283
@@ -57,11 +88,12 @@ export default {
5788 name: ' AnswerInput' ,
5889
5990 components: {
60- IconClose,
91+ IconArrowDown,
92+ IconArrowUp,
6193 IconCheckboxBlankOutline,
94+ IconDelete,
6295 IconRadioboxBlank,
63- NcActions,
64- NcActionButton,
96+ NcButton,
6597 },
6698
6799 props: {
@@ -83,6 +115,10 @@ export default {
83115 },
84116 isDropdown: {
85117 type: Boolean ,
118+ default: false ,
119+ },
120+ maxIndex: {
121+ type: Number ,
86122 required: true ,
87123 },
88124 maxOptionLength: {
@@ -95,10 +131,27 @@ export default {
95131 return {
96132 queue: null ,
97133 debounceOnInput: null ,
134+ isIMEComposing: false ,
98135 }
99136 },
100137
101138 computed: {
139+ ariaLabel () {
140+ if (this .answer .local ) {
141+ return t (' forms' , ' Add a new answer option' )
142+ }
143+ return t (' forms' , ' The text of option {index}' , {
144+ index: this .index + 1 ,
145+ })
146+ },
147+
148+ placeholder () {
149+ if (this .answer .local ) {
150+ return t (' forms' , ' Add a new answer option' )
151+ }
152+ return t (' forms' , ' Answer number {index}' , { index: this .index + 1 })
153+ },
154+
102155 pseudoIcon () {
103156 return this .isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
104157 },
@@ -108,8 +161,8 @@ export default {
108161 this .queue = new PQueue ({ concurrency: 1 })
109162
110163 // As data instead of method, to have a separate debounce per AnswerInput
111- this .debounceOnInput = pDebounce (( ) => {
112- return this .queue .add (() => this .onInput ())
164+ this .debounceOnInput = debounce (( event ) => {
165+ return this .queue .add (() => this .onInput (event ))
113166 }, 500 )
114167 },
115168
@@ -122,37 +175,44 @@ export default {
122175 * Focus the input
123176 */
124177 focus () {
125- this .$refs .input .focus ()
178+ this .$refs .input ? .focus ()
126179 },
127180
128181 /**
129182 * Option changed, processing the data
183+ *
184+ * @param {InputEvent} event The input event that triggered adding a new entry
130185 */
131- async onInput () {
132- // clone answer
133- const answer = Object .assign ({}, this .answer )
134- answer .text = this .$refs .input .value
186+ async onInput ({ target, isComposing }) {
187+ if (! isComposing && ! this .isIMEComposing && target .value !== ' ' ) {
188+ // clone answer
189+ const answer = Object .assign ({}, this .answer )
190+ answer .text = this .$refs .input .value
135191
136- if (this .answer .local ) {
137- // Dispatched for creation. Marked as synced
138- this .$set (this .answer , ' local' , false )
139- const newAnswer = await this .createAnswer (answer)
140-
141- // Forward changes, but use current answer.text to avoid erasing
142- // any in-between changes while creating the answer
143- newAnswer .text = this .$refs .input .value
144- this .$emit (' update:answer' , answer .id , newAnswer)
145- } else {
146- await this .updateAnswer (answer)
147- this .$emit (' update:answer' , answer .id , answer)
192+ if (this .answer .local ) {
193+ // Dispatched for creation. Marked as synced
194+ this .$set (this .answer , ' local' , false )
195+ const newAnswer = await this .createAnswer (answer)
196+
197+ // Forward changes, but use current answer.text to avoid erasing
198+ // any in-between changes while creating the answer
199+ newAnswer .text = this .$refs .input .value
200+
201+ this .$emit (' create-answer' , this .index , newAnswer)
202+ } else {
203+ await this .updateAnswer (answer)
204+ this .$emit (' update:answer' , this .index , answer)
205+ }
148206 }
149207 },
150208
151209 /**
152210 * Request a new answer
153211 */
154212 focusNextInput () {
155- this .$emit (' focus-next' , this .index )
213+ if (this .index <= this .maxIndex ) {
214+ this .$emit (' focus-next' , this .index )
215+ }
156216 },
157217
158218 /**
@@ -162,14 +222,24 @@ export default {
162222 * @param {Event} e the event
163223 */
164224 async deleteEntry (e ) {
225+ if (this .answer .local ) {
226+ return
227+ }
228+
165229 if (e .type !== ' click' && this .$refs .input .value .length !== 0 ) {
166230 return
167231 }
168232
169233 // Dismiss delete key action
170234 e .preventDefault ()
171235
172- this .$emit (' delete' , this .answer .id )
236+ // do this in queue to prevent race conditions between PATCH and DELETE
237+ this .queue .add (() => {
238+ this .$emit (' delete' , this .answer .id )
239+ // Prevent any patch requests
240+ this .queue .pause ()
241+ this .queue .clear ()
242+ })
173243 },
174244
175245 /**
@@ -196,7 +266,7 @@ export default {
196266
197267 // Was synced once, this is now up to date with the server
198268 delete answer .local
199- return Object . assign ({}, answer, OcsResponse2Data (response)[0 ])
269+ return OcsResponse2Data (response)[0 ]
200270 } catch (error) {
201271 logger .error (' Error while saving answer' , { answer, error })
202272 showError (t (' forms' , ' Error while saving the answer' ))
@@ -233,6 +303,45 @@ export default {
233303 logger .error (' Error while saving answer' , { answer, error })
234304 showError (t (' forms' , ' Error while saving the answer' ))
235305 }
306+ return answer
307+ },
308+
309+ /**
310+ * Reorder option but keep focus on the button
311+ */
312+ onMoveDown () {
313+ this .$emit (' move-down' )
314+ if (this .index < this .maxIndex - 1 ) {
315+ this .$nextTick (() => this .$refs .buttonDown .$el .focus ())
316+ } else {
317+ this .$nextTick (() => this .$refs .buttonUp .$el .focus ())
318+ }
319+ },
320+ onMoveUp () {
321+ this .$emit (' move-up' )
322+ if (this .index > 1 ) {
323+ this .$nextTick (() => this .$refs .buttonUp .$el .focus ())
324+ } else {
325+ this .$nextTick (() => this .$refs .buttonDown .$el .focus ())
326+ }
327+ },
328+
329+ /**
330+ * Handle composition start event for IME inputs
331+ */
332+ onCompositionStart () {
333+ this .isIMEComposing = true
334+ },
335+
336+ /**
337+ * Handle composition end event for IME inputs
338+ * @param {CompositionEvent} event The input event that triggered adding a new entry
339+ */
340+ onCompositionEnd ({ target, isComposing }) {
341+ this .isIMEComposing = false
342+ if (! isComposing) {
343+ this .onInput ({ target, isComposing })
344+ }
236345 },
237346 },
238347}
@@ -243,24 +352,42 @@ export default {
243352 position: relative;
244353 display: inline- flex;
245354 min- height: var (-- default- clickable- area);
355+ width: 100 % ;
246356
247357 & __pseudoInput {
248358 color: var (-- color- primary- element);
249- margin-inline-start : -2 px ;
359+ margin- inline- start: calc ( - 1 * var ( -- default - grid - baseline)) ;
250360 z- index: 1 ;
251361 }
252362
363+ .option__actions {
364+ display: flex;
365+ position: absolute;
366+ gap: var (-- default- grid- baseline);
367+ inset- inline- end: 16px ;
368+ height: var (-- default- clickable- area);
369+ }
370+
371+ .option__actions - button {
372+ margin- block: auto;
373+
374+ & : last- of - type {
375+ margin- inline: 5px ;
376+ }
377+ }
378+
253379 .question__input {
254380 width: calc (100 % - var (-- default- clickable- area));
255381 position: relative;
256382 inset- inline- start: - 12px ;
383+ margin- block: 0 ! important;
257384 margin- inline- end: - 12px ! important;
258385
259386 & -- shifted {
260- inset-inline-start : -34 px ;
261- inset-block -start : 1 px ;
262- margin-inline-end : -34 px !important ;
263- padding-inline-start : 36 px !important ;
387+ inset- inline- start: calc ( - 1 * var ( -- default - clickable - area)) ;
388+ padding - inline - start: calc (
389+ var ( -- default - clickable - area) + var ( -- default - grid - baseline)
390+ ) ! important;
264391 }
265392 }
266393}
0 commit comments