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

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