js-kompo-utils 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1 -0
  2. package/app.js +22 -0
  3. package/components/VlApexChart.vue +89 -0
  4. package/components/VlCKEditorTemplate.vue +52 -0
  5. package/components/VlCKEditorWithVars.vue +80 -0
  6. package/components/VlCollapsible.vue +91 -0
  7. package/components/VlCopyBox.vue +61 -0
  8. package/components/VlCreditCardInput.vue +104 -0
  9. package/components/VlDateMonthDay.vue +327 -0
  10. package/components/VlDateTextInput.vue +151 -0
  11. package/components/VlInternationalPhoneInput.vue +902 -0
  12. package/components/VlPartialDate.vue +283 -0
  13. package/components/VlPasswordInput.vue +358 -0
  14. package/components/VlQrCodeScanner.vue +91 -0
  15. package/components/VlSwipeableTab.vue +35 -0
  16. package/components/VlSwipeableTabs.vue +325 -0
  17. package/components/VlText.vue +72 -0
  18. package/components/VlTutorialStepBuilder.vue +671 -0
  19. package/components/VlValidatedInput.vue +262 -0
  20. package/components/data/countries.js +253 -0
  21. package/components/getAllDefaultComponents.js +12 -0
  22. package/docs/architecture.md +71 -0
  23. package/docs/tutorials.md +247 -0
  24. package/docs/usage.md +128 -0
  25. package/functions/index.js +7 -0
  26. package/functions/inputs.js +64 -0
  27. package/functions/loading.js +20 -0
  28. package/kompo-ckeditor-1.0.17/index.js +5 -0
  29. package/kompo-ckeditor-1.0.17/komponents/CKEditor.vue +36 -0
  30. package/kompo-ckeditor-1.0.17/komponents/CKEditorExtended.vue +66 -0
  31. package/kompo-ckeditor-1.0.17/komponents/EditableCKEditor.vue +42 -0
  32. package/kompo-ckeditor-1.0.17/komponents/TranslatableEditor.vue +28 -0
  33. package/kompo-ckeditor-1.0.17/mixins/CKEditor.js +291 -0
  34. package/kompo-ckeditor-1.0.17/mixins/CKEditorUtilities.js +52 -0
  35. package/kompo-ckeditor-1.0.17/mixins/CkUploadAdapter.js +95 -0
  36. package/kompo-ckeditor-1.0.17/mixins/translations/en.js +1 -0
  37. package/kompo-ckeditor-1.0.17/mixins/translations/fr.js +1 -0
  38. package/kompo-ckeditor-1.0.17/plugins/JsonBlockCommand.js +100 -0
  39. package/kompo-ckeditor-1.0.17/plugins/JsonBlockEditing.js +225 -0
  40. package/kompo-ckeditor-1.0.17/plugins/JsonBlockUi.js +21 -0
  41. package/kompo-ckeditor-1.0.17/plugins/MentionPlugin.js +118 -0
  42. package/orchestrator/KompoJsBuilder.js +333 -0
  43. package/package.json +25 -0
  44. package/raw_animations/count-up-animation.js +220 -0
  45. package/raw_animations/magic-poof.js +210 -0
  46. package/tutorials/TutorialConfig.js +34 -0
  47. package/tutorials/TutorialEngine.js +448 -0
  48. package/tutorials/index.js +2 -0
  49. package/tutorials/modules/AutoNext.js +86 -0
  50. package/tutorials/modules/Avatar.js +94 -0
  51. package/tutorials/modules/Bubble.js +617 -0
  52. package/tutorials/modules/Cursor.js +315 -0
  53. package/tutorials/modules/Highlight.js +282 -0
  54. package/tutorials/modules/HoverForcer.js +250 -0
  55. package/tutorials/modules/Mobile.js +148 -0
  56. package/tutorials/modules/Options.js +98 -0
  57. package/tutorials/modules/Overlay.js +142 -0
  58. package/tutorials/modules/Scroll.js +37 -0
  59. package/tutorials/modules/Typewriter.js +132 -0
  60. package/tutorials/step-builder/step-builder-bridge.js +436 -0
  61. package/tutorials/step-builder/utils/bezier-editor.js +379 -0
  62. package/tutorials/step-builder/utils/code-generator.js +127 -0
  63. package/tutorials/step-builder/utils/element-picker.js +125 -0
  64. package/tutorials/tutorial.scss +816 -0
  65. package/tutorials/utils/selectors.js +225 -0
  66. package/tutorials/utils/stability.js +76 -0
  67. package/tutorials/utils/svg.js +46 -0
@@ -0,0 +1,902 @@
1
+ <template>
2
+ <vl-form-field v-bind="$_wrapperAttributes">
3
+ <div :class="inputGroupClass">
4
+ <!-- Country Selector -->
5
+ <div class="country-selector" ref="countrySelector">
6
+ <button
7
+ type="button"
8
+ class="country-trigger"
9
+ :class="{ 'is-open': isDropdownOpen }"
10
+ @click="toggleDropdown"
11
+ @keydown="onTriggerKeydown"
12
+ >
13
+ <img v-if="currentCountry.flag && !isDetectingCountry" :src="currentCountry.flag" :alt="currentCountry.name" class="flag-img" />
14
+ <span v-else-if="isDetectingCountry" class="flag-loading">⏳</span>
15
+ <span v-else class="flag-fallback">🌍</span>
16
+ <span class="code">+{{ currentCountry.dialCode }}</span>
17
+ <span class="arrow">▼</span>
18
+ </button>
19
+
20
+ <div
21
+ v-if="isDropdownOpen"
22
+ class="country-dropdown"
23
+ @click.stop
24
+ >
25
+ <div class="search-container">
26
+ <input
27
+ ref="searchInput"
28
+ v-model="searchTerm"
29
+ type="text"
30
+ placeholder="Search countries..."
31
+ class="search-input"
32
+ @keydown="onSearchKeydown"
33
+ @input="onSearchInput"
34
+ />
35
+ </div>
36
+
37
+ <div class="countries-list" ref="countriesList">
38
+ <button
39
+ v-for="country in filteredCountries"
40
+ :key="country.code"
41
+ type="button"
42
+ class="country-option"
43
+ :class="{ 'is-selected': country.code === selectedCountryCode }"
44
+ @click="selectCountry(country)"
45
+ >
46
+ <img v-if="country.flag" :src="country.flag" :alt="country.name" class="flag-img" />
47
+ <span v-else class="flag-fallback">🌍</span>
48
+ <span class="name">{{ country.name }}</span>
49
+ <span class="dial-code">+{{ country.dialCode }}</span>
50
+ </button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <!-- Phone Input -->
56
+ <input
57
+ ref="phoneInput"
58
+ class="phone-input"
59
+ :value="formattedValue"
60
+ v-bind="$_attributes"
61
+ v-on="$_events"
62
+ @input="onPhoneInput"
63
+ @keydown="onPhoneKeydown"
64
+ @focus="onPhoneFocus"
65
+ @blur="onPhoneBlur"
66
+ />
67
+ </div>
68
+ </vl-form-field>
69
+ </template>
70
+
71
+ <script>
72
+ import Field from 'vue-kompo/js/form/mixins/Field'
73
+ import HasInputAttributes from 'vue-kompo/js/form/mixins/HasInputAttributes'
74
+ import { parsePhoneNumberFromString, getCountries, getCountryCallingCode, AsYouType } from 'libphonenumber-js'
75
+ import { COUNTRIES_DATA } from './data/countries.js'
76
+
77
+ export default {
78
+ name: 'VlInternationalPhoneInput',
79
+ mixins: [Field, HasInputAttributes],
80
+
81
+ data() {
82
+ return {
83
+ // Phone number state
84
+ phoneValue: this.component?.value || '',
85
+
86
+ // Country selection state
87
+ selectedCountryCode: this.$_config('country') || this.$_config('defaultCountry'),
88
+ isDropdownOpen: false,
89
+ searchTerm: '',
90
+
91
+ // Input state
92
+ isFocused: false,
93
+
94
+ // Countries data
95
+ countriesData: COUNTRIES_DATA,
96
+
97
+ // Geolocation state
98
+ isDetectingCountry: false,
99
+ }
100
+ },
101
+
102
+ computed: {
103
+ $_attributes() {
104
+ return {
105
+ ...this.$_defaultFieldAttributes,
106
+ ...this.$_defaultInputAttributes,
107
+ type: 'tel',
108
+ inputmode: 'tel',
109
+ autocomplete: 'tel',
110
+ placeholder: this.placeholder,
111
+ }
112
+ },
113
+
114
+ placeholder() {
115
+ return this.$_config('placeholder') || 'Phone number'
116
+ },
117
+
118
+ inputGroupClass() {
119
+ const baseClass = 'vlInputGroup rounded-lg flex items-stretch'
120
+ const errorClass = this.showError ? (this.$_config('invalidClass') || '!border !border-red-600') : ''
121
+ return `${baseClass} ${errorClass}`
122
+ },
123
+
124
+ // Current country info
125
+ currentCountry() {
126
+ const country = this.countries.find(c => c.code === this.selectedCountryCode)
127
+ return country || this.countries[0] // Fallback to first country
128
+ },
129
+
130
+ // All countries with metadata - static data
131
+ countries() {
132
+ return this.countriesData
133
+ },
134
+
135
+ // Filtered countries based on search
136
+ filteredCountries() {
137
+ if (!this.searchTerm.trim()) {
138
+ return this.countries
139
+ }
140
+
141
+ const query = this.searchTerm.toLowerCase()
142
+ return this.countries.filter(country => {
143
+ // Search by country name
144
+ if (country.name.toLowerCase().includes(query)) return true
145
+
146
+ // Search by country code
147
+ if (country.code.toLowerCase().includes(query)) return true
148
+
149
+ // Search by dial code (with or without +)
150
+ const dialCode = country.dialCode
151
+ const dialCodeWithPlus = `+${dialCode}`
152
+ if (dialCode.includes(query) || dialCodeWithPlus.includes(query)) return true
153
+
154
+ // Search by dial code without the + prefix
155
+ if (query.startsWith('+')) {
156
+ const queryWithoutPlus = query.substring(1)
157
+ if (dialCode.includes(queryWithoutPlus)) return true
158
+ }
159
+
160
+ return false
161
+ })
162
+ },
163
+
164
+ // Formatted phone value for display
165
+ formattedValue() {
166
+ if (!this.phoneValue) return ''
167
+
168
+ try {
169
+ // If phone value starts with the country code, show it formatted
170
+ if (this.phoneValue.startsWith('+')) {
171
+ const asYouType = new AsYouType()
172
+ const formatted = asYouType.input(this.phoneValue)
173
+ return formatted || this.phoneValue
174
+ } else {
175
+ // For national numbers, format with country context
176
+ const asYouType = new AsYouType(this.selectedCountryCode)
177
+ const formatted = asYouType.input(this.phoneValue)
178
+ return formatted || this.phoneValue
179
+ }
180
+ } catch (error) {
181
+ return this.phoneValue
182
+ }
183
+ },
184
+
185
+ // Validation state
186
+ isValidPhone() {
187
+ if (!this.phoneValue) return true // Empty is valid
188
+
189
+ try {
190
+ const phoneNumber = parsePhoneNumberFromString(this.phoneValue, this.selectedCountryCode)
191
+ return phoneNumber ? phoneNumber.isValid() : false
192
+ } catch (error) {
193
+ return false
194
+ }
195
+ },
196
+
197
+ showError() {
198
+ const validateFront = this.$_config('validateFront')
199
+ if (!validateFront) return false
200
+
201
+ return this.phoneValue && !this.isValidPhone && !this.isFocused
202
+ },
203
+ },
204
+
205
+ watch: {
206
+ 'component.value'(newValue) {
207
+ this.phoneValue = newValue || ''
208
+
209
+ // Auto-detect country from phone number if no country is explicitly set
210
+ if (newValue && !this.$_config('country')) {
211
+ this.autoDetectCountryFromPhone(newValue)
212
+ }
213
+ },
214
+
215
+ selectedCountryCode(newCode, oldCode) {
216
+ if (newCode !== oldCode && this.phoneValue) {
217
+ this.reformatWithCountry(newCode)
218
+ }
219
+ }
220
+ },
221
+
222
+ mounted() {
223
+ // Close dropdown when clicking outside
224
+ document.addEventListener('click', this.handleClickOutside)
225
+
226
+ // Set initial value
227
+ // this.component.value = this.phoneValue
228
+
229
+ // Auto-detect country if no explicit country is set and no phone value exists
230
+ if (this.phoneValue) {
231
+ this.autoDetectCountryFromPhone(this.phoneValue)
232
+ } else if (!this.selectedCountryCode) {
233
+ this.detectCountryByLocation()
234
+ }
235
+ },
236
+
237
+ beforeDestroy() {
238
+ document.removeEventListener('click', this.handleClickOutside)
239
+ },
240
+
241
+ methods: {
242
+ // Country selection methods
243
+ toggleDropdown() {
244
+ this.isDropdownOpen = !this.isDropdownOpen
245
+ if (this.isDropdownOpen) {
246
+ this.$nextTick(() => {
247
+ this.focusSearchInput()
248
+ })
249
+ } else {
250
+ this.searchTerm = ''
251
+ }
252
+ },
253
+
254
+ selectCountry(country) {
255
+ const previousCountryCode = this.selectedCountryCode;
256
+ const previousCountry = this.countries.find(c => c.code === previousCountryCode);
257
+ this.selectedCountryCode = country.code
258
+ this.isDropdownOpen = false
259
+ this.searchTerm = ''
260
+
261
+ // Get current and new dial codes
262
+ const previousDialCode = previousCountry ? previousCountry.dialCode : '';
263
+ const newDialCode = country.dialCode;
264
+
265
+ // Check if current value is just a country code (like +54, +1, etc.)
266
+ const currentDialCode = `+${previousDialCode}`
267
+ const isOnlyCountryCode = !this.phoneValue ||
268
+ this.phoneValue === currentDialCode ||
269
+ this.phoneValue === previousDialCode ||
270
+ this.phoneValue.replace(/\D/g, '') === previousDialCode
271
+
272
+ if (isOnlyCountryCode) {
273
+ // Set new country code and ensure component.value is complete
274
+ this.phoneValue = `+${newDialCode}`
275
+ this.component.value = `+${newDialCode}`
276
+
277
+ // Update input display immediately
278
+ this.$nextTick(() => {
279
+ if (this.$refs.phoneInput) {
280
+ this.$refs.phoneInput.value = this.formattedValue
281
+ }
282
+ })
283
+ } else {
284
+ // Extract national number and reformat with new country
285
+ let nationalNumber = this.phoneValue;
286
+ console.log(nationalNumber, previousDialCode);
287
+ // Remove previous country code if it exists
288
+ if (nationalNumber.startsWith(`+${previousDialCode}`)) {
289
+ nationalNumber = nationalNumber.substring((`+${previousDialCode}`).length).trim();
290
+ } else if (nationalNumber.startsWith(previousDialCode)) {
291
+ nationalNumber = nationalNumber.substring(previousDialCode.length).trim();
292
+ } else if (nationalNumber.startsWith('+')) {
293
+ // Try to parse and extract national number
294
+ try {
295
+ const parsed = parsePhoneNumberFromString(nationalNumber);
296
+ if (parsed && parsed.nationalNumber) {
297
+ nationalNumber = parsed.nationalNumber;
298
+ }
299
+ } catch (error) {
300
+ // Keep original if parsing fails
301
+ }
302
+ }
303
+
304
+ // Set new phone value with new country code
305
+ this.phoneValue = `+${newDialCode}${nationalNumber}`;
306
+ this.component.value = this.phoneValue;
307
+
308
+ // Try to format properly
309
+ try {
310
+ const phoneNumber = parsePhoneNumberFromString(this.phoneValue);
311
+ if (phoneNumber && phoneNumber.isValid()) {
312
+ this.component.value = phoneNumber.number;
313
+ }
314
+ } catch (error) {
315
+ // Keep current value if parsing fails
316
+ }
317
+ }
318
+
319
+ // Focus back to phone input
320
+ this.$nextTick(() => {
321
+ this.focusPhoneInput()
322
+ })
323
+ },
324
+
325
+
326
+ reformatWithCountry(phoneValue, countryCode) {
327
+ try {
328
+ const phoneNumber = parsePhoneNumberFromString(phoneValue, countryCode)
329
+ if (phoneNumber && phoneNumber.isValid()) {
330
+ // Always keep the full international number in component.value
331
+ this.phoneValue = phoneNumber.number
332
+ this.component.value = phoneNumber.number
333
+ } else {
334
+ // Fallback: ensure we have at least the country code
335
+ const country = this.countries.find(c => c.code === countryCode);
336
+ if (country) {
337
+ const cleanValue = phoneValue.replace(/\D/g, '');
338
+ this.phoneValue = `+${country.dialCode}${cleanValue}`;
339
+ this.component.value = this.phoneValue;
340
+ }
341
+ }
342
+ } catch (error) {
343
+ // Keep current value if parsing fails
344
+ }
345
+ },
346
+
347
+ // Search methods
348
+ focusSearchInput() {
349
+ if (this.$refs.searchInput) {
350
+ this.$refs.searchInput.focus()
351
+ }
352
+ },
353
+
354
+ onSearchInput() {
355
+ // Search is reactive via computed property
356
+ },
357
+
358
+ onSearchKeydown(event) {
359
+ switch (event.key) {
360
+ case 'Escape':
361
+ this.closeDropdown()
362
+ this.focusPhoneInput()
363
+ break
364
+ case 'ArrowDown':
365
+ event.preventDefault()
366
+ this.focusFirstCountry()
367
+ break
368
+ case 'Enter':
369
+ event.preventDefault()
370
+ if (this.filteredCountries.length > 0) {
371
+ this.selectCountry(this.filteredCountries[0])
372
+ }
373
+ break
374
+ }
375
+ },
376
+
377
+ focusFirstCountry() {
378
+ this.$nextTick(() => {
379
+ const firstOption = this.$refs.countriesList?.querySelector('.country-option')
380
+ if (firstOption) {
381
+ firstOption.focus()
382
+ }
383
+ })
384
+ },
385
+
386
+ // Phone input methods
387
+ onPhoneInput(event) {
388
+ const input = event.target
389
+ const newValue = input.value
390
+
391
+ // Check if input contains country code (starts with +)
392
+ if (newValue.startsWith('+')) {
393
+ this.handleInputWithCountryCode(newValue)
394
+ return
395
+ }
396
+
397
+ // Clean the input value
398
+ const cleanedValue = this.cleanPhoneInput(newValue)
399
+
400
+ // Check if value exceeds maximum length
401
+ if (!this.isWithinMaxLength(cleanedValue)) {
402
+ // Restore previous value
403
+ input.value = this.formattedValue
404
+ return
405
+ }
406
+
407
+ this.updateInput(input, cleanedValue)
408
+ },
409
+
410
+ updateInput(input, cleanedValue = null) {
411
+ // Always ensure we have the country code in the phone value
412
+ const country = this.countries.find(c => c.code === this.selectedCountryCode);
413
+ let fullPhoneValue = cleanedValue;
414
+
415
+ if (cleanedValue && country) {
416
+ // If the cleaned value doesn't start with the country code, add it
417
+ if (!cleanedValue.startsWith(`+${country.dialCode}`) && !cleanedValue.startsWith(country.dialCode)) {
418
+ fullPhoneValue = `+${country.dialCode}${cleanedValue.replace(/^\+/, '')}`;
419
+ } else if (cleanedValue.startsWith(country.dialCode) && !cleanedValue.startsWith(`+${country.dialCode}`)) {
420
+ fullPhoneValue = `+${cleanedValue}`;
421
+ }
422
+ }
423
+
424
+ // Update internal state - phoneValue for display, component.value for the complete number
425
+ this.phoneValue = fullPhoneValue || cleanedValue
426
+ this.component.value = this.getE164Number(this.phoneValue)
427
+
428
+ // Update display value with proper formatting
429
+ this.$nextTick(() => {
430
+ const formatted = this.formattedValue
431
+ input.value = formatted
432
+
433
+ // Maintain cursor position
434
+ const cursorPos = Math.min(input.selectionStart, formatted.length)
435
+ input.setSelectionRange(cursorPos, cursorPos)
436
+ })
437
+ },
438
+
439
+ handleInputWithCountryCode(inputValue) {
440
+ try {
441
+ // Parse the full international number
442
+ const phoneNumber = parsePhoneNumberFromString(inputValue)
443
+
444
+ if (phoneNumber && phoneNumber.country) {
445
+ // Update country if detected
446
+ this.selectedCountryCode = phoneNumber.country
447
+
448
+ // Store the full international number to preserve country code in display
449
+ this.phoneValue = inputValue
450
+ this.component.value = phoneNumber.number || inputValue
451
+ } else {
452
+ // If can't parse, treat as regular input
453
+ const cleanedValue = this.cleanPhoneInput(inputValue)
454
+ this.phoneValue = cleanedValue
455
+ this.component.value = this.getE164Number(cleanedValue)
456
+ }
457
+ } catch (error) {
458
+ console.warn('Error parsing phone with country code:', error)
459
+ // Fallback to regular input handling
460
+ const cleanedValue = this.cleanPhoneInput(inputValue)
461
+ this.phoneValue = cleanedValue
462
+ this.component.value = this.getE164Number(cleanedValue)
463
+ }
464
+ },
465
+
466
+ onPhoneKeydown(event) {
467
+ // Allow control keys
468
+ const controlKeys = ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Tab', 'Escape', 'Enter']
469
+ if (controlKeys.includes(event.key)) return
470
+
471
+ // Allow copy/paste shortcuts
472
+ const isShortcut = (event.ctrlKey || event.metaKey) &&
473
+ ['a', 'c', 'v', 'x'].includes(event.key.toLowerCase())
474
+ if (isShortcut) return
475
+
476
+ // Allow only digits, spaces, dashes, parentheses, and plus
477
+ const allowedChars = /[0-9\s\-\(\)\+]/
478
+ if (!allowedChars.test(event.key)) {
479
+ event.preventDefault()
480
+ return
481
+ }
482
+
483
+ // Handle plus sign - only allow at beginning
484
+ if (event.key === '+') {
485
+ const input = event.target
486
+ const cursorPos = input.selectionStart
487
+ const hasExistingPlus = input.value.includes('+')
488
+
489
+ if (hasExistingPlus || cursorPos !== 0) {
490
+ event.preventDefault()
491
+ }
492
+ }
493
+ },
494
+
495
+ onPhoneFocus() {
496
+ this.isFocused = true
497
+ },
498
+
499
+ onPhoneBlur() {
500
+ this.isFocused = false
501
+ },
502
+
503
+ focusPhoneInput() {
504
+ if (this.$refs.phoneInput) {
505
+ this.$refs.phoneInput.focus()
506
+ }
507
+ },
508
+
509
+ // Utility methods
510
+ cleanPhoneInput(value) {
511
+ if (!value) return ''
512
+
513
+ // Remove all non-digit characters except spaces, dashes, parentheses, and plus
514
+ return value.replace(/[^0-9\s\-\(\)\+]/g, '')
515
+ },
516
+
517
+ isWithinMaxLength(value) {
518
+ // E.164 maximum is 15 digits total
519
+ const digitsOnly = value.replace(/\D/g, '')
520
+ return digitsOnly.length <= 15
521
+ },
522
+
523
+ getE164Number(phoneValue) {
524
+ if (!phoneValue) return ''
525
+
526
+ try {
527
+ // If it already looks like an international number, try to parse it as-is
528
+ if (phoneValue.startsWith('+')) {
529
+ const phoneNumber = parsePhoneNumberFromString(phoneValue)
530
+ return phoneNumber ? phoneNumber.number : phoneValue
531
+ }
532
+
533
+ // Otherwise, try to parse it with the selected country
534
+ const phoneNumber = parsePhoneNumberFromString(phoneValue, this.selectedCountryCode)
535
+ return phoneNumber ? phoneNumber.number : phoneValue
536
+ } catch (error) {
537
+ return phoneValue
538
+ }
539
+ },
540
+
541
+ closeDropdown() {
542
+ this.isDropdownOpen = false
543
+ this.searchTerm = ''
544
+ },
545
+
546
+ handleClickOutside(event) {
547
+ if (this.isDropdownOpen && !this.$refs.countrySelector.contains(event.target)) {
548
+ this.closeDropdown()
549
+ }
550
+ },
551
+
552
+ // Auto-detect country from IP geolocation
553
+ async detectCountryByLocation() {
554
+ if (this.isDetectingCountry) return
555
+
556
+ this.isDetectingCountry = true
557
+
558
+ try {
559
+ // Try multiple geolocation services for better reliability
560
+ const countryCode = await this.getCountryFromIP()
561
+ if (countryCode && this.countries.find(c => c.code === countryCode)) {
562
+ this.selectedCountryCode = countryCode
563
+ }
564
+ } catch (error) {
565
+ console.warn('Failed to detect country by location:', error)
566
+ } finally {
567
+ this.isDetectingCountry = false
568
+ }
569
+ },
570
+
571
+ async getCountryFromIP() {
572
+ // Try ipapi.co first (free, reliable)
573
+ try {
574
+ const response = await fetch('https://ipapi.co/json/', {
575
+ timeout: 5000
576
+ })
577
+ const data = await response.json()
578
+ return data.country_code
579
+ } catch (error) {
580
+ console.warn('ipapi.co failed:', error)
581
+ }
582
+
583
+ // Fallback to ip-api.com
584
+ try {
585
+ const response = await fetch('http://ip-api.com/json/', {
586
+ timeout: 5000
587
+ })
588
+ const data = await response.json()
589
+ return data.countryCode
590
+ } catch (error) {
591
+ console.warn('ip-api.com failed:', error)
592
+ }
593
+
594
+ // Fallback to ipinfo.io
595
+ try {
596
+ const response = await fetch('https://ipinfo.io/json', {
597
+ timeout: 5000
598
+ })
599
+ const data = await response.json()
600
+ return data.country
601
+ } catch (error) {
602
+ console.warn('ipinfo.io failed:', error)
603
+ }
604
+
605
+ return null
606
+ },
607
+
608
+ // Auto-detect country from existing phone number
609
+ autoDetectCountryFromPhone(phoneValue) {;
610
+ try {
611
+ const phoneNumber = parsePhoneNumberFromString(phoneValue)
612
+ if (phoneNumber && phoneNumber.country) {
613
+ this.selectedCountryCode = phoneNumber.country
614
+
615
+ return true;
616
+ }
617
+ } catch (error) {
618
+ console.warn('Failed to auto-detect country from phone:', error)
619
+ }
620
+
621
+ return false;
622
+ },
623
+
624
+ // API methods
625
+ async loadCountriesData() {
626
+ try {
627
+ this.isLoadingCountries = true
628
+
629
+ // Fetch from REST Countries API
630
+ const response = await fetch('https://restcountries.com/v3.1/all?fields=name,cca2,idd,flags')
631
+ const countries = await response.json()
632
+
633
+ // Transform data to our format
634
+ this.countriesData = countries
635
+ .filter(country => country.idd?.root && country.idd?.suffixes?.length > 0)
636
+ .map(country => ({
637
+ code: country.cca2,
638
+ name: country.name.common,
639
+ dialCode: country.idd.root + country.idd.suffixes[0],
640
+ flag: country.flags?.png || null
641
+ }))
642
+ .sort((a, b) => a.name.localeCompare(b.name))
643
+
644
+ } catch (error) {
645
+ console.error('Failed to load countries data:', error)
646
+ // Fallback to libphonenumber-js data
647
+ this.countriesData = getCountries().map(code => ({
648
+ code,
649
+ name: this.getCountryName(code),
650
+ dialCode: getCountryCallingCode(code),
651
+ flag: null
652
+ }))
653
+ } finally {
654
+ this.isLoadingCountries = false
655
+ }
656
+ },
657
+
658
+ // Fallback country data methods
659
+ getCountryName(code) {
660
+ const countryNames = {
661
+ 'US': 'United States', 'CA': 'Canada', 'GB': 'United Kingdom', 'FR': 'France',
662
+ 'DE': 'Germany', 'IT': 'Italy', 'ES': 'Spain', 'AU': 'Australia',
663
+ 'JP': 'Japan', 'CN': 'China', 'IN': 'India', 'BR': 'Brazil',
664
+ 'MX': 'Mexico', 'AR': 'Argentina', 'RU': 'Russia', 'ZA': 'South Africa',
665
+ 'EG': 'Egypt', 'NG': 'Nigeria', 'KE': 'Kenya', 'MA': 'Morocco',
666
+ 'SA': 'Saudi Arabia', 'AE': 'UAE', 'IL': 'Israel', 'TR': 'Turkey',
667
+ 'TH': 'Thailand', 'SG': 'Singapore', 'MY': 'Malaysia', 'ID': 'Indonesia',
668
+ 'PH': 'Philippines', 'VN': 'Vietnam', 'KR': 'South Korea', 'TW': 'Taiwan',
669
+ 'HK': 'Hong Kong', 'NZ': 'New Zealand', 'CH': 'Switzerland', 'AT': 'Austria',
670
+ 'BE': 'Belgium', 'NL': 'Netherlands', 'DK': 'Denmark', 'SE': 'Sweden',
671
+ 'NO': 'Norway', 'FI': 'Finland', 'PL': 'Poland', 'CZ': 'Czech Republic',
672
+ 'HU': 'Hungary', 'RO': 'Romania', 'BG': 'Bulgaria', 'GR': 'Greece',
673
+ 'PT': 'Portugal', 'IE': 'Ireland', 'IS': 'Iceland', 'LU': 'Luxembourg',
674
+ 'MT': 'Malta', 'CY': 'Cyprus', 'EE': 'Estonia', 'LV': 'Latvia',
675
+ 'LT': 'Lithuania', 'SK': 'Slovakia', 'SI': 'Slovenia', 'HR': 'Croatia',
676
+ 'RS': 'Serbia', 'BA': 'Bosnia', 'ME': 'Montenegro', 'MK': 'North Macedonia',
677
+ 'AL': 'Albania', 'XK': 'Kosovo', 'MD': 'Moldova', 'UA': 'Ukraine',
678
+ 'BY': 'Belarus', 'GE': 'Georgia', 'AM': 'Armenia', 'AZ': 'Azerbaijan',
679
+ 'KZ': 'Kazakhstan', 'UZ': 'Uzbekistan', 'KG': 'Kyrgyzstan', 'TJ': 'Tajikistan',
680
+ 'TM': 'Turkmenistan', 'MN': 'Mongolia', 'AF': 'Afghanistan', 'PK': 'Pakistan',
681
+ 'BD': 'Bangladesh', 'LK': 'Sri Lanka', 'MV': 'Maldives', 'BT': 'Bhutan',
682
+ 'NP': 'Nepal', 'MM': 'Myanmar', 'LA': 'Laos', 'KH': 'Cambodia',
683
+ 'BN': 'Brunei', 'TL': 'East Timor', 'FJ': 'Fiji', 'PG': 'Papua New Guinea',
684
+ 'SB': 'Solomon Islands', 'VU': 'Vanuatu', 'NC': 'New Caledonia', 'PF': 'French Polynesia',
685
+ 'WS': 'Samoa', 'TO': 'Tonga', 'KI': 'Kiribati', 'TV': 'Tuvalu',
686
+ 'NR': 'Nauru', 'PW': 'Palau', 'FM': 'Micronesia', 'MH': 'Marshall Islands',
687
+ 'CK': 'Cook Islands', 'NU': 'Niue', 'TK': 'Tokelau', 'WF': 'Wallis and Futuna',
688
+ 'AS': 'American Samoa', 'GU': 'Guam', 'MP': 'Northern Mariana Islands', 'VI': 'US Virgin Islands',
689
+ 'PR': 'Puerto Rico', 'DO': 'Dominican Republic', 'CU': 'Cuba', 'JM': 'Jamaica',
690
+ 'HT': 'Haiti', 'TT': 'Trinidad and Tobago', 'BB': 'Barbados', 'LC': 'Saint Lucia',
691
+ 'VC': 'Saint Vincent', 'GD': 'Grenada', 'AG': 'Antigua and Barbuda', 'KN': 'Saint Kitts and Nevis',
692
+ 'DM': 'Dominica', 'BZ': 'Belize', 'GT': 'Guatemala', 'SV': 'El Salvador',
693
+ 'HN': 'Honduras', 'NI': 'Nicaragua', 'CR': 'Costa Rica', 'PA': 'Panama',
694
+ 'CO': 'Colombia', 'VE': 'Venezuela', 'GY': 'Guyana', 'SR': 'Suriname',
695
+ 'GF': 'French Guiana', 'UY': 'Uruguay', 'PY': 'Paraguay', 'BO': 'Bolivia',
696
+ 'PE': 'Peru', 'EC': 'Ecuador', 'CL': 'Chile', 'FK': 'Falkland Islands',
697
+ 'GS': 'South Georgia', 'SH': 'Saint Helena', 'AC': 'Ascension Island', 'TA': 'Tristan da Cunha',
698
+ 'CV': 'Cape Verde', 'ST': 'São Tomé and Príncipe', 'GQ': 'Equatorial Guinea', 'GA': 'Gabon',
699
+ 'CG': 'Republic of the Congo', 'CD': 'Democratic Republic of the Congo', 'CF': 'Central African Republic',
700
+ 'TD': 'Chad', 'CM': 'Cameroon', 'NE': 'Niger', 'BF': 'Burkina Faso',
701
+ 'ML': 'Mali', 'SN': 'Senegal', 'GM': 'Gambia', 'GW': 'Guinea-Bissau',
702
+ 'GN': 'Guinea', 'SL': 'Sierra Leone', 'LR': 'Liberia', 'CI': 'Ivory Coast',
703
+ 'GH': 'Ghana', 'TG': 'Togo', 'BJ': 'Benin', 'DZ': 'Algeria',
704
+ 'TN': 'Tunisia', 'LY': 'Libya', 'SD': 'Sudan', 'SS': 'South Sudan',
705
+ 'ET': 'Ethiopia', 'ER': 'Eritrea', 'DJ': 'Djibouti', 'SO': 'Somalia',
706
+ 'UG': 'Uganda', 'RW': 'Rwanda', 'BI': 'Burundi', 'TZ': 'Tanzania',
707
+ 'MW': 'Malawi', 'ZM': 'Zambia', 'ZW': 'Zimbabwe', 'BW': 'Botswana',
708
+ 'NA': 'Namibia', 'SZ': 'Eswatini', 'LS': 'Lesotho', 'MG': 'Madagascar',
709
+ 'MU': 'Mauritius', 'SC': 'Seychelles', 'KM': 'Comoros', 'YT': 'Mayotte',
710
+ 'RE': 'Réunion', 'MZ': 'Mozambique', 'AO': 'Angola'
711
+ }
712
+ return countryNames[code] || code
713
+ },
714
+
715
+ onTriggerKeydown(event) {
716
+ if (event.key === 'Enter' || event.key === ' ') {
717
+ event.preventDefault()
718
+ this.toggleDropdown()
719
+ }
720
+ }
721
+ }
722
+ }
723
+ </script>
724
+
725
+ <style scoped>
726
+ .vlInputGroup {
727
+ display: flex;
728
+ align-items: stretch;
729
+ border-radius: 0.5rem;
730
+ }
731
+
732
+ .country-selector {
733
+ position: relative;
734
+ flex-shrink: 0;
735
+ }
736
+
737
+ .country-trigger {
738
+ display: flex;
739
+ align-items: center;
740
+ padding: 0.5rem 0.75rem;
741
+ border: 1px solid #d1d5db;
742
+ border-right: none;
743
+ border-radius: 0.5rem 0 0 0.5rem;
744
+ background: white;
745
+ cursor: pointer;
746
+ user-select: none;
747
+ min-width: 120px;
748
+ transition: border-color 0.15s ease-in-out;
749
+ }
750
+
751
+ .country-trigger:hover {
752
+ border-color: #9ca3af;
753
+ }
754
+
755
+ .country-trigger.is-open {
756
+ border-color: #3b82f6;
757
+ box-shadow: 0 0 0 1px #3b82f6;
758
+ }
759
+
760
+ .flag-img {
761
+ width: 20px;
762
+ height: 15px;
763
+ margin-right: 0.375rem;
764
+ border-radius: 2px;
765
+ object-fit: cover;
766
+ }
767
+
768
+ .flag-fallback {
769
+ margin-right: 0.375rem;
770
+ font-size: 1rem;
771
+ }
772
+ .flag-loading {
773
+ margin-right: 0.375rem;
774
+ font-size: 0.875rem;
775
+ animation: pulse 1.5s ease-in-out infinite;
776
+ }
777
+ @keyframes pulse {
778
+ 0%, 100% { opacity: 1; }
779
+ 50% { opacity: 0.5; }
780
+ }
781
+
782
+ .country-trigger .code {
783
+ font-weight: 500;
784
+ color: #374151;
785
+ margin-right: auto;
786
+ }
787
+
788
+ .country-trigger .arrow {
789
+ font-size: 0.625rem;
790
+ color: #6b7280;
791
+ transition: transform 0.15s ease-in-out;
792
+ }
793
+
794
+ .country-trigger.is-open .arrow {
795
+ transform: rotate(180deg);
796
+ }
797
+
798
+ .country-dropdown {
799
+ position: absolute;
800
+ top: 100%;
801
+ left: 0;
802
+ z-index: 50;
803
+ background: white;
804
+ border: 1px solid #d1d5db;
805
+ border-radius: 0.5rem;
806
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
807
+ max-height: 400px;
808
+ min-width: 320px;
809
+ width: max-content;
810
+ overflow: hidden;
811
+ }
812
+
813
+ .search-container {
814
+ padding: 0.5rem;
815
+ border-bottom: 1px solid #e5e7eb;
816
+ }
817
+
818
+ .search-input {
819
+ width: 100%;
820
+ padding: 0.5rem 0.75rem;
821
+ border: 1px solid #d1d5db;
822
+ border-radius: 0.375rem;
823
+ outline: none;
824
+ font-size: 0.875rem;
825
+ }
826
+
827
+ .search-input:focus {
828
+ border-color: #3b82f6;
829
+ box-shadow: 0 0 0 1px #3b82f6;
830
+ }
831
+
832
+ .countries-list {
833
+ max-height: 340px;
834
+ overflow-y: auto;
835
+ overflow-x: hidden;
836
+ }
837
+
838
+ .country-option {
839
+ display: flex;
840
+ align-items: center;
841
+ width: 100%;
842
+ padding: 0.5rem 0.75rem;
843
+ border: none;
844
+ background: none;
845
+ text-align: left;
846
+ cursor: pointer;
847
+ transition: background-color 0.15s ease-in-out;
848
+ }
849
+
850
+ .country-option:hover {
851
+ background-color: #f9fafb;
852
+ }
853
+
854
+ .country-option.is-selected {
855
+ background-color: #eff6ff;
856
+ color: #1d4ed8;
857
+ }
858
+
859
+ .country-option .flag-img {
860
+ width: 20px;
861
+ height: 15px;
862
+ margin-right: 0.5rem;
863
+ border-radius: 2px;
864
+ object-fit: cover;
865
+ }
866
+
867
+ .country-option .flag-fallback {
868
+ margin-right: 0.5rem;
869
+ font-size: 1rem;
870
+ }
871
+
872
+ .country-option .name {
873
+ flex: 1;
874
+ font-size: 0.875rem;
875
+ }
876
+
877
+ .country-option .dial-code {
878
+ font-size: 0.75rem;
879
+ color: #6b7280;
880
+ font-weight: 500;
881
+ }
882
+
883
+ .phone-input {
884
+ flex: 1;
885
+ padding: 0.5rem 0.75rem;
886
+ border: 1px solid #d1d5db;
887
+ border-radius: 0 0.5rem 0.5rem 0;
888
+ outline: none;
889
+ font-size: 1rem;
890
+ }
891
+
892
+ .phone-input:focus {
893
+ border-color: #3b82f6;
894
+ box-shadow: 0 0 0 1px #3b82f6;
895
+ }
896
+
897
+ .phone-input:disabled {
898
+ background-color: #f9fafb;
899
+ color: #6b7280;
900
+ cursor: not-allowed;
901
+ }
902
+ </style>