PluginProbe
The Innovative Form Builder – IvyForms / 1.0
The Innovative Form Builder – IvyForms v1.0
1.5.1 1.5 1.4.1 1.4 trunk 0.1.2 0.2 0.2.1 0.3 0.3.1 0.4 0.5 0.6 0.6.1 0.6.1-backup 0.6.1.1 0.7 0.8 0.8.1 0.8.2 0.9 0.9.1 1.0 1.1 1.1.1 All 27 releases
ivyforms / frontend / src / views / _components / input / IvyTextInput.vue

IvyTextInput.vue in The Innovative Form Builder – IvyForms 1.0, at frontend/src/views/_components/input/IvyTextInput.vue

570 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <ElInput
3 ref="elInputRef"
4 v-bind="{ ...$attrs, ...$props }"
5 v-model="localModelValue"
6 :class="[
7 type === 'textarea' ? 'ivyforms-textarea' : 'ivyforms-input',
8 { 'is-filled': props.filled, 'is-secondary': props.secondary },
9 ]"
10 :show-password="showPassword !== undefined ? showPassword : type === 'password'"
11 :type="localInputType"
12 @focus="isFocused = true"
13 @blur="isFocused = false"
14 >
15 <template v-if="!!slots['prefix'] || iconStart" #prefix>
16 <slot name="prefix">
17 <button
18 v-if="iconStart && hasIconStartListener"
19 type="button"
20 class="ivyforms-input__icon-button"
21 :aria-label="`${iconStart} action`"
22 @click.stop="onIconStartClick($event)"
23 @keydown.enter.prevent.stop="onIconStartClick($event)"
24 @keydown.space.prevent.stop="onIconStartClick($event)"
25 >
26 <IvyIcon
27 :name="iconStart"
28 :category="iconStartCategory"
29 :type="iconStartType"
30 :class="['ivyforms-input__icon-start', { 'is-clickable': hasIconStartListener }]"
31 :size="props.iconStartSize"
32 color="var(--map-base-dusk-symbol-2)"
33 />
34 </button>
35 <IvyIcon
36 v-else-if="iconStart"
37 :name="iconStart"
38 :category="iconStartCategory"
39 :type="iconStartType"
40 :class="['ivyforms-input__icon-start', { 'is-clickable': hasIconStartListener }]"
41 :size="props.iconStartSize"
42 color="var(--map-base-dusk-symbol-2)"
43 @click="onIconStartClick($event)"
44 />
45 </slot>
46 </template>
47 <template v-if="!!slots['suffix'] || iconEnd || props.clearable" #suffix>
48 <slot name="suffix">
49 <button
50 v-if="props.iconEnd && hasIconEndListener"
51 type="button"
52 class="ivyforms-input__icon-button"
53 :aria-label="`${props.iconEnd} action`"
54 @click.stop="onIconEndClick($event)"
55 @keydown.enter.prevent.stop="onIconEndClick($event)"
56 @keydown.space.prevent.stop="onIconEndClick($event)"
57 >
58 <IvyIcon
59 :name="props.iconEnd"
60 :category="props.iconEndCategory"
61 :type="props.iconEndType"
62 :class="['ivyforms-input__icon-end', { 'is-clickable': hasIconEndListener }]"
63 :size="props.iconEndSize"
64 color="var(--map-base-dusk-symbol-2)"
65 />
66 </button>
67 <IvyIcon
68 v-else-if="props.iconEnd"
69 :name="props.iconEnd"
70 :category="props.iconEndCategory"
71 :type="props.iconEndType"
72 :class="['ivyforms-input__icon-end', { 'is-clickable': hasIconEndListener }]"
73 :size="props.iconEndSize"
74 color="var(--map-base-dusk-symbol-2)"
75 @click="onIconEndClick($event)"
76 />
77 <IvyIcon
78 v-if="props.clearable && localModelValue"
79 name="close-circle"
80 class="ivyforms-input__clear"
81 size="l"
82 color="var(--map-base-dusk-symbol-2)"
83 type="fill-duo"
84 @click.stop="clearInput"
85 />
86 </slot>
87 </template>
88 </ElInput>
89 </template>
90
91 <script setup lang="ts">
92 import { ref, watch, useSlots, onMounted, onUnmounted, getCurrentInstance, computed } from 'vue'
93 import type { IconCategory } from '@/types/icons/icon-category'
94 import type { IconType } from '@/types/icons/icon-type'
95 import type { IconSize } from '@/types/icons/icon-size'
96
97 interface Props {
98 type?:
99 | 'text'
100 | 'textarea'
101 | 'number'
102 | 'select'
103 | 'multiple-select'
104 | 'phone'
105 | 'email'
106 | 'website'
107 | 'password'
108 placeholder?: string
109 iconStart?: string
110 iconEnd?: string
111 iconStartCategory?: IconCategory
112 iconEndCategory?: IconCategory
113 avatar?: string
114 iconStartType?: IconType
115 iconEndType?: IconType
116 iconStartSize?: IconSize
117 iconEndSize?: IconSize
118 filled?: boolean
119 secondary?: boolean
120 prefix?: string
121 suffix?: string
122 clearable?: boolean
123 showPassword?: boolean
124 modelValue: string | number | null | undefined
125 }
126
127 const props = withDefaults(defineProps<Props>(), {
128 type: 'text',
129 placeholder: '',
130 iconStart: '',
131 iconEnd: '',
132 iconStartCategory: 'global',
133 iconEndCategory: 'global',
134 iconStartType: 'outline',
135 iconEndType: 'outline',
136 avatar: '',
137 prefix: '',
138 suffix: '',
139 showPassword: undefined,
140 iconStartSize: 'd',
141 iconEndSize: 'd',
142 })
143
144 const emit = defineEmits(['update:modelValue', 'icon-start-click', 'icon-end-click'])
145
146 const onIconStartClick = (event?: Event) => {
147 if (!hasIconStartListener.value) {
148 return
149 }
150
151 emit('icon-start-click', event)
152 }
153
154 const onIconEndClick = (event?: Event) => {
155 if (!hasIconEndListener.value) {
156 return
157 }
158
159 emit('icon-end-click', event)
160 }
161
162 // Detect whether parent provided listeners for the icon click events.
163 const instance = getCurrentInstance()
164
165 const hasIconStartListener = computed(() => {
166 const p =
167 (instance && (instance.vnode as unknown as { props?: Record<string, unknown> }).props) || {}
168 return Boolean('onIconStartClick' in p || 'onIcon-start-click' in p)
169 })
170
171 const hasIconEndListener = computed(() => {
172 const p =
173 (instance && (instance.vnode as unknown as { props?: Record<string, unknown> }).props) || {}
174 return Boolean('onIconEndClick' in p || 'onIcon-end-click' in p)
175 })
176
177 const localModelValue = ref(props.modelValue)
178 const localInputType = ref(props.type)
179
180 const slots = useSlots()
181
182 const elInputRef = ref()
183 const isFocused = ref(false)
184
185 let cleanupAutofill: (() => void) | null = null
186
187 // Autofill detection - simplified without problematic animation events
188 const setupAutofillDetection = () => {
189 if (!elInputRef.value) return
190
191 // Skip autofill detection for textarea elements (paragraph fields)
192 if (props.type === 'textarea') {
193 return
194 }
195
196 const inputElement =
197 elInputRef.value.input || elInputRef.value.$el?.querySelector('input') || elInputRef.value.$el
198 if (!inputElement) return
199
200 // Only apply autofill detection to actual input elements, not textareas
201 if (inputElement.tagName?.toLowerCase() === 'textarea') {
202 return
203 }
204
205 // Clean up previous detection if exists
206 if (cleanupAutofill) {
207 cleanupAutofill()
208 cleanupAutofill = null
209 }
210
211 // Single check function that handles all autofill detection
212 const syncInputValue = () => {
213 if (inputElement.value !== localModelValue.value) {
214 localModelValue.value = inputElement.value
215 }
216 }
217
218 // Strategy 1: Polling for first 3 seconds with reasonable intervals
219 const intervalId = setInterval(syncInputValue, 100)
220 const timeoutId = setTimeout(() => clearInterval(intervalId), 3000)
221
222 // Strategy 2: Basic event listeners (removed problematic animation events)
223 const eventTypes = ['input', 'change', 'blur']
224 eventTypes.forEach((eventType) => {
225 inputElement.addEventListener(eventType, syncInputValue, { passive: true })
226 })
227
228 // Cleanup function
229 cleanupAutofill = () => {
230 clearInterval(intervalId)
231 clearTimeout(timeoutId)
232 eventTypes.forEach((eventType) => {
233 inputElement.removeEventListener(eventType, syncInputValue)
234 })
235 }
236
237 return cleanupAutofill
238 }
239
240 // Watch for modelValue changes (especially form resets)
241 watch(
242 () => props.modelValue,
243 (newValue, oldValue) => {
244 // If input was reset (empty/null value) after being populated, restart autofill detection
245 const isReset = !newValue || newValue === ''
246 const wasPopulated = oldValue && oldValue !== ''
247
248 if (isReset && wasPopulated) {
249 // Restart autofill detection after a brief delay
250 setTimeout(() => {
251 setupAutofillDetection()
252 }, 200)
253 }
254 },
255 )
256
257 onMounted(() => {
258 // Set up autofill detection after component is mounted and Element Plus is ready
259 setTimeout(() => {
260 cleanupAutofill = setupAutofillDetection()
261 }, 100)
262 })
263
264 onUnmounted(() => {
265 if (cleanupAutofill) {
266 cleanupAutofill()
267 }
268 })
269
270 // Sync localModelValue with props.modelValue
271 watch(
272 () => props.modelValue,
273 (newValue) => {
274 localModelValue.value = newValue
275 },
276 )
277
278 // Emit update:modelValue when localModelValue changes
279 watch(localModelValue, (newValue) => {
280 emit('update:modelValue', newValue)
281 })
282
283 const clearInput = () => {
284 localModelValue.value = ''
285 }
286
287 const focus = () => {
288 elInputRef.value?.focus()
289 }
290
291 defineExpose({
292 focus,
293 })
294 </script>
295
296 <style lang="scss">
297 // Input
298 .ivyforms-input {
299 &.el-input {
300 height: 40px;
301
302 .el-input__wrapper {
303 display: flex;
304 align-items: center;
305 gap: var(--Spacing-sm, 8px);
306 padding: var(--Spacing-sm, 4px) var(--Spacing-lg, 12px);
307 align-self: stretch;
308 border-radius: var(--Radius-radius-md, 8px);
309 border: 1px solid var(--map-base-dusk-stroke--2);
310 background: var(--map-base-dusk-o05);
311 box-shadow: none !important;
312 outline: none !important;
313 transition: none !important;
314 box-sizing: border-box;
315
316 .el-input__prefix-inner > :last-child {
317 margin-right: 0;
318 }
319
320 // Hover
321 &:hover,
322 &.is-hover {
323 border: 1px solid var(--map-base-dusk-stroke-0);
324 background: var(--map-base-dusk-o05);
325 }
326
327 // Inner input
328 input {
329 padding: 0 !important;
330 margin: 0 !important;
331 font-size: 16px;
332 font-style: normal;
333 font-weight: 400;
334 line-height: 22px;
335 color: var(--map-base-text-0);
336 border: none !important;
337 box-shadow: none !important;
338 background-color: transparent;
339 display: flex;
340 align-items: center;
341 gap: 4px;
342 flex: 1 0 0;
343 outline: none !important;
344 box-sizing: border-box;
345 }
346
347 // Active & Focus
348 &:active,
349 &:focus,
350 &.is-focus,
351 &.is-active {
352 border: 1px solid var(--map-base-brand-stroke-0);
353 background: var(--map-base-dusk-o05);
354 box-shadow: none !important;
355 outline: none !important;
356 padding: var(--Spacing-sm, 4px) var(--Spacing-lg, 12px) !important;
357 }
358
359 // Clear Icon
360 .ivyforms-input__clear {
361 cursor: pointer;
362 width: 20px;
363 height: 20px;
364 transform: scale(1);
365 margin: 0;
366 }
367
368 .ivyforms-input__icon-start.is-clickable,
369 .ivyforms-input__icon-end.is-clickable {
370 cursor: pointer;
371 }
372
373 .ivyforms-input__icon-button {
374 border: 0;
375 padding: 0;
376 margin: 0;
377 background: transparent;
378 display: inline-flex;
379 align-items: center;
380 justify-content: center;
381 }
382
383 /* Force pointer on the SVG inside the IvyIcon to override parent cursor rules */
384 .ivyforms-input__icon-start.is-clickable .ivyforms-icon__svg,
385 .ivyforms-input__icon-end.is-clickable .ivyforms-icon__svg,
386 .ivyforms-input__icon-start.is-clickable svg,
387 .ivyforms-input__icon-end.is-clickable svg {
388 cursor: pointer !important;
389 pointer-events: auto;
390 }
391
392 .el-input__clear {
393 display: none !important;
394
395 &::before {
396 display: none !important;
397 }
398 }
399 }
400
401 // Disabled state
402 &.is-disabled {
403 .el-input__wrapper {
404 background: var(--map-base-dusk-o05);
405 border-color: var(--map-base-dusk-stroke--2);
406 box-shadow: none;
407
408 input {
409 color: var(--map-base-text-0);
410 opacity: 0.5;
411 }
412
413 input[readonly]] {
414 opacity: 1;
415 -webkit-text-fill-color: currentColor;
416 }
417 }
418 }
419
420 // Secondary
421 &.is-secondary {
422 .el-input__wrapper {
423 // Active & Focus
424 &:active,
425 &:focus,
426 &.is-focus,
427 &.is-active {
428 border: 1px solid var(--map-base-purple-stroke-0) !important;
429 }
430 }
431 }
432 }
433 }
434
435 // Form Item
436 .el-form-item {
437 &.ivyforms-form-item-input {
438 // Secondary
439 &.is-secondary {
440 .ivyforms-input {
441 &.el-input {
442 .el-input__wrapper {
443 // Active & Focus
444 &:active,
445 &:focus,
446 &.is-focus,
447 &.is-active {
448 border: 1px solid var(--map-base-purple-stroke-0);
449 }
450 }
451 }
452 }
453 }
454 }
455
456 // Error and warning states
457 &.is-error {
458 .ivyforms-input {
459 &.el-input {
460 .el-input__wrapper {
461 border: 1px solid var(--map-status-error-symbol-0) !important;
462 box-shadow: none !important;
463 }
464
465 .el-input__wrapper:hover,
466 .el-input__wrapper:focus,
467 .el-input__wrapper:active,
468 .el-input__wrapper.is-focus,
469 .el-input__wrapper.is-active {
470 border: 1px solid var(--map-status-error-symbol-0) !important;
471 box-shadow: none !important;
472 }
473 }
474 }
475 }
476
477 &.is-warning {
478 .ivyforms-input {
479 &.el-input {
480 .el-input__wrapper {
481 border: 1px solid var(--map-status-warning-stroke-0);
482 }
483 }
484 }
485 }
486
487 // Textarea
488 &.ivyforms-form-item-textarea {
489 .ivyforms-textarea {
490 display: flex;
491 flex-direction: column;
492 align-items: flex-start;
493 font-size: 16px;
494 font-style: normal;
495 font-weight: 400;
496 line-height: 22px;
497
498 // Element Textarea
499 .el-textarea__inner {
500 border-radius: var(--Radius-radius-md, 8px);
501 border: 1px solid var(--map-base-dusk-stroke--2);
502 background-color: var(--map-base-dusk-o05);
503 display: flex;
504 align-items: center;
505 gap: var(--Spacing-sm, 8px);
506 align-self: stretch;
507 box-shadow: none;
508 color: var(--map-base-text-0);
509
510 // Hover
511 &:hover {
512 border: 1px solid var(--map-base-dusk-stroke-0);
513 background: var(--map-base-dusk-o05);
514 }
515
516 // Active & Focus
517 &:active,
518 &:focus,
519 &.is-focus,
520 &.is-active {
521 border: 1px solid var(--map-base-brand-stroke-0);
522 background: var(--map-base-dusk-o05);
523 box-shadow: none;
524 }
525 }
526 }
527
528 // Secondary
529 &.is-secondary {
530 .ivyforms-textarea {
531 .el-textarea__inner {
532 // Active & Focus
533 &:active,
534 &:focus,
535 &.is-focus,
536 &.is-active {
537 border: 1px solid var(--map-base-purple-stroke-0);
538 }
539 }
540 }
541 }
542 }
543
544 // Error and warning states
545 &.is-error {
546 .ivyforms-textarea {
547 .el-textarea__inner {
548 border: 1px solid var(--map-status-error-symbol-0) !important;
549 box-shadow: 0 0 0 1px var(--el-color-danger) inset !important;
550 }
551 }
552 }
553
554 &.is-warning {
555 .ivyforms-textarea {
556 .el-textarea__inner {
557 border: 1px solid var(--map-status-warning-stroke-0);
558 }
559 }
560 }
561
562 .ivyforms-textarea.el-textarea.is-disabled {
563 .el-textarea__inner[readonly]] {
564 opacity: 1;
565 -webkit-text-fill-color: currentColor;
566 }
567 }
568 }
569 </style>
570