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 / public / fields / AddressFormField.vue

AddressFormField.vue in The Innovative Form Builder – IvyForms 1.0, at frontend/src/views/public/fields/AddressFormField.vue

439 lines 16.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div
3 :class="[
4 'ivyforms-field',
5 'ivyforms-field__address',
6 'ivyforms-field__address--' + field.id,
7 field.cssClasses,
8 {
9 'ivyforms-field__address--main-label-visible': !field.hideLabel,
10 'ivyforms-field__address--readonly': !!field.readOnly,
11 },
12 `ivyforms-field__address--label-${effectiveLabelPosition}`,
13 ]"
14 >
15 <div
16 v-if="
17 !field.hideLabel &&
18 (effectiveLabelPosition === 'top' || effectiveLabelPosition === 'default')
19 "
20 class="ivyforms-field__address-label ivyforms-field__address-label-main ivyforms-gap-4 ivyforms-align-items-center ivyforms-mb-8 regular-16"
21 >
22 {{ field.label }}
23 </div>
24 <div
25 class="ivyforms-field__address__container ivyforms-flex ivyforms-width-100"
26 :class="{
27 'ivyforms-flex-direction-row ivyforms-align-items-start':
28 effectiveLabelPosition === 'left' || effectiveLabelPosition === 'right',
29 'ivyforms-flex-direction-column':
30 effectiveLabelPosition === 'top' || effectiveLabelPosition === 'default',
31 }"
32 >
33 <div
34 v-if="!field.hideLabel && effectiveLabelPosition === 'left'"
35 class="ivyforms-field__address-label ivyforms-gap-4 regular-16 ivyforms-flex-shrink-0 ivyforms-mr-16 ivyforms-mb-0 ivyforms-width-fit-content ivyforms-flex ivyforms-align-items-center"
36 >
37 {{ field.label }}
38 </div>
39 <div
40 class="ivyforms-field__address__layout ivyforms-flex-1 ivyforms-width-100 ivyforms-flex ivyforms-flex-direction-column"
41 :class="{
42 'ivyforms-mb-0': effectiveLabelPosition === 'left' || effectiveLabelPosition === 'right',
43 }"
44 :style="{ gap: horizontalGapValue }"
45 >
46 <!-- Row 1: Street Address (full width) -->
47 <div class="ivyforms-flex ivyforms-flex-direction-row" :style="{ gap: horizontalGapValue }">
48 <div v-if="getSubVisible(0)" class="ivyforms-width-100">
49 <IvyFormItem
50 :required="isSubRequired(0)"
51 :prop="`${field.type}_${field.fieldIndex}.${addressFields[0].type}`"
52 :show-info="!!getSubDescription(0)"
53 :info-text="getSubDescription(0) || ''"
54 :error="getSubfieldError(addressFields[0].type)"
55 :show-info-icon="false"
56 >
57 <template #label>
58 <div
59 v-if="!addressFields[0].hide"
60 class="ivyforms-field__address-label medium-14 ivyforms-flex ivyforms-align-items-center"
61 >
62 <span class="ivyforms-nowrap">{{ addressFields[0].label }}</span>
63 </div>
64 <div v-else class="ivyforms-placeholder-hidden" aria-hidden="true"></div>
65 </template>
66 <IvyTextInput
67 :id="addressFields[0].fieldID"
68 :model-value="getFieldValue(addressFields[0].type)"
69 class="ivyforms-width-100"
70 type="text"
71 :placeholder="addressFields[0].placeholder"
72 :disabled="isDisabled"
73 :required="isSubRequired(0)"
74 :aria-required="isSubRequired(0) ? 'true' : 'false'"
75 @input="updateFieldValue(addressFields[0].type, $event)"
76 @blur="(e) => syncFieldFromDom(addressFields[0].type, e)"
77 />
78 </IvyFormItem>
79 </div>
80 </div>
81 <!-- Row 2: Address Line 2 (full width) -->
82 <div class="ivyforms-flex ivyforms-flex-direction-row" :style="{ gap: horizontalGapValue }">
83 <div v-if="getSubVisible(1)" class="ivyforms-width-100">
84 <IvyFormItem
85 :required="isSubRequired(1)"
86 :prop="`${field.type}_${field.fieldIndex}.${addressFields[1].type}`"
87 :show-info="!!getSubDescription(1)"
88 :info-text="getSubDescription(1) || ''"
89 :error="getSubfieldError(addressFields[1].type)"
90 :show-info-icon="false"
91 >
92 <template #label>
93 <div
94 v-if="!addressFields[1].hide"
95 class="ivyforms-field__address-label medium-14 ivyforms-flex ivyforms-align-items-center"
96 >
97 <span class="ivyforms-nowrap">{{ addressFields[1].label }}</span>
98 </div>
99 <div v-else class="ivyforms-placeholder-hidden" aria-hidden="true"></div>
100 </template>
101 <IvyTextInput
102 :id="addressFields[1].fieldID"
103 :model-value="getFieldValue(addressFields[1].type)"
104 class="ivyforms-width-100"
105 type="text"
106 :placeholder="addressFields[1].placeholder"
107 :disabled="isDisabled"
108 :required="isSubRequired(1)"
109 :aria-required="isSubRequired(1) ? 'true' : 'false'"
110 @input="updateFieldValue(addressFields[1].type, $event)"
111 @blur="(e) => syncFieldFromDom(addressFields[1].type, e)"
112 />
113 </IvyFormItem>
114 </div>
115 </div>
116 <!-- Row 3: State (full width) -->
117 <div class="ivyforms-flex ivyforms-flex-direction-row" :style="{ gap: horizontalGapValue }">
118 <div v-if="getSubVisible(3)" class="ivyforms-width-100">
119 <IvyFormItem
120 :required="isSubRequired(3)"
121 :prop="`${field.type}_${field.fieldIndex}.${addressFields[3].type}`"
122 :show-info="!!getSubDescription(3)"
123 :info-text="getSubDescription(3) || ''"
124 :error="getSubfieldError(addressFields[3].type)"
125 :show-info-icon="false"
126 >
127 <template #label>
128 <div
129 v-if="!addressFields[3].hide"
130 class="ivyforms-field__address-label medium-14 ivyforms-flex ivyforms-align-items-center"
131 >
132 <span class="ivyforms-nowrap">{{ addressFields[3].label }}</span>
133 </div>
134 <div v-else class="ivyforms-placeholder-hidden" aria-hidden="true"></div>
135 </template>
136 <IvyTextInput
137 :id="addressFields[3].fieldID"
138 :model-value="getFieldValue(addressFields[3].type)"
139 class="ivyforms-width-100"
140 type="text"
141 :placeholder="addressFields[3].placeholder"
142 :disabled="isDisabled"
143 :required="isSubRequired(3)"
144 :aria-required="isSubRequired(3) ? 'true' : 'false'"
145 @input="updateFieldValue(addressFields[3].type, $event)"
146 @blur="(e) => syncFieldFromDom(addressFields[3].type, e)"
147 />
148 </IvyFormItem>
149 </div>
150 </div>
151 <!-- Row 4: City, Zip, Country (three fields in one row) -->
152 <div class="ivyforms-flex ivyforms-flex-direction-row" :style="{ gap: horizontalGapValue }">
153 <template
154 v-for="(fieldData, idx) in [addressFields[2], addressFields[4], addressFields[5]]"
155 :key="fieldData.fieldID"
156 >
157 <div v-if="getSubVisible(idx === 0 ? 2 : idx === 1 ? 4 : 5)" class="ivyforms-width-100">
158 <IvyFormItem
159 :required="isSubRequired(idx === 0 ? 2 : idx === 1 ? 4 : 5)"
160 :prop="`${field.type}_${field.fieldIndex}.${fieldData.type}`"
161 :show-info="!!getSubDescription(idx === 0 ? 2 : idx === 1 ? 4 : 5)"
162 :info-text="getSubDescription(idx === 0 ? 2 : idx === 1 ? 4 : 5) || ''"
163 :error="getSubfieldError(fieldData.type)"
164 :show-info-icon="false"
165 >
166 <template #label>
167 <div
168 v-if="!fieldData.hide"
169 class="ivyforms-field__address-label medium-14 ivyforms-flex ivyforms-align-items-center"
170 >
171 <span class="ivyforms-nowrap">{{ fieldData.label }}</span>
172 </div>
173 <div v-else class="ivyforms-placeholder-hidden" aria-hidden="true"></div>
174 </template>
175 <template v-if="fieldData.type === 'country'">
176 <IvySelectInput
177 :id="fieldData.fieldID"
178 v-model="countryValue"
179 :error="!!getSubfieldError('country')"
180 :placeholder="getLabel('select_country')"
181 :disabled="isDisabled"
182 :required="isSubRequired(idx === 0 ? 2 : idx === 1 ? 4 : 5)"
183 filterable
184 :aria-required="
185 isSubRequired(idx === 0 ? 2 : idx === 1 ? 4 : 5) ? 'true' : 'false'
186 "
187 @update:model-value="(val) => updateFieldValue('country', val)"
188 >
189 <IvySelectOption value="">{{ getLabel('select_country') }}</IvySelectOption>
190 <IvySelectOption
191 v-for="country in countryOptions"
192 :key="country"
193 :value="country"
194 >{{ country }}</IvySelectOption
195 >
196 </IvySelectInput>
197 </template>
198 <template v-else>
199 <IvyTextInput
200 :id="fieldData.fieldID"
201 :model-value="getFieldValue(fieldData.type)"
202 class="ivyforms-width-100"
203 type="text"
204 :placeholder="fieldData.placeholder"
205 :disabled="isDisabled"
206 :required="isSubRequired(idx === 0 ? 2 : idx === 1 ? 4 : 5)"
207 :aria-required="
208 isSubRequired(idx === 0 ? 2 : idx === 1 ? 4 : 5) ? 'true' : 'false'
209 "
210 @input="updateFieldValue(fieldData.type, $event)"
211 @blur="(e) => syncFieldFromDom(fieldData.type, e)"
212 />
213 </template>
214 </IvyFormItem>
215 </div>
216 </template>
217 </div>
218 </div>
219 <div
220 v-if="!field.hideLabel && effectiveLabelPosition === 'right'"
221 class="ivyforms-field__address-label ivyforms-gap-4 regular-16 ivyforms-flex-shrink-0 ivyforms-ml-16 ivyforms-mb-0 ivyforms-width-fit-content ivyforms-flex ivyforms-align-items-center"
222 >
223 {{ field.label }}
224 </div>
225 </div>
226 </div>
227 </template>
228
229 <script setup lang="ts">
230 import { computed, inject, isRef, onMounted, watch, type ComputedRef } from 'vue'
231 import { useLabels } from '@/composables/useLabels'
232 import { useAutofillDetection, type AutofillField } from '@/composables/useAutofillDetection'
233 import { countryList } from '@/constants/countries'
234 import IvySelectInput from '@/views/_components/input/IvySelectInput.vue'
235 import IvySelectOption from '@/views/_components/input/IvySelectOption.vue'
236 import type { Field } from '@/types/field'
237 const { getLabel } = useLabels()
238
239 // Inject clearFieldError function from parent
240 const clearFieldError = inject<(fieldKey: string) => void>('clearFieldError', () => {})
241
242 interface AddressFieldValue {
243 [key: string]: string | undefined
244 fullAddress?: string
245 }
246 interface FieldProps {
247 modelValue: AddressFieldValue
248 field: Field
249 disabled?: boolean
250 error?: string
251 fieldErrors?: Record<string, string>
252 horizontalGap?: string | ComputedRef<string>
253 }
254
255 const props = withDefaults(defineProps<FieldProps>(), {
256 modelValue: () => ({}),
257 disabled: false,
258 error: '',
259 fieldErrors: () => ({}),
260 horizontalGap: '12px',
261 })
262 const emit = defineEmits(['update:modelValue'])
263
264 const isDisabled = computed(() => !!props.disabled || !!props.field.readOnly)
265
266 // Extract horizontal gap value from prop (handles both direct number and computed ref)
267 const horizontalGapValue = computed(() => {
268 return isRef(props.horizontalGap) ? props.horizontalGap.value : props.horizontalGap
269 })
270
271 // Always use the correct order for address fields
272 const addressFieldOrder = ['streetAddress', 'addressLine2', 'city', 'state', 'zip', 'country']
273 const addressFieldTypes = computed(() => {
274 // If the field has addressFieldTypes, reorder to match the required order
275 const types = props.field.addressFieldTypes || [
276 'streetAddress',
277 'addressLine2',
278 'city',
279 'state',
280 'zip',
281 'country',
282 ]
283 // Map to required order, filter out missing, and fill with empty if needed
284 return addressFieldOrder.map((t) => types.find((x) => x === t) || t)
285 })
286
287 const countryOptions = countryList.map((c) => c.name)
288
289 const addressFields = computed(() => {
290 return addressFieldTypes.value.map((fieldType) => {
291 const rawLabel = ((props.field as Record<string, string>)[`${fieldType}Label`] ?? '') as string
292 const isCleared = rawLabel.trim() === ''
293 const explicitHide = (props.field as Record<string, boolean>)[`${fieldType}HideLabel`] || false
294 const hide = explicitHide || isCleared
295 return {
296 type: fieldType,
297 label: rawLabel,
298 placeholder: (props.field as Record<string, string>)[`${fieldType}Placeholder`] || '',
299 fieldID: `ivyforms-field__address-${fieldType}_${(props.field.formId || '').toString()}_${(props.field.fieldIndex || '').toString()}`,
300 hide,
301 }
302 })
303 })
304
305 const isSubRequired = (idx: number): boolean => {
306 const t = addressFields.value[idx]?.type
307 return !!(props.field as Record<string, unknown>)[`${t}Required`]
308 }
309 const getSubDescription = (idx: number): string => {
310 const t = addressFields.value[idx]?.type
311 return ((props.field as Record<string, string>)[`${t}Description`] || '') as string
312 }
313 const getSubfieldError = (fieldType: string): string => {
314 const subKey = `${props.field.type}_${props.field.fieldIndex}.${fieldType}`
315 return props.fieldErrors?.[subKey] || ''
316 }
317 const getFieldValue = (fieldType: string) =>
318 props.modelValue?.[fieldType] ||
319 (fieldType === 'country' ? props.field.countryDefaultValue || '' : '')
320 function updateFieldValue(fieldType: string, value: string, autofill = false) {
321 if (autofill && fieldType === 'country' && props.modelValue?.country) {
322 return
323 }
324 const newModelValue: AddressFieldValue = { ...props.modelValue, [fieldType]: value }
325 emit('update:modelValue', newModelValue)
326
327 const subKey = `${props.field.type}_${props.field.fieldIndex}.${fieldType}`
328 clearFieldError(subKey)
329 }
330
331 const countryValue = computed({
332 get: () => getFieldValue('country'),
333 set: (val: string) => updateFieldValue('country', val),
334 })
335
336 const getSubVisible = (idx: number): boolean => {
337 const t = addressFields.value[idx]?.type
338 return (props.field as Record<string, boolean>)[`${t}Visible`] !== false
339 }
340
341 // Sync field from DOM on blur (includes autofill)
342 function syncFieldFromDom(fieldType: string, e: FocusEvent) {
343 const el = e.target as HTMLInputElement | null
344 if (!el) return
345 if (el.value && el.value !== getFieldValue(fieldType)) {
346 updateFieldValue(fieldType, el.value)
347 }
348 }
349
350 // Setup autofill detection using the composable
351 const autofillFields = computed<AutofillField[]>(() => {
352 return addressFields.value.map((fieldData) => ({
353 id: fieldData.fieldID,
354 getValue: () => getFieldValue(fieldData.type),
355 setValue: (value: string) => updateFieldValue(fieldData.type, value, true),
356 onClearError: () => {
357 const subKey = `${props.field.type}_${props.field.fieldIndex}.${fieldData.type}`
358 clearFieldError(subKey)
359 },
360 }))
361 })
362
363 const { restartDetection, checkForInitialAutofill } = useAutofillDetection({
364 fields: autofillFields.value,
365 pollingDuration: 7000, // Increased for slow autofill
366 pollingInterval: 100,
367 initialCheckDelay: 100, // Faster initial check
368 })
369
370 // Manual check on mount and when fields become available
371 onMounted(() => {
372 // Initial check immediately after mount
373 setTimeout(checkForInitialAutofill, 50)
374 // Additional checks for delayed autofill
375 setTimeout(checkForInitialAutofill, 300)
376 setTimeout(checkForInitialAutofill, 1000)
377 })
378
379 // Watch for field changes and restart detection
380 watch(
381 () => addressFields.value.map((f) => f.fieldID),
382 () => {
383 setTimeout(restartDetection, 100)
384 },
385 { flush: 'post' },
386 )
387
388 const effectiveLabelPosition = computed(() => {
389 const position = props.field?.labelPosition
390 if (position === 'default' || !position) {
391 return 'top'
392 }
393 return position
394 })
395 </script>
396
397 <style lang="scss" scoped>
398 .ivyforms-placeholder-hidden {
399 height: 20px;
400 visibility: hidden;
401 display: block;
402 }
403 .ivyforms-field__address {
404 &--readonly {
405 opacity: 0.7;
406 :deep(.el-input__wrapper),
407 :deep(.el-input__wrapper:hover),
408 :deep(.el-input__wrapper.is-focus) {
409 border-color: var(--map-base-dusk-stroke--2) !important;
410 box-shadow: none !important;
411 }
412 }
413 &-label {
414 color: var(--map-base-text-0);
415 display: inline-flex;
416 }
417 &__container {
418 display: flex;
419 width: 100%;
420 &--flex-row {
421 flex-direction: row;
422 align-items: flex-start;
423 gap: 0;
424 }
425 &--flex-column {
426 flex-direction: column;
427 }
428 }
429 &__layout {
430 flex: 1;
431 width: 100%;
432 &--with-left-label,
433 &--with-right-label {
434 @extend .ivyforms-mb-0;
435 }
436 }
437 }
438 </style>
439