PluginProbe
The Innovative Form Builder – IvyForms / 0.8
The Innovative Form Builder – IvyForms v0.8
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 0.8, at frontend/src/views/public/fields/AddressFormField.vue

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