js-kompo-utils 1.0.34 → 1.0.36

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.
@@ -56,6 +56,7 @@
56
56
  <input
57
57
  ref="phoneInput"
58
58
  class="phone-input"
59
+ :class="{ 'has-extension': showExtension }"
59
60
  :value="formattedValue"
60
61
  v-bind="$_attributes"
61
62
  v-on="$_events"
@@ -64,6 +65,44 @@
64
65
  @focus="onPhoneFocus"
65
66
  @blur="onPhoneBlur"
66
67
  />
68
+
69
+ <!-- Extension ("poste"), opt-in via ->withExtension(). Collapsed until asked for,
70
+ so the common case stays a single field. -->
71
+ <button
72
+ v-if="showExtension && !extensionOpen"
73
+ type="button"
74
+ class="phone-extension-toggle"
75
+ :title="extensionLabel"
76
+ :disabled="$_attributes.disabled"
77
+ @click="openExtension"
78
+ >
79
+ <span class="phone-extension-plus">+</span>
80
+ <span class="phone-extension-toggle-label">{{ extensionLabel }}</span>
81
+ </button>
82
+
83
+ <div v-else-if="showExtension" class="phone-extension-wrap">
84
+ <span class="phone-extension-prefix">{{ extensionLabel }}</span>
85
+ <input
86
+ ref="extensionInput"
87
+ class="phone-extension-input"
88
+ type="text"
89
+ inputmode="numeric"
90
+ autocomplete="off"
91
+ :value="extensionValue"
92
+ :placeholder="extensionPlaceholder"
93
+ :disabled="$_attributes.disabled"
94
+ :readonly="$_attributes.readonly"
95
+ @input="onExtensionInput"
96
+ @keydown.esc="closeExtension"
97
+ />
98
+ <button
99
+ type="button"
100
+ class="phone-extension-clear"
101
+ :title="extensionClearLabel"
102
+ :disabled="$_attributes.disabled"
103
+ @click="closeExtension"
104
+ >×</button>
105
+ </div>
67
106
  </div>
68
107
  </vl-form-field>
69
108
  </template>
@@ -96,6 +135,11 @@ export default {
96
135
 
97
136
  // Geolocation state
98
137
  isDetectingCountry: false,
138
+
139
+ // Optional extension ("poste"), submitted as `<name>_ext`. Starts open only
140
+ // when there is one to show, so an empty field stays a single box.
141
+ extensionValue: this.normalizeExtension(this.$_config('extensionValue')),
142
+ extensionOpen: this.normalizeExtension(this.$_config('extensionValue')) !== '',
99
143
  }
100
144
  },
101
145
 
@@ -114,6 +158,22 @@ export default {
114
158
  placeholder() {
115
159
  return this.$_config('placeholder') || 'Phone number'
116
160
  },
161
+
162
+ showExtension() {
163
+ return !!this.$_config('withExtension')
164
+ },
165
+
166
+ extensionLabel() {
167
+ return this.$_config('extensionLabel') || 'ext'
168
+ },
169
+
170
+ extensionPlaceholder() {
171
+ return this.$_config('extensionPlaceholder') || '000'
172
+ },
173
+
174
+ extensionClearLabel() {
175
+ return this.$_config('extensionClearLabel') || 'Remove'
176
+ },
117
177
 
118
178
  inputGroupClass() {
119
179
  const baseClass = 'vlInputGroup rounded-lg flex items-stretch'
@@ -121,6 +181,14 @@ export default {
121
181
  return `${baseClass} ${errorClass}`
122
182
  },
123
183
 
184
+ // An explicit country() is a hard setting. Otherwise the IP lookup runs when it is turned
185
+ // on — its result wins over defaultCountry — or when there is no default to start from.
186
+ shouldDetectByIp() {
187
+ if (this.$_config('country')) return false
188
+
189
+ return !!this.$_config('detectCountryByIp') || !this.selectedCountryCode
190
+ },
191
+
124
192
  // Current country info
125
193
  currentCountry() {
126
194
  const country = this.countries.find(c => c.code === this.selectedCountryCode)
@@ -212,6 +280,13 @@ export default {
212
280
  }
213
281
  },
214
282
 
283
+ // Kompo swaps `component` in place on a refresh without re-running data(), which is why
284
+ // the number re-syncs above. The extension has to follow or it keeps the stale prefill.
285
+ 'component.config.extensionValue'(newValue) {
286
+ this.extensionValue = this.normalizeExtension(newValue)
287
+ this.extensionOpen = this.extensionOpen || this.extensionValue !== ''
288
+ },
289
+
215
290
  selectedCountryCode(newCode, oldCode) {
216
291
  if (newCode !== oldCode && this.phoneValue) {
217
292
  this.reformatWithCountry(newCode)
@@ -226,10 +301,10 @@ export default {
226
301
  // Set initial value
227
302
  // this.component.value = this.phoneValue
228
303
 
229
- // Auto-detect country if no explicit country is set and no phone value exists
304
+ // A stored number always decides its own country; an empty field may ask the IP.
230
305
  if (this.phoneValue) {
231
306
  this.autoDetectCountryFromPhone(this.phoneValue)
232
- } else if (!this.selectedCountryCode) {
307
+ } else if (this.shouldDetectByIp) {
233
308
  this.detectCountryByLocation()
234
309
  }
235
310
  },
@@ -239,6 +314,42 @@ export default {
239
314
  },
240
315
 
241
316
  methods: {
317
+ // The extension is not part of the number, so it rides alongside it as its own
318
+ // key instead of being folded into the value the way E.164 cannot carry it.
319
+ $_fill(jsonFormData) {
320
+ jsonFormData[this.$_name] = (this.$_value === null || this.$_value === undefined) ? '' : this.$_value
321
+
322
+ if (this.showExtension) {
323
+ jsonFormData[this.$_name + '_ext'] = this.extensionValue || ''
324
+ }
325
+ },
326
+
327
+ // `|| ''` would swallow a "0" extension, and the column is a string.
328
+ normalizeExtension(value) {
329
+ return (value === null || value === undefined) ? '' : String(value)
330
+ },
331
+
332
+ onExtensionInput(event) {
333
+ this.extensionValue = event.target.value.replace(/[^0-9#*]/g, '')
334
+
335
+ if (event.target.value !== this.extensionValue) {
336
+ event.target.value = this.extensionValue
337
+ }
338
+ },
339
+
340
+ openExtension() {
341
+ this.extensionOpen = true
342
+
343
+ this.$nextTick(() => this.$refs.extensionInput?.focus())
344
+ },
345
+
346
+ // Collapsing clears the value: the empty companion still posts, so the stored
347
+ // extension is removed rather than silently kept.
348
+ closeExtension() {
349
+ this.extensionValue = ''
350
+ this.extensionOpen = false
351
+ },
352
+
242
353
  // Country selection methods
243
354
  toggleDropdown() {
244
355
  this.isDropdownOpen = !this.isDropdownOpen
@@ -569,40 +680,25 @@ export default {
569
680
  }
570
681
  },
571
682
 
683
+ // Free, keyless, https + CORS. http:// providers are mixed content on an https page and
684
+ // never resolve, and a provider answering without a code (ipapi.co rate-limits often) has
685
+ // to fall through to the next one instead of ending the chain.
572
686
  async getCountryFromIP() {
573
- // Try ipapi.co first (free, reliable)
574
- try {
575
- const response = await fetch('https://ipapi.co/json/', {
576
- timeout: 5000
577
- })
578
- const data = await response.json()
579
- return data.country_code
580
- } catch (error) {
581
- console.warn('ipapi.co failed:', error)
582
- }
583
-
584
- // Fallback to ip-api.com
585
- try {
586
- const response = await fetch('http://ip-api.com/json/', {
587
- timeout: 5000
588
- })
589
- const data = await response.json()
590
- return data.countryCode
591
- } catch (error) {
592
- console.warn('ip-api.com failed:', error)
593
- }
594
-
595
- // Fallback to ipinfo.io
596
- try {
597
- const response = await fetch('https://ipinfo.io/json', {
598
- timeout: 5000
599
- })
600
- const data = await response.json()
601
- return data.country
602
- } catch (error) {
603
- console.warn('ipinfo.io failed:', error)
687
+ const providers = [
688
+ ['https://get.geojs.io/v1/ip/country.json', data => data.country],
689
+ ['https://ipwho.is/', data => data.success && data.country_code],
690
+ ['https://ipapi.co/json/', data => data.country_code],
691
+ ]
692
+
693
+ for (const [url, readCountry] of providers) {
694
+ try {
695
+ const code = readCountry(await (await fetch(url)).json())
696
+ if (code) return code
697
+ } catch (error) {
698
+ console.warn(`${url} failed:`, error)
699
+ }
604
700
  }
605
-
701
+
606
702
  return null
607
703
  },
608
704
 
@@ -922,4 +1018,100 @@ export default {
922
1018
  color: #6b7280;
923
1019
  cursor: not-allowed;
924
1020
  }
1021
+
1022
+ .phone-input.has-extension {
1023
+ border-radius: 0;
1024
+ }
1025
+
1026
+ /* Collapsed state: a quiet pill closing the group's right edge. */
1027
+ .phone-extension-toggle {
1028
+ display: flex;
1029
+ align-items: center;
1030
+ gap: 0.2rem;
1031
+ flex: none;
1032
+ padding: 0 0.7rem;
1033
+ margin-left: -1px;
1034
+ border: 1px solid #d1d5db;
1035
+ border-radius: 0 0.5rem 0.5rem 0;
1036
+ background-color: transparent;
1037
+ color: #6b7280;
1038
+ font-size: 0.8125rem;
1039
+ line-height: 1;
1040
+ white-space: nowrap;
1041
+ cursor: pointer;
1042
+ transition: color 0.15s ease, background-color 0.15s ease;
1043
+ }
1044
+
1045
+ .phone-extension-toggle:hover:not(:disabled) {
1046
+ color: #3b82f6;
1047
+ background-color: #f3f4f6;
1048
+ }
1049
+
1050
+ .phone-extension-toggle:disabled {
1051
+ cursor: not-allowed;
1052
+ opacity: 0.6;
1053
+ }
1054
+
1055
+ .phone-extension-plus {
1056
+ font-size: 0.95rem;
1057
+ line-height: 1;
1058
+ }
1059
+
1060
+ /* Expanded state: label + field + dismiss, sharing one bordered cell. */
1061
+ .phone-extension-wrap {
1062
+ display: flex;
1063
+ align-items: center;
1064
+ flex: none;
1065
+ margin-left: -1px;
1066
+ padding-left: 0.6rem;
1067
+ border: 1px solid #d1d5db;
1068
+ border-radius: 0 0.5rem 0.5rem 0;
1069
+ }
1070
+
1071
+ .phone-extension-wrap:focus-within {
1072
+ border-color: #3b82f6;
1073
+ box-shadow: 0 0 0 1px #3b82f6;
1074
+ z-index: 1;
1075
+ }
1076
+
1077
+ .phone-extension-prefix {
1078
+ color: #9ca3af;
1079
+ font-size: 0.8125rem;
1080
+ white-space: nowrap;
1081
+ pointer-events: none;
1082
+ }
1083
+
1084
+ .phone-extension-input {
1085
+ width: 3.75rem;
1086
+ min-width: 0;
1087
+ padding: 0.5rem 0.25rem;
1088
+ border: none;
1089
+ outline: none;
1090
+ background-color: transparent;
1091
+ font-size: 1rem;
1092
+ }
1093
+
1094
+ .phone-extension-input:disabled {
1095
+ color: #6b7280;
1096
+ cursor: not-allowed;
1097
+ }
1098
+
1099
+ .phone-extension-clear {
1100
+ padding: 0 0.55rem 0 0.15rem;
1101
+ border: none;
1102
+ background-color: transparent;
1103
+ color: #9ca3af;
1104
+ font-size: 1.05rem;
1105
+ line-height: 1;
1106
+ cursor: pointer;
1107
+ }
1108
+
1109
+ .phone-extension-clear:hover:not(:disabled) {
1110
+ color: #ef4444;
1111
+ }
1112
+
1113
+ .phone-extension-clear:disabled {
1114
+ cursor: not-allowed;
1115
+ opacity: 0.6;
1116
+ }
925
1117
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-kompo-utils",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "description": "",
5
5
  "main": "app.js",
6
6
  "scripts": {