@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.
Files changed (40) hide show
  1. package/README.md +585 -72
  2. package/dist/_chunks/types.d.ts +661 -0
  3. package/dist/_chunks/types.js +52 -0
  4. package/dist/_chunks/types.js.map +1 -0
  5. package/dist/_chunks/usePhoneValidation.js +539 -0
  6. package/dist/_chunks/usePhoneValidation.js.map +1 -0
  7. package/dist/index.cjs +13859 -1240
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +120 -585
  10. package/dist/index.d.ts +120 -585
  11. package/dist/index.js +13752 -1113
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +3 -2
  14. package/dist/vee-validate/index.cjs +113 -0
  15. package/dist/vee-validate/index.cjs.map +1 -0
  16. package/dist/vee-validate/index.d.cts +86 -0
  17. package/dist/vee-validate/index.d.ts +86 -0
  18. package/dist/vee-validate/index.js +112 -0
  19. package/dist/vee-validate/index.js.map +1 -0
  20. package/dist/zod/index.cjs +211 -0
  21. package/dist/zod/index.cjs.map +1 -0
  22. package/dist/zod/index.d.cts +65 -0
  23. package/dist/zod/index.d.ts +65 -0
  24. package/dist/zod/index.js +208 -0
  25. package/dist/zod/index.js.map +1 -0
  26. package/package.json +41 -6
  27. package/src/components/ACountrySelect.vue +79 -1
  28. package/src/components/ATelInput.vue +206 -66
  29. package/src/composables/useCountryDetection.ts +28 -11
  30. package/src/composables/useCountryMatching.ts +160 -20
  31. package/src/composables/useCountrySelection.ts +71 -0
  32. package/src/composables/usePhoneValidation.ts +81 -18
  33. package/src/composables/useSyncedModel.ts +80 -0
  34. package/src/composables/useTelInputValidation.ts +50 -11
  35. package/src/index.ts +2 -0
  36. package/src/types.ts +80 -0
  37. package/src/vee-validate/index.ts +2 -0
  38. package/src/vee-validate/useTelField.ts +202 -0
  39. package/src/zod/index.ts +259 -0
  40. 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.1",
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
- "vue": "^3.5.0"
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
- "@alikhalilll/a-ui-base": "1.0.0"
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 { resolveMessages, type ATelInputProps, type ATelInputSlots } from '../types';
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
- /** Internal source of truth — the ISO2 alpha-2 code of the picker selection. Synced with
36
- * `country` (dial number) via watchers below. */
37
- const selectedIso2 = ref<string>('');
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. The `onSettle` callback
71
- * runs at the end of every debounce window: it short-circuits when
72
- * detection is disabled / the user already picked / input is empty,
73
- * otherwise marks a detection attempt and applies any match.
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 (userPickedCountry.value || selectedIso2.value) return;
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
- autoSettingCountry.value = true;
88
- selectedIso2.value = match.country.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 — respect it.
152
+ if (selectedIso2.value) return; // v-model:country or v-model has an initial value.
96
153
 
97
- // Explicit `defaultCountry` is treated as the initial picker value (the picker shows
98
- // immediately) this is how callers opt out of the hidden-until-detected default. Accepts
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
- autoSettingCountry.value = true;
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 has already typed something while detection was resolving, re-attempt
133
- // matching now that we have a hint country for the libphonenumber national-format pass.
134
- if (phone.value && !userPickedCountry.value && !selectedIso2.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
- autoSettingCountry.value = true;
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
- autoSettingCountry.value = true;
146
- selectedIso2.value = iso2;
199
+ selection.set(iso2, 'env');
147
200
  }
148
201
  });
149
202
 
150
- /** External → internal: when the caller mutates `v-model:country` (dial number), resolve
151
- * it to an ISO2. If the current ISO2 already maps to this dial (e.g. user has Canada
152
- * selected and the caller writes back `1`), keep the existing selection — don't churn it. */
153
- watch(
154
- country,
155
- (next) => {
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
- if (selectedIso2.value) selectedIso2.value = '';
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) selectedIso2.value = 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
- { flush: 'sync' }
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, etc.).
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
- v-model:selected="selectedIso2"
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;