PluginProbe
The Innovative Form Builder – IvyForms / 1.1.1
The Innovative Form Builder – IvyForms v1.1.1
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 / IvyPhoneInput.vue

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

445 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <template>
2 <div ref="phoneInputRef" :class="['ivyforms-phone-input', { 'is-secondary': isSecondary }]">
3 <VueTelInput
4 ref="vueTelRef"
5 v-model="localModelValue"
6 :class="['ivyforms-phone-input__control', { 'is-focused': isInputFocused }]"
7 :input-options="{ placeholder: isInputFocused ? examplePhoneNumber : placeholder || '' }"
8 :ignored-countries="ignoredCountries"
9 :mode="vueTelMode"
10 :valid-characters-only="true"
11 :disabled="disabled"
12 :default-country="computedDefaultCountry"
13 @focus="onInputFocus"
14 @blur="handleBlur"
15 @country-changed="onCountryChanged"
16 @open="onDropdownOpen"
17 @close="onDropdownClose"
18 @on-input="onInput"
19 >
20 <template #arrow-icon>
21 <IvyIcon
22 class="ivyforms-phone-input__arrow"
23 category="arrows"
24 name="chevron-down"
25 size="s"
26 color="var(--map-base-dusk-symbol-2)"
27 />
28 </template>
29 </VueTelInput>
30 </div>
31 </template>
32
33 <script setup lang="ts">
34 import { VueTelInput } from 'vue-tel-input'
35 import 'vue-tel-input/vue-tel-input.css'
36 import { computed, ref, nextTick, onBeforeMount, onMounted, inject, watch } from 'vue'
37 import { parsePhoneNumberFromString, getExampleNumber, type CountryCode } from 'libphonenumber-js'
38 import examples from 'libphonenumber-js/mobile/examples'
39 import IvyIcon from '@/views/_components/icon/IvyIcon.vue'
40 import { ignoredCountries } from '@/constants/countries'
41 import { useLabels } from '@/composables/useLabels'
42
43 interface Props {
44 modelValue?: string | undefined
45 placeholder?: string
46 disabled?: boolean
47 phoneFormat?: 'international' | 'national' | 'e164'
48 autoDetectCountry?: boolean
49 ariaLabel?: string
50 ariaLabelledby?: string
51 }
52
53 const props = withDefaults(defineProps<Props>(), {
54 modelValue: '',
55 placeholder: '',
56 disabled: false,
57 phoneFormat: 'national',
58 autoDetectCountry: true,
59 ariaLabel: '',
60 ariaLabelledby: '',
61 })
62
63 const emit = defineEmits<{
64 (e: 'update:modelValue', value: string): void
65 (e: 'blur'): void
66 (e: 'update', payload: { countryCode: string; isValid: boolean }): void
67 }>()
68
69 const { getLabel } = useLabels()
70
71 const vueTelRef = ref<InstanceType<typeof VueTelInput> | null>(null)
72 const phoneInputRef = ref<HTMLElement | null>(null)
73 const internalValue = ref<string>('')
74 const selectedCountryCode = ref<string>('')
75 const manuallySelectedCountry = ref<string>('')
76 const isInputFocused = ref(false)
77
78 const localModelValue = computed({
79 get() {
80 return internalValue.value
81 },
82 set(v: string) {
83 internalValue.value = v
84 },
85 })
86
87 // Determine vue-tel-input mode (it supports 'international' and 'national'). For e164 we'll leverage international.
88 const vueTelMode = computed(() =>
89 props.phoneFormat === 'international' ? 'international' : 'national',
90 )
91
92 const examplePhoneNumber = computed(() => {
93 if (!selectedCountryCode.value) return ''
94 try {
95 const ex = getExampleNumber(selectedCountryCode.value as CountryCode, examples)
96 if (!ex) return ''
97 if (props.phoneFormat === 'international') return ex.formatInternational()
98 if (props.phoneFormat === 'e164') return ex.number
99 return ex.formatNational()
100 } catch {
101 return ''
102 }
103 })
104
105 const onCountryChanged = (country: { iso2: string }) => {
106 selectedCountryCode.value = country.iso2
107 manuallySelectedCountry.value = country.iso2
108 }
109
110 const onDropdownOpen = () => {
111 // Adjust dropdown width dynamically (ensures full country name visibility)
112 nextTick(() => {
113 const dropdown = phoneInputRef.value?.querySelector('.vti__dropdown-list') as HTMLElement | null
114 if (dropdown) {
115 dropdown.style.minWidth = '280px'
116 // Add WCAG accessibility label for screen readers
117 dropdown.setAttribute('aria-label', getLabel('select_country'))
118 dropdown.setAttribute('role', 'listbox')
119 // Make dropdown list visible to assistive technologies
120 dropdown.removeAttribute('aria-hidden')
121
122 // Ensure dropdown items are keyboard accessible when open
123 const items = dropdown.querySelectorAll('.vti__dropdown-item')
124 items.forEach((item) => {
125 item.setAttribute('role', 'option')
126 item.setAttribute('tabindex', '0')
127 })
128 }
129 })
130 }
131
132 const onDropdownClose = () => {
133 // WCAG 4.1.2: Prevent nested interactive controls by hiding dropdown from assistive technologies when closed
134 nextTick(() => {
135 const dropdown = phoneInputRef.value?.querySelector('.vti__dropdown-list') as HTMLElement | null
136 if (dropdown) {
137 // Hide from assistive technologies when closed
138 dropdown.setAttribute('aria-hidden', 'true')
139
140 // Remove focusability from items when closed to prevent nested interactive issues
141 const items = dropdown.querySelectorAll('.vti__dropdown-item')
142 items.forEach((item) => {
143 item.setAttribute('tabindex', '-1')
144 })
145 }
146 })
147 }
148
149 const onInputFocus = () => {
150 isInputFocused.value = true
151 }
152 const handleBlur = () => {
153 isInputFocused.value = false
154 emit('blur')
155 }
156
157 const formatAndEmit = (rawNumber: string, phoneObject: { country: string; valid: boolean }) => {
158 let formatted = rawNumber
159 try {
160 const pn = parsePhoneNumberFromString(rawNumber || '', selectedCountryCode.value as CountryCode)
161 if (pn) {
162 if (props.phoneFormat === 'international') formatted = pn.formatInternational()
163 else if (props.phoneFormat === 'e164') formatted = pn.number
164 else formatted = pn.formatNational()
165 }
166 } catch {
167 // ignore formatting errors, emit raw
168 }
169 emit('update:modelValue', formatted)
170 emit('update', { countryCode: selectedCountryCode.value, isValid: phoneObject.valid })
171 }
172
173 const onInput = (
174 value: string,
175 phoneObject: { country: string; valid: boolean; number: string },
176 ) => {
177 if (manuallySelectedCountry.value) {
178 if (
179 phoneObject.country &&
180 phoneObject.country !== manuallySelectedCountry.value &&
181 value.replace(/\D/g, '').length > 6
182 ) {
183 selectedCountryCode.value = phoneObject.country
184 manuallySelectedCountry.value = ''
185 } else {
186 selectedCountryCode.value = manuallySelectedCountry.value
187 }
188 } else if (phoneObject.country) {
189 selectedCountryCode.value = phoneObject.country
190 }
191 formatAndEmit(phoneObject.number, phoneObject)
192 }
193
194 const convertIncomingValue = (incoming: string) => {
195 if (!incoming) return ''
196 try {
197 const pn = parsePhoneNumberFromString(incoming)
198 if (pn?.country) {
199 selectedCountryCode.value = pn.country
200 if (props.phoneFormat === 'international') return pn.formatInternational()
201 if (props.phoneFormat === 'e164') return pn.number
202 return pn.formatNational()
203 }
204 } catch {
205 return incoming
206 }
207 return incoming
208 }
209
210 onBeforeMount(() => {
211 if (props.modelValue) {
212 internalValue.value = convertIncomingValue(props.modelValue)
213 }
214 })
215
216 // Update internal value if external model changes
217 watch(
218 () => props.modelValue,
219 (nv) => {
220 if (nv && nv !== internalValue.value) {
221 internalValue.value = convertIncomingValue(nv)
222 }
223 },
224 )
225
226 // When format changes dynamically (rare in public form) reformat current value
227 watch(
228 () => props.phoneFormat,
229 () => {
230 if (internalValue.value) {
231 internalValue.value = convertIncomingValue(internalValue.value)
232 }
233 },
234 )
235
236 // Determine default country when auto detection is disabled
237 interface TelCountry {
238 iso2: string
239 // other props from vue-tel-input country objects are ignored for now
240 }
241
242 const computedDefaultCountry = computed(() => {
243 if (selectedCountryCode.value) return selectedCountryCode.value
244 if (!props.autoDetectCountry) {
245 const telInstance = vueTelRef.value as unknown as { allCountries?: TelCountry[] }
246 const list: TelCountry[] = telInstance?.allCountries ?? []
247 const first = list.find((c) => c.iso2 && !ignoredCountries.includes(c.iso2.toUpperCase()))
248 return first ? first.iso2 : 'us'
249 }
250 return undefined
251 })
252
253 onMounted(() => {
254 if (!props.autoDetectCountry && !selectedCountryCode.value) {
255 nextTick(() => {
256 const telInstance = vueTelRef.value as unknown as { allCountries?: TelCountry[] }
257 const list: TelCountry[] = telInstance?.allCountries ?? []
258 const first = list.find((c) => c.iso2 && !ignoredCountries.includes(c.iso2.toUpperCase()))
259 if (first) selectedCountryCode.value = first.iso2
260 })
261 }
262
263 // Add WCAG accessibility attributes to the input element
264 nextTick(() => {
265 const input = vueTelRef.value?.$el?.querySelector('.vti__input') as HTMLInputElement | null
266 if (input) {
267 // Ensure accessibility: only set aria-label for screen readers
268 input.setAttribute('aria-label', props.ariaLabel || getLabel('phone_number'))
269 }
270
271 // WCAG 4.1.2: Initialize dropdown as hidden and items as non-focusable to prevent nested interactive controls
272 const dropdown = phoneInputRef.value?.querySelector('.vti__dropdown-list') as HTMLElement | null
273 if (dropdown) {
274 dropdown.setAttribute('aria-hidden', 'true')
275 const items = dropdown.querySelectorAll('.vti__dropdown-item')
276 items.forEach((item) => {
277 item.setAttribute('tabindex', '-1')
278 })
279 }
280 })
281 })
282
283 const focus = () => {
284 const input = vueTelRef.value?.$el?.querySelector('.vti__input') as HTMLInputElement | null
285 input?.focus()
286 }
287
288 const isSecondary = inject('isSecondary', false)
289
290 defineExpose({ focus })
291 </script>
292
293 <style lang="scss">
294 .ivyforms-phone-input {
295 position: relative;
296 display: flex;
297 align-items: center;
298 width: 100%;
299
300 .vue-tel-input {
301 height: 40px;
302 border-radius: 8px;
303 box-shadow: none !important;
304 border: 1px solid var(--map-base-dusk-stroke--2);
305 flex: 1;
306 display: flex;
307 background: var(--map-base-dusk-o05);
308 transition: border-color 0.15s ease;
309
310 &:hover,
311 &:focus-within,
312 &.is-focused {
313 border-color: var(--map-base-dusk-stroke-0);
314 }
315 &.is-secondary:hover,
316 &.is-secondary:focus-within {
317 border-color: var(--map-base-purple-stroke-0);
318 }
319 &.disabled,
320 &.disabled:hover {
321 background: var(--map-base-dusk-o05);
322 opacity: 0.6;
323 cursor: not-allowed;
324 }
325
326 .vti__dropdown {
327 width: 64px;
328 min-width: 64px;
329 padding-left: 12px;
330 display: flex;
331 align-items: center;
332 justify-content: center;
333 background: transparent;
334 }
335 .vti__dropdown.open .ivyforms-phone-input__arrow {
336 transform: rotate(180deg);
337 }
338
339 .vti__dropdown-list {
340 left: 0;
341 padding: 8px 0;
342 margin: 0;
343 list-style: none;
344 max-height: 224px;
345 position: absolute;
346 background: var(--primitive-white);
347 width: 280px !important;
348 min-width: 280px;
349 box-shadow: var(--shadow-100);
350 border-radius: 4px;
351 border: none;
352 overflow-y: auto;
353 z-index: 10;
354 @include scrollbar();
355 &.below {
356 top: 45px;
357 }
358 .vti__dropdown-item {
359 height: auto;
360 display: flex;
361 align-items: flex-start;
362 gap: 8px;
363 font-size: 14px;
364 font-weight: 400;
365 padding: 4px 8px;
366 cursor: pointer;
367 transition: background-color 0.15s;
368 white-space: normal; /* allow wrapping */
369 line-height: 18px;
370 color: var(--map-base-text--2);
371
372 .vti__flag {
373 display: inline-block;
374 width: 20px;
375 height: 15px;
376 margin-top: 2px;
377 margin-right: 8px;
378 visibility: visible !important;
379 flex-shrink: 0;
380 }
381
382 strong {
383 font-weight: 400;
384 color: var(--map-base-text-0);
385 }
386 span {
387 color: var(--map-base-text--2);
388 }
389
390 &.highlighted {
391 background: var(--map-base-purple-o05);
392 }
393 &[aria-selected='true']='true'] {
394 background: var(--map-base-purple-o05);
395 strong,
396 span {
397 color: var(--map-base-purple-symbol-0);
398 }
399 }
400 }
401 }
402
403 .vti__input {
404 font-size: 16px;
405 font-weight: 400;
406 padding: 0 12px 0 4px;
407 color: var(--map-base-text-0);
408 background: transparent;
409 border: none;
410 width: 100%;
411 outline: none;
412 border-top-right-radius: 8px;
413 border-bottom-right-radius: 8px;
414 &::placeholder {
415 color: var(--map-base-text--1);
416 }
417 }
418 }
419 &__arrow {
420 transition: transform 0.15s ease;
421 }
422 }
423 </style>
424
425 <style lang="scss">
426 .el-form-item.is-error {
427 .ivyforms-phone-input.m-phone-number-input {
428 border-radius: 8px;
429 box-shadow: 0 0 0 1px var(--map-status-error-symbol-1) inset;
430 border: 1px solid var(--map-status-error-symbol-0);
431 .vue-tel-input {
432 border-color: transparent !important;
433 }
434 &:hover,
435 &:focus-within {
436 box-shadow: 0 0 0 1px var(--map-status-error-symbol-1) inset;
437 border: 1px solid var(--map-status-error-symbol-0);
438 .vue-tel-input {
439 border-color: transparent !important;
440 }
441 }
442 }
443 }
444 </style>
445