@alikhalilll/a-tel-input 1.0.2 → 1.1.1
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.
- package/.media/README.md +3 -0
- package/.media/hero.png +0 -0
- package/README.md +597 -72
- package/dist/_chunks/types.d.ts +661 -0
- package/dist/_chunks/types.js +52 -0
- package/dist/_chunks/types.js.map +1 -0
- package/dist/_chunks/usePhoneValidation.js +539 -0
- package/dist/_chunks/usePhoneValidation.js.map +1 -0
- package/dist/index.cjs +471 -695
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -587
- package/dist/index.d.ts +122 -587
- package/dist/index.js +454 -658
- package/dist/index.js.map +1 -1
- package/dist/styles.css +20 -5
- package/dist/vee-validate/index.cjs +113 -0
- package/dist/vee-validate/index.cjs.map +1 -0
- package/dist/vee-validate/index.d.cts +86 -0
- package/dist/vee-validate/index.d.ts +86 -0
- package/dist/vee-validate/index.js +112 -0
- package/dist/vee-validate/index.js.map +1 -0
- package/dist/zod/index.cjs +211 -0
- package/dist/zod/index.cjs.map +1 -0
- package/dist/zod/index.d.cts +65 -0
- package/dist/zod/index.d.ts +65 -0
- package/dist/zod/index.js +208 -0
- package/dist/zod/index.js.map +1 -0
- package/package.json +34 -3
- package/src/components/ACountrySelect.vue +17 -3
- package/src/components/ATelInput.vue +206 -66
- package/src/composables/useCountryDetection.ts +28 -11
- package/src/composables/useCountryMatching.ts +160 -20
- package/src/composables/useCountrySelection.ts +71 -0
- package/src/composables/usePhoneValidation.ts +81 -18
- package/src/composables/useSyncedModel.ts +80 -0
- package/src/composables/useTelInputValidation.ts +50 -11
- package/src/index.ts +2 -0
- package/src/types.ts +80 -0
- package/src/vee-validate/index.ts +2 -0
- package/src/vee-validate/useTelField.ts +202 -0
- package/src/zod/index.ts +259 -0
- package/web-types.json +44 -1
|
@@ -586,7 +586,9 @@ defineExpose({
|
|
|
586
586
|
|
|
587
587
|
.a-country-select__search {
|
|
588
588
|
border-bottom: 1px solid hsl(var(--ak-ui-border) / 0.7);
|
|
589
|
-
padding
|
|
589
|
+
/* Drop bottom padding — the list owns the gap below the search border so the
|
|
590
|
+
sticky group header can overlap it and sit flush against the search bar. */
|
|
591
|
+
padding: 0.375rem 0.375rem 0;
|
|
590
592
|
}
|
|
591
593
|
.a-country-select__search-box {
|
|
592
594
|
position: relative;
|
|
@@ -654,6 +656,10 @@ defineExpose({
|
|
|
654
656
|
.a-country-select__list {
|
|
655
657
|
flex: 1;
|
|
656
658
|
overflow-y: auto;
|
|
659
|
+
/* Top padding lives inside the scroll container so the first sticky group header
|
|
660
|
+
can overlap it (via negative `top`) and sit flush against the search border
|
|
661
|
+
with zero visible gap. */
|
|
662
|
+
padding-top: 0.375rem;
|
|
657
663
|
/* Themed scrollbar — Firefox + WebKit/Blink. Resolves the browser-default
|
|
658
664
|
light-grey scrollbar that didn't match the popover surface in dark mode. */
|
|
659
665
|
scrollbar-width: thin;
|
|
@@ -683,16 +689,24 @@ defineExpose({
|
|
|
683
689
|
|
|
684
690
|
.a-country-select__group-header {
|
|
685
691
|
position: sticky;
|
|
686
|
-
top
|
|
692
|
+
/* Negative top equal to the list's `padding-top` makes the sticky header
|
|
693
|
+
overlap that padding band so when the user scrolls it sits flush against
|
|
694
|
+
the search bar's bottom border — no visible gap. */
|
|
695
|
+
top: -0.375rem;
|
|
687
696
|
z-index: 10;
|
|
688
697
|
background: hsl(var(--ak-ui-popover));
|
|
689
698
|
color: hsl(var(--ak-ui-muted-foreground));
|
|
690
|
-
padding
|
|
699
|
+
/* Extra top padding compensates for the negative `top` offset so the visible
|
|
700
|
+
label keeps its usual breathing room. */
|
|
701
|
+
padding: 0.5rem 0.75rem 0.375rem;
|
|
691
702
|
font-size: 10px;
|
|
692
703
|
font-weight: 500;
|
|
693
704
|
letter-spacing: 0.05em;
|
|
694
705
|
text-transform: uppercase;
|
|
695
706
|
margin: 0;
|
|
707
|
+
/* Hairline under the header that only shows once it's stuck — helps it read
|
|
708
|
+
as a distinct band from the search border above. */
|
|
709
|
+
box-shadow: 0 1px 0 0 hsl(var(--ak-ui-border) / 0.5);
|
|
696
710
|
}
|
|
697
711
|
|
|
698
712
|
.a-country-select__group-list {
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed, onMounted, ref, useId, watch } from 'vue';
|
|
3
|
+
import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
3
4
|
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
5
|
import { usePhoneValidation } from '../composables/usePhoneValidation';
|
|
5
6
|
import { detectCountry, type DetectCountryOptions } from '../composables/useCountryDetection';
|
|
6
7
|
import { useCountryMatching } from '../composables/useCountryMatching';
|
|
7
8
|
import { useTypingPhase } from '../composables/useTypingPhase';
|
|
8
9
|
import { useTelInputValidation } from '../composables/useTelInputValidation';
|
|
10
|
+
import { useCountrySelection } from '../composables/useCountrySelection';
|
|
11
|
+
import { useSyncedModel } from '../composables/useSyncedModel';
|
|
9
12
|
import { DEFAULT_SIZE } from '@alikhalilll/a-ui-base';
|
|
10
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
resolveMessages,
|
|
15
|
+
type ATelInputProps,
|
|
16
|
+
type ATelInputSlots,
|
|
17
|
+
type ATelInputEmits,
|
|
18
|
+
} from '../types';
|
|
11
19
|
import { normalizeDigits } from '../utils/digits';
|
|
12
20
|
import ACountrySelect from './ACountrySelect.vue';
|
|
13
21
|
import { CheckCircleIcon, AlertCircleIcon, SpinnerIcon } from '../icons';
|
|
@@ -21,8 +29,11 @@ const props = withDefaults(defineProps<ATelInputProps>(), {
|
|
|
21
29
|
detectFromInput: true,
|
|
22
30
|
detectDebounceMs: 800,
|
|
23
31
|
showValidationIcon: false,
|
|
32
|
+
validateOn: 'change',
|
|
24
33
|
});
|
|
25
34
|
|
|
35
|
+
const emit = defineEmits<ATelInputEmits>();
|
|
36
|
+
|
|
26
37
|
defineSlots<ATelInputSlots>();
|
|
27
38
|
|
|
28
39
|
const phone = defineModel<string>('phone', { default: '' });
|
|
@@ -32,9 +43,33 @@ const phone = defineModel<string>('phone', { default: '' });
|
|
|
32
43
|
* NANP (`+1` covers 25+ countries) — the picker still needs an exact country. */
|
|
33
44
|
const country = defineModel<number | null>('country', { default: null });
|
|
34
45
|
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Default v-model — the canonical **E.164** string (e.g. `'+201066105963'`).
|
|
48
|
+
*
|
|
49
|
+
* Single-string contract for VeeValidate's `<Field v-slot="{ field }">` pattern
|
|
50
|
+
* (`v-bind="field"`), native `<form>` submission, or any `v-model="phoneE164"`
|
|
51
|
+
* consumer. Bind it with:
|
|
52
|
+
*
|
|
53
|
+
* <ATelInput v-model="phoneE164" />
|
|
54
|
+
*
|
|
55
|
+
* <VeeField v-slot="{ field, errors }" name="phone">
|
|
56
|
+
* <ATelInput v-bind="field" :error="errors[0]" />
|
|
57
|
+
* </VeeField>
|
|
58
|
+
*
|
|
59
|
+
* When set externally, the value is parsed via libphonenumber-js → the country
|
|
60
|
+
* picker and the digits-only `phone` model are derived from it. When the user
|
|
61
|
+
* types or picks a country, the composed E.164 is written back out. Stays in
|
|
62
|
+
* sync with `v-model:phone` / `v-model:country` — you can use either contract.
|
|
63
|
+
*/
|
|
64
|
+
const modelValue = defineModel<string>({ default: '' });
|
|
65
|
+
|
|
66
|
+
/** The picker selection state machine — `iso2` is the internal source of truth, `source`
|
|
67
|
+
* records where the current selection came from, `detectionLocked` answers "should
|
|
68
|
+
* typed-input detection re-route the picker on the next burst?". Single mutator: `set`.
|
|
69
|
+
* Replaces the historical flag soup (`userPickedCountry` / `autoSettingCountry` /
|
|
70
|
+
* `inputDetectionApplied`). */
|
|
71
|
+
const selection = useCountrySelection();
|
|
72
|
+
const selectedIso2 = selection.iso2;
|
|
38
73
|
|
|
39
74
|
const { getCountries, validate, getRequiredInfo, getCountryByValue, getCountriesByDial } =
|
|
40
75
|
usePhoneValidation();
|
|
@@ -47,9 +82,6 @@ const { resolveCountryIdentifier, dialNumberFor, matchLeadingDialCode } = useCou
|
|
|
47
82
|
|
|
48
83
|
void getCountries();
|
|
49
84
|
|
|
50
|
-
const userPickedCountry = ref(false);
|
|
51
|
-
const autoSettingCountry = ref(false);
|
|
52
|
-
|
|
53
85
|
/** Silently resolved via IP/timezone/locale when `detectFromInput` is on — used as a hint
|
|
54
86
|
* so local-format numbers (e.g. Egyptian `01066105963`) can be parsed without a `+` prefix.
|
|
55
87
|
* Seeded from `defaultCountry` so it has a usable value before async detection resolves. */
|
|
@@ -67,42 +99,65 @@ function tryMatchPhone(digits: string) {
|
|
|
67
99
|
|
|
68
100
|
/* ---------------------------------------------------------------
|
|
69
101
|
* Typing-phase state machine — owns `isDetecting`, `hasFinishedTyping`,
|
|
70
|
-
* `detectionAttempted` and the debounce timer.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
102
|
+
* `detectionAttempted` and the debounce timer. `onSettle` decides — once
|
|
103
|
+
* the user pauses — whether to re-route the picker based on what they
|
|
104
|
+
* typed. The decision tree below is the entire detection policy:
|
|
105
|
+
*
|
|
106
|
+
* 1. `detectFromInput` opt-out → bail.
|
|
107
|
+
* 2. `selection.detectionLocked` (user picked, or a previous input-driven
|
|
108
|
+
* match was already applied) → bail.
|
|
109
|
+
* 3. Empty input → bail.
|
|
110
|
+
* 4. A country is already selected from a *hint* source (`'default'` /
|
|
111
|
+
* `'env'` / `'external'`) AND the user did NOT type an explicit `+`
|
|
112
|
+
* prefix → bail. Local-format typing must not get re-routed by tier-3
|
|
113
|
+
* ambiguous prefix lookups (e.g. `055…` matching Brazil's `+55`).
|
|
114
|
+
* 5. Run the matcher. If it lands on the same country we already have AND
|
|
115
|
+
* the same national number, only lock detection. Otherwise apply the
|
|
116
|
+
* new country + stripped national number and lock.
|
|
74
117
|
* ------------------------------------------------------------- */
|
|
118
|
+
/** User explicitly picked a country from the picker — locks the selection so subsequent
|
|
119
|
+
* typed-input detection cannot churn the picker. */
|
|
120
|
+
function onPickerPick(iso2: string) {
|
|
121
|
+
selection.set(iso2, 'picker');
|
|
122
|
+
}
|
|
123
|
+
|
|
75
124
|
const typing = useTypingPhase({
|
|
76
125
|
debounceMs: computed(() => Math.max(0, props.detectDebounceMs)),
|
|
77
126
|
onSettle: () => {
|
|
78
127
|
if (!props.detectFromInput) return;
|
|
79
|
-
if (
|
|
128
|
+
if (selection.detectionLocked.value) return;
|
|
80
129
|
const current = phone.value;
|
|
81
130
|
if (!current) return;
|
|
82
131
|
|
|
132
|
+
const typedInternational = (displayValue.value ?? '').trimStart().startsWith('+');
|
|
133
|
+
if (selectedIso2.value && !typedInternational) return;
|
|
134
|
+
|
|
83
135
|
typing.markDetectionAttempt();
|
|
84
136
|
|
|
85
137
|
const match = tryMatchPhone(current);
|
|
86
138
|
if (!match) return;
|
|
87
|
-
|
|
88
|
-
selectedIso2.value
|
|
139
|
+
|
|
140
|
+
if (match.country.value === selectedIso2.value && match.nationalNumber === phone.value) {
|
|
141
|
+
// No-op except for the lock — the matcher confirmed our current state.
|
|
142
|
+
selection.source.value = 'input';
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
selection.set(match.country.value, 'input');
|
|
89
146
|
phone.value = match.nationalNumber;
|
|
90
147
|
},
|
|
91
148
|
});
|
|
92
149
|
const { isDetecting, hasFinishedTyping, detectionAttempted } = typing;
|
|
93
150
|
|
|
94
151
|
onMounted(async () => {
|
|
95
|
-
if (selectedIso2.value) return; // v-model has an initial value
|
|
152
|
+
if (selectedIso2.value) return; // v-model:country or v-model has an initial value.
|
|
96
153
|
|
|
97
|
-
// Explicit `defaultCountry` is
|
|
98
|
-
//
|
|
99
|
-
// either an ISO2 code (`'EG'`) or a dial-digit string (`'20'`, `'+20'`).
|
|
154
|
+
// Explicit `defaultCountry` is the initial picker value (and a parsing hint). Accepts
|
|
155
|
+
// an ISO2 code (`'EG'`) or a dial-digit string (`'20'`, `'+20'`).
|
|
100
156
|
if (props.defaultCountry) {
|
|
101
157
|
const seed = resolveCountryIdentifier(props.defaultCountry);
|
|
102
158
|
if (seed) {
|
|
103
159
|
inferredCountry.value = seed;
|
|
104
|
-
|
|
105
|
-
selectedIso2.value = seed;
|
|
160
|
+
selection.set(seed, 'default');
|
|
106
161
|
return;
|
|
107
162
|
}
|
|
108
163
|
}
|
|
@@ -129,59 +184,50 @@ onMounted(async () => {
|
|
|
129
184
|
|
|
130
185
|
if (props.detectFromInput) {
|
|
131
186
|
inferredCountry.value = iso2;
|
|
132
|
-
// If the user
|
|
133
|
-
//
|
|
134
|
-
if (phone.value && !
|
|
187
|
+
// If the user typed something while detection was resolving, re-attempt the match
|
|
188
|
+
// now that we have a hint country for libphonenumber's national-format parse.
|
|
189
|
+
if (phone.value && !selection.detectionLocked.value && !selectedIso2.value) {
|
|
135
190
|
const match = tryMatchPhone(phone.value);
|
|
136
191
|
if (match) {
|
|
137
|
-
|
|
138
|
-
selectedIso2.value = match.country.value;
|
|
192
|
+
selection.set(match.country.value, 'input');
|
|
139
193
|
phone.value = match.nationalNumber;
|
|
140
194
|
}
|
|
141
195
|
}
|
|
142
196
|
return;
|
|
143
197
|
}
|
|
144
198
|
if (!selectedIso2.value && iso2) {
|
|
145
|
-
|
|
146
|
-
selectedIso2.value = iso2;
|
|
199
|
+
selection.set(iso2, 'env');
|
|
147
200
|
}
|
|
148
201
|
});
|
|
149
202
|
|
|
150
|
-
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
203
|
+
/* ---------------------------------------------------------------
|
|
204
|
+
* `country` (dial-number model) ↔ `selectedIso2` two-way sync.
|
|
205
|
+
*
|
|
206
|
+
* Replaces the historical pair of manual watchers + `autoSettingCountry` flag.
|
|
207
|
+
* `useSyncedModel` handles the echo-loop guard internally: writes that originate
|
|
208
|
+
* from `compose()` are stamped via `lastEmitted` so the corresponding `apply`
|
|
209
|
+
* call (which fires when Vue's defineModel cascades the write back through the
|
|
210
|
+
* reactivity graph) recognises and skips the echo.
|
|
211
|
+
*
|
|
212
|
+
* When the *caller* writes `v-model:country` from outside (a fresh dial number
|
|
213
|
+
* not derived from us), `apply` runs with `source: 'external'`, leaving
|
|
214
|
+
* `detectionLocked` false — typed-international input is still allowed to
|
|
215
|
+
* override an externally-seeded selection.
|
|
216
|
+
* ------------------------------------------------------------- */
|
|
217
|
+
useSyncedModel<number | null>({
|
|
218
|
+
model: country,
|
|
219
|
+
triggers: [selectedIso2],
|
|
220
|
+
compose: () => (selectedIso2.value ? dialNumberFor(selectedIso2.value) : null),
|
|
221
|
+
apply: (next) => {
|
|
156
222
|
if (next == null) {
|
|
157
|
-
|
|
223
|
+
selection.clear();
|
|
158
224
|
return;
|
|
159
225
|
}
|
|
160
226
|
if (dialNumberFor(selectedIso2.value) === next) return; // already in sync
|
|
161
227
|
const iso2 = resolveCountryIdentifier(String(next));
|
|
162
|
-
if (iso2)
|
|
163
|
-
},
|
|
164
|
-
{ immediate: true }
|
|
165
|
-
);
|
|
166
|
-
|
|
167
|
-
/** Internal → external: keep `country` (dial number) in lockstep with `selectedIso2`, and
|
|
168
|
-
* flag "user manually picked from picker" when the change isn't one we initiated.
|
|
169
|
-
* `flush: 'sync'` so the `autoSettingCountry` guard is reliable. */
|
|
170
|
-
watch(
|
|
171
|
-
selectedIso2,
|
|
172
|
-
(iso2, prev) => {
|
|
173
|
-
const wasAutoSet = autoSettingCountry.value;
|
|
174
|
-
autoSettingCountry.value = false;
|
|
175
|
-
|
|
176
|
-
const nextDial = dialNumberFor(iso2);
|
|
177
|
-
if (country.value !== nextDial) country.value = nextDial;
|
|
178
|
-
|
|
179
|
-
if (!wasAutoSet && props.detectFromInput && iso2 && prev !== iso2) {
|
|
180
|
-
userPickedCountry.value = true;
|
|
181
|
-
}
|
|
228
|
+
if (iso2) selection.set(iso2, 'external');
|
|
182
229
|
},
|
|
183
|
-
|
|
184
|
-
);
|
|
230
|
+
});
|
|
185
231
|
|
|
186
232
|
/** The string shown in the `<input>`. Deliberately decoupled from `phone` (the digits-only
|
|
187
233
|
* model) so the visible field is NOT rewritten mid-edit — non-digits / alternative numerals
|
|
@@ -193,6 +239,45 @@ const displayValue = ref<string>(String(phone.value ?? ''));
|
|
|
193
239
|
* watcher to leave `displayValue` alone (the user is still editing it). */
|
|
194
240
|
let phoneEditedByInput = false;
|
|
195
241
|
|
|
242
|
+
/* ---------------------------------------------------------------
|
|
243
|
+
* Default v-model (E.164 string) ↔ `phone` + `selectedIso2` two-way sync.
|
|
244
|
+
*
|
|
245
|
+
* Single-string contract for VeeValidate's `<Field v-slot="{ field }">` pattern
|
|
246
|
+
* (`v-bind="field"`), native `<form>` submission, or any `v-model="phoneE164"`
|
|
247
|
+
* consumer. Implemented with the same `useSyncedModel` helper used for `country`
|
|
248
|
+
* — one shared echo-loop guard, no hand-rolled flag pair.
|
|
249
|
+
*
|
|
250
|
+
* Crucially, `apply` does NOT write to `displayValue`. The existing `watch(phone)`
|
|
251
|
+
* handler already updates `displayValue` when the change isn't user-driven (i.e.
|
|
252
|
+
* `phoneEditedByInput === false`); and when the user IS mid-typing, it leaves
|
|
253
|
+
* `displayValue` alone. Writing the parsed national number here would clobber
|
|
254
|
+
* what the user just typed — that was the original "typing rewrites to '96610'"
|
|
255
|
+
* bug.
|
|
256
|
+
* ------------------------------------------------------------- */
|
|
257
|
+
useSyncedModel<string>({
|
|
258
|
+
model: modelValue,
|
|
259
|
+
triggers: [phone, selectedIso2],
|
|
260
|
+
compose: () => {
|
|
261
|
+
if (!selectedIso2.value || !phone.value) return '';
|
|
262
|
+
return validate({ country: { iso2: selectedIso2.value }, phone: phone.value }).full_phone ?? '';
|
|
263
|
+
},
|
|
264
|
+
apply: (next) => {
|
|
265
|
+
const trimmed = String(next ?? '').trim();
|
|
266
|
+
if (!trimmed) {
|
|
267
|
+
if (phone.value !== '') phone.value = '';
|
|
268
|
+
if (selectedIso2.value !== '') selection.clear();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const e164 = trimmed.startsWith('+') ? trimmed : `+${trimmed.replace(/^\+/, '')}`;
|
|
272
|
+
const parsed = parsePhoneNumberFromString(e164);
|
|
273
|
+
if (!parsed || !parsed.country) return;
|
|
274
|
+
if (selectedIso2.value !== parsed.country) {
|
|
275
|
+
selection.set(parsed.country, 'external');
|
|
276
|
+
}
|
|
277
|
+
if (phone.value !== parsed.nationalNumber) phone.value = parsed.nationalNumber;
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
|
|
196
281
|
function commitPhone(value: string) {
|
|
197
282
|
phoneEditedByInput = true;
|
|
198
283
|
phone.value = value;
|
|
@@ -208,13 +293,10 @@ function handlePhoneInput(e: Event) {
|
|
|
208
293
|
|
|
209
294
|
if (!cleaned) {
|
|
210
295
|
// Always reset on clear — even after a manual pick. Instant (not debounced) so the
|
|
211
|
-
// picker + spinner hide the moment the input goes empty.
|
|
296
|
+
// picker + spinner hide the moment the input goes empty. `selection.clear()` drops
|
|
297
|
+
// both `iso2` and `source` back to the empty/no-country state, re-arming detection.
|
|
212
298
|
typing.reset();
|
|
213
|
-
if (props.detectFromInput)
|
|
214
|
-
autoSettingCountry.value = true;
|
|
215
|
-
selectedIso2.value = '';
|
|
216
|
-
userPickedCountry.value = false;
|
|
217
|
-
}
|
|
299
|
+
if (props.detectFromInput) selection.clear();
|
|
218
300
|
commitPhone('');
|
|
219
301
|
return;
|
|
220
302
|
}
|
|
@@ -265,8 +347,12 @@ const dirAttr = computed<'ltr' | 'rtl' | undefined>(() =>
|
|
|
265
347
|
/* ---------------------------------------------------------------
|
|
266
348
|
* Validation facade — wraps the raw `usePhoneValidation` calls and
|
|
267
349
|
* produces the view-layer surface (visible state gated by the typing
|
|
268
|
-
* pause, localised error message, conditional show flags,
|
|
350
|
+
* pause / blur, localised error message, conditional show flags,
|
|
351
|
+
* external `error` override, etc.).
|
|
269
352
|
* ------------------------------------------------------------- */
|
|
353
|
+
/** Set to `true` the first time the input is blurred. Drives `validateOn: 'blur'`. */
|
|
354
|
+
const hasBlurred = ref(false);
|
|
355
|
+
|
|
270
356
|
const {
|
|
271
357
|
validation,
|
|
272
358
|
required,
|
|
@@ -278,11 +364,13 @@ const {
|
|
|
278
364
|
selectedDialCode,
|
|
279
365
|
} = useTelInputValidation(
|
|
280
366
|
{ validate, getRequiredInfo, getCountryByValue },
|
|
281
|
-
{ phone, selectedIso2, hasFinishedTyping, messages },
|
|
367
|
+
{ phone, selectedIso2, hasFinishedTyping, hasBlurred, messages },
|
|
282
368
|
{
|
|
283
369
|
locale: () => props.locale,
|
|
284
370
|
showValidation: () => props.showValidation,
|
|
285
371
|
errorMessages: () => props.errorMessages,
|
|
372
|
+
validateOn: () => props.validateOn,
|
|
373
|
+
externalError: () => props.error,
|
|
286
374
|
}
|
|
287
375
|
);
|
|
288
376
|
|
|
@@ -298,6 +386,31 @@ const effectivePlaceholder = computed(
|
|
|
298
386
|
const helperId = useId();
|
|
299
387
|
const describedBy = computed(() => (showError.value || showHint.value ? helperId : undefined));
|
|
300
388
|
|
|
389
|
+
/* ---------------------------------------------------------------
|
|
390
|
+
* Imperative API — form libraries (VeeValidate, etc.) need to focus
|
|
391
|
+
* the offending field after a failed submit. `inputRef` is also used
|
|
392
|
+
* by `handleBlur` / `handleFocus` to forward the native event.
|
|
393
|
+
* ------------------------------------------------------------- */
|
|
394
|
+
const inputRef = ref<HTMLInputElement | null>(null);
|
|
395
|
+
|
|
396
|
+
function handleBlur(e: FocusEvent) {
|
|
397
|
+
hasBlurred.value = true;
|
|
398
|
+
emit('blur', e);
|
|
399
|
+
}
|
|
400
|
+
function handleFocus(e: FocusEvent) {
|
|
401
|
+
emit('focus', e);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function focus(options?: FocusOptions) {
|
|
405
|
+
inputRef.value?.focus(options);
|
|
406
|
+
}
|
|
407
|
+
function blur() {
|
|
408
|
+
inputRef.value?.blur();
|
|
409
|
+
}
|
|
410
|
+
function select() {
|
|
411
|
+
inputRef.value?.select();
|
|
412
|
+
}
|
|
413
|
+
|
|
301
414
|
defineExpose({
|
|
302
415
|
validation,
|
|
303
416
|
required,
|
|
@@ -307,6 +420,9 @@ defineExpose({
|
|
|
307
420
|
isDetecting,
|
|
308
421
|
hasFinishedTyping,
|
|
309
422
|
detectionAttempted,
|
|
423
|
+
focus,
|
|
424
|
+
blur,
|
|
425
|
+
select,
|
|
310
426
|
});
|
|
311
427
|
</script>
|
|
312
428
|
|
|
@@ -339,23 +455,45 @@ defineExpose({
|
|
|
339
455
|
</span>
|
|
340
456
|
|
|
341
457
|
<input
|
|
458
|
+
ref="inputRef"
|
|
342
459
|
:value="displayValue"
|
|
343
460
|
type="tel"
|
|
344
461
|
inputmode="numeric"
|
|
345
462
|
autocomplete="tel"
|
|
346
463
|
dir="ltr"
|
|
347
464
|
data-slot="tel-input-field"
|
|
465
|
+
:name="props.name"
|
|
348
466
|
:disabled="props.disabled || props.loading"
|
|
349
467
|
:placeholder="effectivePlaceholder"
|
|
350
468
|
:aria-label="messages.phoneInputLabel"
|
|
351
469
|
:aria-invalid="visibleValidationState === 'error' || undefined"
|
|
352
470
|
:aria-describedby="describedBy"
|
|
471
|
+
:aria-errormessage="visibleValidationState === 'error' ? helperId : undefined"
|
|
472
|
+
:aria-busy="props.validating || undefined"
|
|
353
473
|
:class="cn('a-tel-input__input', props.inputClass)"
|
|
354
474
|
:data-has-dial="selectedDialCode ? '' : undefined"
|
|
355
475
|
@input="handlePhoneInput"
|
|
356
476
|
@change="handlePhoneChange"
|
|
477
|
+
@blur="handleBlur"
|
|
478
|
+
@focus="handleFocus"
|
|
357
479
|
/>
|
|
358
480
|
|
|
481
|
+
<!-- Async-validation spinner (e.g. server-side "phone exists?" check). Independent
|
|
482
|
+
of `isDetecting` (which is for country detection) so both can be shown without
|
|
483
|
+
interfering. Lives next to the input and never disables it. -->
|
|
484
|
+
<Transition name="a-tell-detect">
|
|
485
|
+
<div
|
|
486
|
+
v-if="props.validating"
|
|
487
|
+
class="a-tel-input__validating"
|
|
488
|
+
data-slot="tel-input-validating"
|
|
489
|
+
aria-hidden="true"
|
|
490
|
+
>
|
|
491
|
+
<slot name="validating">
|
|
492
|
+
<SpinnerIcon class="a-tel-input__detecting-icon" />
|
|
493
|
+
</slot>
|
|
494
|
+
</div>
|
|
495
|
+
</Transition>
|
|
496
|
+
|
|
359
497
|
<!-- Detection-in-flight spinner — shown only during the first debounce window,
|
|
360
498
|
before the picker has appeared. Once the picker is visible (success OR a failed
|
|
361
499
|
attempt that revealed the empty picker) we stop re-flashing on every keystroke. -->
|
|
@@ -383,7 +521,7 @@ defineExpose({
|
|
|
383
521
|
data-slot="tel-input-country-wrapper"
|
|
384
522
|
>
|
|
385
523
|
<ACountrySelect
|
|
386
|
-
|
|
524
|
+
:selected="selectedIso2"
|
|
387
525
|
:allowed-dial-codes="props.allowedDialCodes"
|
|
388
526
|
:disabled="props.disabled || props.loading"
|
|
389
527
|
:size="props.size"
|
|
@@ -394,6 +532,7 @@ defineExpose({
|
|
|
394
532
|
:suggested-label="messages.suggestedLabel"
|
|
395
533
|
:all-countries-label="messages.allCountriesLabel"
|
|
396
534
|
:country-label="messages.countryLabel"
|
|
535
|
+
@update:selected="onPickerPick"
|
|
397
536
|
:select-country-label="messages.selectCountryLabel"
|
|
398
537
|
:flag-url="props.flagUrl"
|
|
399
538
|
:searcher="props.searcher"
|
|
@@ -628,7 +767,8 @@ defineExpose({
|
|
|
628
767
|
padding-inline-start: 0.25rem;
|
|
629
768
|
}
|
|
630
769
|
|
|
631
|
-
.a-tel-input__detecting
|
|
770
|
+
.a-tel-input__detecting,
|
|
771
|
+
.a-tel-input__validating {
|
|
632
772
|
display: inline-flex;
|
|
633
773
|
height: 100%;
|
|
634
774
|
flex-shrink: 0;
|
|
@@ -140,21 +140,38 @@ function tryLocale(): string | null {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/* Module-level dedupe for the IP geolocation fetch. Multiple `<ATelInput>` instances
|
|
144
|
+
* mounting at the same time would otherwise each fire their own request to `ipapi.co`
|
|
145
|
+
* (or whatever `ipEndpoint` resolves to) before the first one's response makes it into
|
|
146
|
+
* sessionStorage. Keyed by endpoint so different consumer-configured endpoints don't
|
|
147
|
+
* collide. */
|
|
148
|
+
const inflightIpFetch = new Map<string, Promise<string | null>>();
|
|
149
|
+
|
|
143
150
|
async function tryIp(endpoint: string, timeoutMs: number): Promise<string | null> {
|
|
144
151
|
if (!isBrowser() || typeof fetch !== 'function') return null;
|
|
152
|
+
const existing = inflightIpFetch.get(endpoint);
|
|
153
|
+
if (existing) return existing;
|
|
154
|
+
|
|
145
155
|
const controller = new AbortController();
|
|
146
156
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
const promise = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetch(endpoint, { signal: controller.signal, credentials: 'omit' });
|
|
160
|
+
if (!res.ok) return null;
|
|
161
|
+
const data = (await res.json()) as { country_code?: string; country?: string };
|
|
162
|
+
const code = (data.country_code ?? data.country ?? '').toString().toUpperCase();
|
|
163
|
+
return /^[A-Z]{2}$/.test(code) ? code : null;
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
} finally {
|
|
167
|
+
clearTimeout(timer);
|
|
168
|
+
// Release the slot once the result is decided. Future calls will read the
|
|
169
|
+
// sessionStorage cache (if a code was found) instead of re-fetching.
|
|
170
|
+
inflightIpFetch.delete(endpoint);
|
|
171
|
+
}
|
|
172
|
+
})();
|
|
173
|
+
inflightIpFetch.set(endpoint, promise);
|
|
174
|
+
return promise;
|
|
158
175
|
}
|
|
159
176
|
|
|
160
177
|
function readCache(): string | null {
|