PluginProbe
The Innovative Form Builder – IvyForms / 0.4
The Innovative Form Builder – IvyForms v0.4
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 0.4, at frontend/src/views/_components/input/IvyPhoneInput.vue

392 lines 10.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
42 interface Props {
43 modelValue?: string | undefined
44 placeholder?: string
45 disabled?: boolean
46 phoneFormat?: 'international' | 'national' | 'e164'
47 autoDetectCountry?: boolean
48 }
49
50 const props = withDefaults(defineProps<Props>(), {
51 modelValue: '',
52 placeholder: '',
53 disabled: false,
54 phoneFormat: 'national',
55 autoDetectCountry: true,
56 })
57
58 const emit = defineEmits<{
59 (e: 'update:modelValue', value: string): void
60 (e: 'blur'): void
61 (e: 'update', payload: { countryCode: string; isValid: boolean }): void
62 }>()
63
64 const vueTelRef = ref<InstanceType<typeof VueTelInput> | null>(null)
65 const phoneInputRef = ref<HTMLElement | null>(null)
66 const internalValue = ref<string>('')
67 const selectedCountryCode = ref<string>('')
68 const manuallySelectedCountry = ref<string>('')
69 const isInputFocused = ref(false)
70
71 const localModelValue = computed({
72 get() {
73 return internalValue.value
74 },
75 set(v: string) {
76 internalValue.value = v
77 },
78 })
79
80 // Determine vue-tel-input mode (it supports 'international' and 'national'). For e164 we'll leverage international.
81 const vueTelMode = computed(() =>
82 props.phoneFormat === 'international' ? 'international' : 'national',
83 )
84
85 const examplePhoneNumber = computed(() => {
86 if (!selectedCountryCode.value) return ''
87 try {
88 const ex = getExampleNumber(selectedCountryCode.value as CountryCode, examples)
89 if (!ex) return ''
90 if (props.phoneFormat === 'international') return ex.formatInternational()
91 if (props.phoneFormat === 'e164') return ex.number
92 return ex.formatNational()
93 } catch {
94 return ''
95 }
96 })
97
98 const onCountryChanged = (country: { iso2: string }) => {
99 selectedCountryCode.value = country.iso2
100 manuallySelectedCountry.value = country.iso2
101 }
102
103 const onDropdownOpen = () => {
104 // Adjust dropdown width dynamically (ensures full country name visibility)
105 nextTick(() => {
106 const dropdown = phoneInputRef.value?.querySelector('.vti__dropdown-list') as HTMLElement | null
107 if (dropdown) {
108 dropdown.style.minWidth = '280px'
109 }
110 })
111 }
112 const onDropdownClose = () => {
113 // no-op: kept for API symmetry
114 }
115
116 const onInputFocus = () => {
117 isInputFocused.value = true
118 }
119 const handleBlur = () => {
120 isInputFocused.value = false
121 emit('blur')
122 }
123
124 const formatAndEmit = (rawNumber: string, phoneObject: { country: string; valid: boolean }) => {
125 let formatted = rawNumber
126 try {
127 const pn = parsePhoneNumberFromString(rawNumber || '', selectedCountryCode.value as CountryCode)
128 if (pn) {
129 if (props.phoneFormat === 'international') formatted = pn.formatInternational()
130 else if (props.phoneFormat === 'e164') formatted = pn.number
131 else formatted = pn.formatNational()
132 }
133 } catch {
134 // ignore formatting errors, emit raw
135 }
136 emit('update:modelValue', formatted)
137 emit('update', { countryCode: selectedCountryCode.value, isValid: phoneObject.valid })
138 }
139
140 const onInput = (
141 value: string,
142 phoneObject: { country: string; valid: boolean; number: string },
143 ) => {
144 if (manuallySelectedCountry.value) {
145 if (
146 phoneObject.country &&
147 phoneObject.country !== manuallySelectedCountry.value &&
148 value.replace(/\D/g, '').length > 6
149 ) {
150 selectedCountryCode.value = phoneObject.country
151 manuallySelectedCountry.value = ''
152 } else {
153 selectedCountryCode.value = manuallySelectedCountry.value
154 }
155 } else if (phoneObject.country) {
156 selectedCountryCode.value = phoneObject.country
157 }
158 formatAndEmit(phoneObject.number, phoneObject)
159 }
160
161 const convertIncomingValue = (incoming: string) => {
162 if (!incoming) return ''
163 try {
164 const pn = parsePhoneNumberFromString(incoming)
165 if (pn?.country) {
166 selectedCountryCode.value = pn.country
167 if (props.phoneFormat === 'international') return pn.formatInternational()
168 if (props.phoneFormat === 'e164') return pn.number
169 return pn.formatNational()
170 }
171 } catch {
172 return incoming
173 }
174 return incoming
175 }
176
177 onBeforeMount(() => {
178 if (props.modelValue) {
179 internalValue.value = convertIncomingValue(props.modelValue)
180 }
181 })
182
183 // Update internal value if external model changes
184 watch(
185 () => props.modelValue,
186 (nv) => {
187 if (nv && nv !== internalValue.value) {
188 internalValue.value = convertIncomingValue(nv)
189 }
190 },
191 )
192
193 // When format changes dynamically (rare in public form) reformat current value
194 watch(
195 () => props.phoneFormat,
196 () => {
197 if (internalValue.value) {
198 internalValue.value = convertIncomingValue(internalValue.value)
199 }
200 },
201 )
202
203 // Determine default country when auto detection is disabled
204 interface TelCountry {
205 iso2: string
206 // other props from vue-tel-input country objects are ignored for now
207 }
208
209 const computedDefaultCountry = computed(() => {
210 if (selectedCountryCode.value) return selectedCountryCode.value
211 if (!props.autoDetectCountry) {
212 const telInstance = vueTelRef.value as unknown as { allCountries?: TelCountry[] }
213 const list: TelCountry[] = telInstance?.allCountries ?? []
214 const first = list.find((c) => c.iso2 && !ignoredCountries.includes(c.iso2.toUpperCase()))
215 return first ? first.iso2 : 'us'
216 }
217 return undefined
218 })
219
220 onMounted(() => {
221 if (!props.autoDetectCountry && !selectedCountryCode.value) {
222 nextTick(() => {
223 const telInstance = vueTelRef.value as unknown as { allCountries?: TelCountry[] }
224 const list: TelCountry[] = telInstance?.allCountries ?? []
225 const first = list.find((c) => c.iso2 && !ignoredCountries.includes(c.iso2.toUpperCase()))
226 if (first) selectedCountryCode.value = first.iso2
227 })
228 }
229 })
230
231 const focus = () => {
232 const input = vueTelRef.value?.$el?.querySelector('.vti__input') as HTMLInputElement | null
233 input?.focus()
234 }
235
236 const isSecondary = inject('isSecondary', false)
237
238 defineExpose({ focus })
239 </script>
240
241 <style lang="scss">
242 .ivyforms-phone-input {
243 position: relative;
244 display: flex;
245 align-items: center;
246 width: 100%;
247
248 .vue-tel-input {
249 height: 40px;
250 border-radius: 8px;
251 box-shadow: none !important;
252 border: 1px solid var(--map-base-dusk-stroke--2);
253 flex: 1;
254 display: flex;
255 background: var(--map-base-dusk-o05);
256 transition: border-color 0.15s ease;
257
258 &:hover,
259 &:focus-within,
260 &.is-focused {
261 border-color: var(--map-base-dusk-stroke-0);
262 }
263 &.is-secondary:hover,
264 &.is-secondary:focus-within {
265 border-color: var(--map-base-purple-stroke-0);
266 }
267 &.disabled,
268 &.disabled:hover {
269 background: var(--map-base-dusk-o05);
270 opacity: 0.6;
271 cursor: not-allowed;
272 }
273
274 .vti__dropdown {
275 width: 64px;
276 min-width: 64px;
277 padding-left: 12px;
278 display: flex;
279 align-items: center;
280 justify-content: center;
281 background: transparent;
282 }
283 .vti__dropdown.open .ivyforms-phone-input__arrow {
284 transform: rotate(180deg);
285 }
286
287 .vti__dropdown-list {
288 left: 0;
289 padding: 8px 0;
290 margin: 0;
291 list-style: none;
292 max-height: 224px;
293 position: absolute;
294 background: var(--primitive-white);
295 width: 280px !important;
296 min-width: 280px;
297 box-shadow: var(--shadow-100);
298 border-radius: 4px;
299 border: none;
300 overflow-y: auto;
301 z-index: 10;
302 @include scrollbar();
303 &.below {
304 top: 45px;
305 }
306 .vti__dropdown-item {
307 height: auto;
308 display: flex;
309 align-items: flex-start;
310 gap: 8px;
311 font-size: 14px;
312 font-weight: 400;
313 padding: 4px 8px;
314 cursor: pointer;
315 transition: background-color 0.15s;
316 white-space: normal; /* allow wrapping */
317 line-height: 18px;
318
319 .vti__flag {
320 display: inline-block;
321 width: 20px;
322 height: 15px;
323 margin-top: 2px;
324 margin-right: 8px;
325 visibility: visible !important;
326 flex-shrink: 0;
327 }
328
329 strong {
330 font-weight: 400;
331 color: var(--map-base-text-0);
332 }
333 span {
334 color: var(--map-base-text--2);
335 }
336
337 &.highlighted {
338 background: var(--map-base-purple-o05);
339 }
340 &[aria-selected='true']='true'] {
341 background: var(--map-base-purple-o05);
342 strong,
343 span {
344 color: var(--map-base-purple-symbol-0);
345 }
346 }
347 }
348 }
349
350 .vti__input {
351 font-size: 16px;
352 font-weight: 400;
353 padding: 0 12px 0 4px;
354 color: var(--map-base-text-0);
355 background: transparent;
356 border: none;
357 width: 100%;
358 outline: none;
359 border-top-right-radius: 8px;
360 border-bottom-right-radius: 8px;
361 &::placeholder {
362 color: var(--map-base-text--1);
363 }
364 }
365 }
366 &__arrow {
367 transition: transform 0.15s ease;
368 }
369 }
370 </style>
371
372 <style lang="scss">
373 .el-form-item.is-error {
374 .ivyforms-phone-input.m-phone-number-input {
375 border-radius: 8px;
376 box-shadow: 0 0 0 1px var(--map-status-error-symbol-1) inset;
377 border: 1px solid var(--map-status-error-symbol-0);
378 .vue-tel-input {
379 border-color: transparent !important;
380 }
381 &:hover,
382 &:focus-within {
383 box-shadow: 0 0 0 1px var(--map-status-error-symbol-1) inset;
384 border: 1px solid var(--map-status-error-symbol-0);
385 .vue-tel-input {
386 border-color: transparent !important;
387 }
388 }
389 }
390 }
391 </style>
392