@alikhalilll/a-tel-input 1.0.1 → 1.1.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.
- package/README.md +585 -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 +13859 -1240
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +120 -585
- package/dist/index.d.ts +120 -585
- package/dist/index.js +13752 -1113
- package/dist/index.js.map +1 -1
- package/dist/styles.css +3 -2
- 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 +41 -6
- package/src/components/ACountrySelect.vue +79 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alikhalilll/a-tel-input",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Headless, shadcn-vue style Vue 3 international telephone input with country detection, validation, and a responsive country picker. Part of the @alikhalilll/a-* toolkit.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "alikhalilll",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"url": "git+https://github.com/alikhalilll/ali-nuxt-toolkit.git",
|
|
10
10
|
"directory": "packages/ui-components/ATelInput"
|
|
11
11
|
},
|
|
12
|
-
"homepage": "https://alikhalilll.github.io/ali-nuxt-toolkit/ui",
|
|
12
|
+
"homepage": "https://alikhalilll.github.io/ali-nuxt-toolkit/ui/tel-input/#install",
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/alikhalilll/ali-nuxt-toolkit/issues"
|
|
15
15
|
},
|
|
@@ -61,9 +61,34 @@
|
|
|
61
61
|
"default": "./dist/resolver/index.cjs"
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
|
+
"./vee-validate": {
|
|
65
|
+
"import": {
|
|
66
|
+
"types": "./dist/vee-validate/index.d.ts",
|
|
67
|
+
"default": "./dist/vee-validate/index.js"
|
|
68
|
+
},
|
|
69
|
+
"require": {
|
|
70
|
+
"types": "./dist/vee-validate/index.d.cts",
|
|
71
|
+
"default": "./dist/vee-validate/index.cjs"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"./zod": {
|
|
75
|
+
"import": {
|
|
76
|
+
"types": "./dist/zod/index.d.ts",
|
|
77
|
+
"default": "./dist/zod/index.js"
|
|
78
|
+
},
|
|
79
|
+
"require": {
|
|
80
|
+
"types": "./dist/zod/index.d.cts",
|
|
81
|
+
"default": "./dist/zod/index.cjs"
|
|
82
|
+
}
|
|
83
|
+
},
|
|
64
84
|
"./styles.css": "./dist/styles.css",
|
|
65
85
|
"./package.json": "./package.json"
|
|
66
86
|
},
|
|
87
|
+
"cssBundleRequires": [
|
|
88
|
+
"a-popover__content",
|
|
89
|
+
"a-drawer__content",
|
|
90
|
+
"a-tel-input"
|
|
91
|
+
],
|
|
67
92
|
"types": "./dist/index.d.ts",
|
|
68
93
|
"web-types": "./web-types.json",
|
|
69
94
|
"files": [
|
|
@@ -80,7 +105,9 @@
|
|
|
80
105
|
"@nuxt/kit": "^3.0.0 || ^4.0.0",
|
|
81
106
|
"@vueuse/core": "^14.0.0",
|
|
82
107
|
"unplugin-vue-components": "^28.0.0 || ^29.0.0 || ^30.0.0 || ^31.0.0 || ^32.0.0",
|
|
83
|
-
"
|
|
108
|
+
"vee-validate": "^4.13.0",
|
|
109
|
+
"vue": "^3.5.0",
|
|
110
|
+
"zod": "^3.23.0 || ^4.0.0"
|
|
84
111
|
},
|
|
85
112
|
"peerDependenciesMeta": {
|
|
86
113
|
"@nuxt/kit": {
|
|
@@ -88,12 +115,17 @@
|
|
|
88
115
|
},
|
|
89
116
|
"unplugin-vue-components": {
|
|
90
117
|
"optional": true
|
|
118
|
+
},
|
|
119
|
+
"vee-validate": {
|
|
120
|
+
"optional": true
|
|
121
|
+
},
|
|
122
|
+
"zod": {
|
|
123
|
+
"optional": true
|
|
91
124
|
}
|
|
92
125
|
},
|
|
93
126
|
"dependencies": {
|
|
94
127
|
"class-variance-authority": "^0.7.1",
|
|
95
|
-
"libphonenumber-js": "^1.12.0"
|
|
96
|
-
"@alikhalilll/a-responsive-popover": "1.0.1"
|
|
128
|
+
"libphonenumber-js": "^1.12.0"
|
|
97
129
|
},
|
|
98
130
|
"devDependencies": {
|
|
99
131
|
"@nuxt/kit": "^4.4.2",
|
|
@@ -110,7 +142,10 @@
|
|
|
110
142
|
"unplugin-vue-components": "^32.1.0",
|
|
111
143
|
"vue": "^3.5.0",
|
|
112
144
|
"vue-tsc": "^3.2.4",
|
|
113
|
-
"
|
|
145
|
+
"vee-validate": "^4.13.0",
|
|
146
|
+
"zod": "^3.23.8",
|
|
147
|
+
"@alikhalilll/a-ui-base": "1.0.0",
|
|
148
|
+
"@alikhalilll/a-responsive-popover": "1.0.1"
|
|
114
149
|
},
|
|
115
150
|
"scripts": {
|
|
116
151
|
"clean": "rimraf dist web-types.json",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed, onMounted, ref, watch } from 'vue';
|
|
2
|
+
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue';
|
|
3
3
|
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
4
|
import {
|
|
5
5
|
AResponsivePopover,
|
|
@@ -188,6 +188,82 @@ watch(open, (isOpen) => {
|
|
|
188
188
|
if (!isOpen) search.value = '';
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
/* ---------------------------------------------------------------
|
|
192
|
+
* Theme inheritance for teleported content. AResponsivePopoverContent
|
|
193
|
+
* is portaled to <body>, so the CSS custom properties a caller set on a
|
|
194
|
+
* wrapper (e.g. `<div :style="{ '--ak-ui-popover': '...' }">`) don't
|
|
195
|
+
* reach the dropdown by inheritance. We snapshot the trigger's resolved
|
|
196
|
+
* tokens and apply them inline on the content. While the menu is open,
|
|
197
|
+
* an rAF loop keeps the values in sync so live theming demos (sliders
|
|
198
|
+
* that mutate the tokens) re-paint the dropdown in real time.
|
|
199
|
+
* ------------------------------------------------------------- */
|
|
200
|
+
const triggerEl = ref<HTMLElement | null>(null);
|
|
201
|
+
const themeStyle = ref<Record<string, string>>({});
|
|
202
|
+
|
|
203
|
+
const THEME_TOKENS = [
|
|
204
|
+
'background',
|
|
205
|
+
'foreground',
|
|
206
|
+
'card',
|
|
207
|
+
'card-foreground',
|
|
208
|
+
'popover',
|
|
209
|
+
'popover-foreground',
|
|
210
|
+
'primary',
|
|
211
|
+
'primary-foreground',
|
|
212
|
+
'secondary',
|
|
213
|
+
'secondary-foreground',
|
|
214
|
+
'muted',
|
|
215
|
+
'muted-foreground',
|
|
216
|
+
'accent',
|
|
217
|
+
'accent-foreground',
|
|
218
|
+
'destructive',
|
|
219
|
+
'destructive-foreground',
|
|
220
|
+
'border',
|
|
221
|
+
'input',
|
|
222
|
+
'ring',
|
|
223
|
+
'radius',
|
|
224
|
+
] as const;
|
|
225
|
+
|
|
226
|
+
function snapshotTheme() {
|
|
227
|
+
const el = triggerEl.value;
|
|
228
|
+
if (!el || typeof window === 'undefined') return;
|
|
229
|
+
const cs = window.getComputedStyle(el);
|
|
230
|
+
const next: Record<string, string> = {};
|
|
231
|
+
for (const t of THEME_TOKENS) {
|
|
232
|
+
const k = `--ak-ui-${t}`;
|
|
233
|
+
const v = cs.getPropertyValue(k);
|
|
234
|
+
if (v) next[k] = v.trim();
|
|
235
|
+
}
|
|
236
|
+
themeStyle.value = next;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let themeRafId = 0;
|
|
240
|
+
function stopThemeLoop() {
|
|
241
|
+
if (themeRafId) {
|
|
242
|
+
cancelAnimationFrame(themeRafId);
|
|
243
|
+
themeRafId = 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function startThemeLoop() {
|
|
247
|
+
stopThemeLoop();
|
|
248
|
+
const tick = () => {
|
|
249
|
+
snapshotTheme();
|
|
250
|
+
if (open.value) themeRafId = requestAnimationFrame(tick);
|
|
251
|
+
else themeRafId = 0;
|
|
252
|
+
};
|
|
253
|
+
themeRafId = requestAnimationFrame(tick);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
watch(open, (isOpen) => {
|
|
257
|
+
if (isOpen) {
|
|
258
|
+
snapshotTheme();
|
|
259
|
+
startThemeLoop();
|
|
260
|
+
} else {
|
|
261
|
+
stopThemeLoop();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
onBeforeUnmount(stopThemeLoop);
|
|
266
|
+
|
|
191
267
|
/** Trigger size — class is consumed by the scoped `<style>` block via `data-size`. The
|
|
192
268
|
* legacy `sizeClasses` slot prop is preserved for backwards compat but it's now an empty
|
|
193
269
|
* string (consumers should rely on `size` directly when overriding the trigger). */
|
|
@@ -215,6 +291,7 @@ defineExpose({
|
|
|
215
291
|
:size-classes="triggerSizeClasses"
|
|
216
292
|
>
|
|
217
293
|
<button
|
|
294
|
+
ref="triggerEl"
|
|
218
295
|
type="button"
|
|
219
296
|
:disabled="props.disabled"
|
|
220
297
|
data-slot="country-select-trigger"
|
|
@@ -247,6 +324,7 @@ defineExpose({
|
|
|
247
324
|
:class="cn('a-country-select__content', props.contentClass)"
|
|
248
325
|
:popover-class="cn('a-country-select__popover', props.popoverClass)"
|
|
249
326
|
:drawer-class="cn('a-country-select__drawer', props.drawerClass)"
|
|
327
|
+
:style="themeStyle"
|
|
250
328
|
>
|
|
251
329
|
<!-- Search header -->
|
|
252
330
|
<slot
|
|
@@ -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;
|