@alikhalilll/a-tel-input 1.0.2 → 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 (39) 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 +444 -683
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +122 -587
  10. package/dist/index.d.ts +122 -587
  11. package/dist/index.js +427 -646
  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 +33 -3
  27. package/src/components/ATelInput.vue +206 -66
  28. package/src/composables/useCountryDetection.ts +28 -11
  29. package/src/composables/useCountryMatching.ts +160 -20
  30. package/src/composables/useCountrySelection.ts +71 -0
  31. package/src/composables/usePhoneValidation.ts +81 -18
  32. package/src/composables/useSyncedModel.ts +80 -0
  33. package/src/composables/useTelInputValidation.ts +50 -11
  34. package/src/index.ts +2 -0
  35. package/src/types.ts +80 -0
  36. package/src/vee-validate/index.ts +2 -0
  37. package/src/vee-validate/useTelField.ts +202 -0
  38. package/src/zod/index.ts +259 -0
  39. package/web-types.json +44 -1
@@ -1,6 +1,12 @@
1
- import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
1
+ import { getCountries, parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
2
2
  import type { CountryOption } from './usePhoneValidation';
3
3
 
4
+ /** Cached snapshot of every country libphonenumber knows about (~250 ISO2 codes).
5
+ * Used by tier 2 of `matchLeadingDialCode` as the last-resort iteration so detection
6
+ * works for *every* country, not just the popular ones in the bundled fallback list.
7
+ * Cached at module load — `getCountries()` is a static metadata table, no I/O. */
8
+ const ALL_LIBPHONENUMBER_ISO2: readonly string[] = getCountries();
9
+
4
10
  /** Synchronous dial-digit → ISO2 fallback for common countries, used when the async
5
11
  * REST Countries fetch hasn't populated `getCountriesByDial`'s index yet at setup. */
6
12
  export const DIAL_TO_ISO2_FALLBACK: Record<string, string> = {
@@ -69,6 +75,36 @@ export const DIAL_TO_ISO2_FALLBACK: Record<string, string> = {
69
75
  * tie-breaker when multiple countries share a dial code (e.g. all NANP). */
70
76
  export const COUNTRY_RECENTS_KEY = 'ali_ui_country_recents_v1';
71
77
 
78
+ /** ISO2 codes iterated by tier 2 of `matchLeadingDialCode` when looking for a country
79
+ * that accepts a local-format input as valid. Mirrors the `FALLBACK_COUNTRIES` list in
80
+ * {@link usePhoneValidation} (kept in sync by tests + by being short and obvious).
81
+ * Order matters — earlier entries get priority when multiple countries would each
82
+ * validate the same input. Built around the most-populated / most-likely countries. */
83
+ export const FALLBACK_ISO2_LIST: readonly string[] = [
84
+ 'SA',
85
+ 'EG',
86
+ 'AE',
87
+ 'US',
88
+ 'GB',
89
+ 'DE',
90
+ 'FR',
91
+ 'ES',
92
+ 'IT',
93
+ 'TR',
94
+ 'RU',
95
+ 'CN',
96
+ 'IN',
97
+ 'JP',
98
+ 'KR',
99
+ 'BR',
100
+ 'MX',
101
+ 'CA',
102
+ 'AU',
103
+ 'NG',
104
+ 'PK',
105
+ 'ID',
106
+ ];
107
+
72
108
  export interface DialMatch {
73
109
  country: CountryOption;
74
110
  /** The national significant number — what the phone input should hold, with both the
@@ -85,6 +121,30 @@ export interface MatchLeadingDialCodeOptions {
85
121
  currentIso2?: string;
86
122
  }
87
123
 
124
+ /** Build a minimal `CountryOption` from libphonenumber metadata when the async REST
125
+ * Countries list hasn't loaded the entry yet. Used so country **detection** works
126
+ * generically for any libphonenumber country, not just the ~22 in the offline
127
+ * fallback list. The picker will overwrite this synthetic record with the real one
128
+ * (with localized name + flag) as soon as `getCountries()` resolves. */
129
+ function buildSyntheticCountry(iso2: string, dialDigits: string): CountryOption {
130
+ const ISO2 = iso2.toUpperCase();
131
+ const digits = String(dialDigits).replace(/\D/g, '');
132
+ return {
133
+ label: `${ISO2} (+${digits})`,
134
+ value: ISO2,
135
+ search_key: `${ISO2.toLowerCase()} +${digits} ${digits}`,
136
+ raw_data: {
137
+ iso2: ISO2,
138
+ dial_code: `+${digits}`,
139
+ dial_digits: digits,
140
+ name: ISO2,
141
+ flag: `https://flagcdn.com/w40/${ISO2.toLowerCase()}.png`,
142
+ source: 'fallback',
143
+ original: {},
144
+ },
145
+ };
146
+ }
147
+
88
148
  function readRecents(): string[] {
89
149
  if (typeof window === 'undefined') return [];
90
150
  try {
@@ -145,12 +205,41 @@ export function useCountryMatching(deps: CountryMatchingDeps) {
145
205
  return Number.isFinite(n) ? n : null;
146
206
  }
147
207
 
208
+ // LRU cache of recent matcher results. Tier 2 iterates over ~250 countries in the
209
+ // worst case (~25–250 ms of parsing); without this cache, every debounce settle on
210
+ // an unmatched input would re-pay that cost. Keyed by the full input + context so
211
+ // user picks / recents updates don't return stale matches. Capped to a small size —
212
+ // typing typically reuses a few prefixes, no need for unbounded memory.
213
+ const MATCHER_CACHE_MAX = 128;
214
+ const matcherCache = new Map<string, DialMatch | null>();
215
+
216
+ function readMatcherCache(key: string): DialMatch | null | undefined {
217
+ if (!matcherCache.has(key)) return undefined;
218
+ // Refresh LRU order by re-inserting.
219
+ const value = matcherCache.get(key)!;
220
+ matcherCache.delete(key);
221
+ matcherCache.set(key, value);
222
+ return value;
223
+ }
224
+
225
+ function writeMatcherCache(key: string, value: DialMatch | null) {
226
+ if (matcherCache.size >= MATCHER_CACHE_MAX) {
227
+ const oldest = matcherCache.keys().next().value;
228
+ if (oldest !== undefined) matcherCache.delete(oldest);
229
+ }
230
+ matcherCache.set(key, value);
231
+ }
232
+
148
233
  /** Three-tier match of the leading digits to a country:
149
234
  * 1. libphonenumber international parse (handles NANP disambiguation).
150
- * 2. libphonenumber national-format parse using `hintCountry` (handles local
151
- * formats like Egyptian `01066105963` with no dial-code prefix).
235
+ * 2. libphonenumber national-format parse, iterating through candidate hint
236
+ * countries (handles local formats like Egyptian `01066105963` with no
237
+ * dial-code prefix). Universal coverage via `getCountries()`.
152
238
  * 3. Longest-prefix match against the dial-digits index, with the current
153
- * selection / recents as tie-breakers when multiple countries share a code. */
239
+ * selection / recents as tie-breakers when multiple countries share a code.
240
+ *
241
+ * Results are LRU-cached per input + context to avoid re-paying tier-2 iteration
242
+ * cost when the user backspaces and retypes the same prefix. */
154
243
  function matchLeadingDialCode(
155
244
  digits: string,
156
245
  options: MatchLeadingDialCodeOptions = {}
@@ -158,38 +247,89 @@ export function useCountryMatching(deps: CountryMatchingDeps) {
158
247
  if (!digits) return null;
159
248
  const { hintCountry, currentIso2 } = options;
160
249
 
161
- // Tier 1: international parse with leading `+`.
250
+ const cacheKey = `${digits}|${hintCountry ?? ''}|${currentIso2 ?? ''}`;
251
+ const cached = readMatcherCache(cacheKey);
252
+ if (cached !== undefined) return cached;
253
+
254
+ const result = runMatch(digits, hintCountry, currentIso2);
255
+ writeMatcherCache(cacheKey, result);
256
+ return result;
257
+ }
258
+
259
+ // Pure tier-1/2/3 matcher — extracted so the public `matchLeadingDialCode` is a thin
260
+ // memoisation wrapper. Returns the first match found; `null` if none.
261
+ function runMatch(
262
+ digits: string,
263
+ hintCountry: string | undefined,
264
+ currentIso2: string | undefined
265
+ ): DialMatch | null {
266
+ // Tier 1: international parse with leading `+`. libphonenumber knows every
267
+ // country's dial code natively — so even when our async country index hasn't
268
+ // populated yet (first paint, no localStorage cache), we can still return a
269
+ // synthetic `CountryOption` derived from the parse result and let the picker
270
+ // upgrade it to the real entry when the fetch resolves.
162
271
  try {
163
272
  const parsed = parsePhoneNumberFromString(`+${digits}`);
164
273
  if (parsed?.country && parsed.countryCallingCode) {
165
- const parsedCountry = getCountryByValue(parsed.country);
166
- if (parsedCountry) {
167
- return { country: parsedCountry, nationalNumber: String(parsed.nationalNumber ?? '') };
168
- }
274
+ const parsedCountry =
275
+ getCountryByValue(parsed.country) ??
276
+ buildSyntheticCountry(parsed.country, String(parsed.countryCallingCode));
277
+ return { country: parsedCountry, nationalNumber: String(parsed.nationalNumber ?? '') };
169
278
  }
170
279
  } catch {
171
280
  /* libphonenumber throws on partial input — fall through */
172
281
  }
173
282
 
174
- // Tier 2: national-format parse using the silently-inferred country as a hint.
175
- if (hintCountry && digits.length >= 4) {
176
- try {
177
- const parsed = parsePhoneNumberFromString(digits, hintCountry as CountryCode);
178
- if (parsed?.isValid()) {
179
- const matched = getCountryByValue(parsed.country || hintCountry);
180
- if (matched) {
283
+ // Tier 2: national-format parse. Iterate through candidate hint countries the env
284
+ // hint, the current selection, the user's recents, the popular-countries shortlist,
285
+ // and finally **every** ISO2 libphonenumber knows about — and return the first one
286
+ // that yields a valid parse. This is what lets `01066105963` resolve to Egypt even
287
+ // when the silent IP/timezone hint is `SA` (the SA parse rejects the number, but
288
+ // iterating finds EG). First-match wins, so the iteration ORDER encodes the priority:
289
+ // 1. Env hint (`hintCountry`).
290
+ // 2. Current picker selection (`currentIso2`).
291
+ // 3. The user's recents (most-recent first).
292
+ // 4. The popular-countries shortlist (`FALLBACK_ISO2_LIST`).
293
+ // 5. Every other libphonenumber country.
294
+ // Step 5 guarantees universal coverage; the earlier steps bias to the more
295
+ // contextually-likely answers when multiple countries would each accept the input.
296
+ if (digits.length >= 4) {
297
+ const candidates = new Set<string>();
298
+ if (hintCountry) candidates.add(hintCountry.toUpperCase());
299
+ if (currentIso2) candidates.add(currentIso2.toUpperCase());
300
+ for (const recent of readRecents()) candidates.add(recent.toUpperCase());
301
+ for (const fallback of FALLBACK_ISO2_LIST) candidates.add(fallback);
302
+ for (const all of ALL_LIBPHONENUMBER_ISO2) candidates.add(all);
303
+
304
+ for (const iso2 of candidates) {
305
+ try {
306
+ const parsed = parsePhoneNumberFromString(digits, iso2 as CountryCode);
307
+ if (parsed?.isValid()) {
308
+ const resolvedIso2 = parsed.country || iso2;
309
+ const matched =
310
+ getCountryByValue(resolvedIso2) ??
311
+ buildSyntheticCountry(resolvedIso2, String(parsed.countryCallingCode ?? ''));
181
312
  return { country: matched, nationalNumber: String(parsed.nationalNumber ?? '') };
182
313
  }
314
+ } catch {
315
+ /* libphonenumber throws on partial input — try next candidate */
183
316
  }
184
- } catch {
185
- /* fall through */
186
317
  }
187
318
  }
188
319
 
189
- // Tier 3: longest-prefix match over the dial-digits index.
320
+ // Tier 3: longest-prefix match over the dial-digits index, with the synchronous
321
+ // `DIAL_TO_ISO2_FALLBACK` table (~60 countries) as a backstop when the async
322
+ // country index hasn't loaded yet. This keeps detection working from first paint
323
+ // for every country in the table — not just the ~22 in `FALLBACK_COUNTRIES`.
190
324
  for (let len = Math.min(3, digits.length); len >= 1; len--) {
191
325
  const prefix = digits.slice(0, len);
192
- const group = getCountriesByDial(prefix);
326
+ let group = getCountriesByDial(prefix);
327
+ if (!group.length) {
328
+ const iso2 = DIAL_TO_ISO2_FALLBACK[prefix];
329
+ if (iso2) {
330
+ group = [getCountryByValue(iso2) ?? buildSyntheticCountry(iso2, prefix)];
331
+ }
332
+ }
193
333
  if (!group.length) continue;
194
334
  const nationalNumber = digits.slice(prefix.length);
195
335
  if (group.length === 1) return { country: group[0], nationalNumber };
@@ -0,0 +1,71 @@
1
+ import { computed, ref, type ComputedRef, type Ref } from 'vue';
2
+
3
+ /**
4
+ * How the currently-selected country came to be.
5
+ *
6
+ * The source drives the detection state machine — some sources are "hints" that
7
+ * typed-international input is allowed to override (`'default'`, `'env'`,
8
+ * `'external'`), others are "locks" that must be cleared before detection can
9
+ * re-route the picker (`'picker'`, `'input'`).
10
+ */
11
+ export type CountrySource =
12
+ /** Nothing selected. */
13
+ | 'none'
14
+ /** Seeded from the `defaultCountry` prop at mount. Overridable. */
15
+ | 'default'
16
+ /** Silent IP / timezone / `navigator.language` resolution at mount. Overridable. */
17
+ | 'env'
18
+ /** `tryMatchPhone` recognised a dial code in user input. Locks until cleared. */
19
+ | 'input'
20
+ /** User clicked an item in the country picker. Locks until cleared. */
21
+ | 'picker'
22
+ /** Caller wrote `v-model:country` (dial number) or `v-model` (E.164) directly.
23
+ * Treated as a hint — typed-international input can still override. */
24
+ | 'external';
25
+
26
+ export interface UseCountrySelectionReturn {
27
+ /** Currently selected ISO 3166-1 alpha-2 code, or `''` when no country selected. */
28
+ iso2: Ref<string>;
29
+ /** Where the current selection came from. */
30
+ source: Ref<CountrySource>;
31
+ /** `true` when typed-input detection should be suppressed (`'picker'` / `'input'`). */
32
+ detectionLocked: ComputedRef<boolean>;
33
+ /** Set both `iso2` and `source` atomically. The single mutator for the selection. */
34
+ set: (iso2: string, source: CountrySource) => void;
35
+ /** Reset to the empty / no-country state. */
36
+ clear: () => void;
37
+ }
38
+
39
+ /**
40
+ * The picker selection state machine for {@link ATelInput}, consolidated into a
41
+ * single composable so the component doesn't have to juggle three boolean flags
42
+ * (`userPickedCountry` / `autoSettingCountry` / `inputDetectionApplied`) and
43
+ * reason about their pairwise interactions.
44
+ *
45
+ * Every write to the selection goes through {@link UseCountrySelectionReturn.set},
46
+ * which records both the new ISO2 and the *origin* of the change. That makes the
47
+ * downstream decision — should detection re-route the picker on the next typed-input
48
+ * burst? — a one-liner: `if (detectionLocked.value) return;`.
49
+ */
50
+ export function useCountrySelection(): UseCountrySelectionReturn {
51
+ const iso2 = ref<string>('');
52
+ const source = ref<CountrySource>('none');
53
+
54
+ function set(nextIso2: string, nextSource: CountrySource) {
55
+ iso2.value = nextIso2;
56
+ source.value = nextSource;
57
+ }
58
+
59
+ function clear() {
60
+ iso2.value = '';
61
+ source.value = 'none';
62
+ }
63
+
64
+ // A "locked" source means the user (or `tryMatchPhone`) has committed to this
65
+ // country — further typed-input detection must not churn the picker. The hint
66
+ // sources (`'default'`, `'env'`, `'external'`) remain overridable by an explicit
67
+ // typed-international prefix; the component layer applies that policy.
68
+ const detectionLocked = computed(() => source.value === 'picker' || source.value === 'input');
69
+
70
+ return { iso2, source, set, clear, detectionLocked };
71
+ }
@@ -94,6 +94,25 @@ export type ValidateArgs =
94
94
  const STORAGE_KEY = 'ali_ui_phone_countries_v1';
95
95
  const REST_COUNTRIES_URL = 'https://restcountries.com/v3.1/all?fields=name,cca2,idd,flags';
96
96
 
97
+ /* -----------------------------------------------------------------------------
98
+ * Module-level singleton state for country data.
99
+ *
100
+ * `usePhoneValidation()` is called once per `<ATelInput>`, once per `<ACountrySelect>`,
101
+ * once per `useTelField()`, once per `zPhone()` — so a single page can spin up four
102
+ * or more instances. Without deduplication, each instance would independently:
103
+ * - JSON.parse the localStorage cache,
104
+ * - fire the REST Countries fetch (~80KB),
105
+ * - parse + normalise the response.
106
+ *
107
+ * Sharing the result via these module-level slots collapses every concurrent call
108
+ * to **one** network request and **one** cache parse for the lifetime of the page.
109
+ * Each `usePhoneValidation()` instance still gets its own reactive `countries` ref,
110
+ * so consumers can mutate their local view (e.g., `props.countries` override) without
111
+ * affecting siblings — the singleton is only consulted as a data source.
112
+ * -------------------------------------------------------------------------- */
113
+ let sharedCountries: CountryOption[] | null = null;
114
+ let inflightFetch: Promise<CountryOption[]> | null = null;
115
+
97
116
  const EX = examples as unknown as Examples;
98
117
 
99
118
  const isBrowser = () => typeof window !== 'undefined';
@@ -267,10 +286,16 @@ export function usePhoneValidation(): UsePhoneValidationReturn {
267
286
  const countries = ref<CountryOption[]>([]);
268
287
  const isCountriesLoading = ref(false);
269
288
 
270
- const byValue = ref<Map<string, CountryOption>>(new Map());
271
- const byDialDigits = ref<Map<string, CountryOption[]>>(new Map());
272
-
273
- function rebuildIndexes(list: CountryOption[]) {
289
+ // Pre-seed the lookup indexes with the bundled fallback (~22 most-populated countries).
290
+ // This makes country *detection* (matchLeadingDialCode, getCountryByValue) work
291
+ // synchronously from first paint — without it, typing a +20/+1/+44 etc. number while
292
+ // the REST Countries fetch is in flight would silently fail every matcher tier because
293
+ // `parsePhoneNumberFromString('+201066105963').country = 'EG'` can't resolve to a
294
+ // `CountryOption` (empty index → null) and tier 3's `getCountriesByDial('20')` also
295
+ // returns []. `countries.value` stays `[]` so `getCountries()` still runs its
296
+ // localStorage → network upgrade path; once that resolves the indexes are rebuilt
297
+ // wholesale with the full ~250-entry list.
298
+ function buildIndexes(list: CountryOption[]) {
274
299
  const valueMap = new Map<string, CountryOption>();
275
300
  const dialMap = new Map<string, CountryOption[]>();
276
301
  for (const item of list) {
@@ -282,6 +307,15 @@ export function usePhoneValidation(): UsePhoneValidationReturn {
282
307
  dialMap.set(dial, bucket);
283
308
  }
284
309
  }
310
+ return { valueMap, dialMap };
311
+ }
312
+
313
+ const _seed = buildIndexes(FALLBACK_COUNTRIES);
314
+ const byValue = ref<Map<string, CountryOption>>(_seed.valueMap);
315
+ const byDialDigits = ref<Map<string, CountryOption[]>>(_seed.dialMap);
316
+
317
+ function rebuildIndexes(list: CountryOption[]) {
318
+ const { valueMap, dialMap } = buildIndexes(list);
285
319
  byValue.value = valueMap;
286
320
  byDialDigits.value = dialMap;
287
321
  }
@@ -335,12 +369,33 @@ export function usePhoneValidation(): UsePhoneValidationReturn {
335
369
  const force = Boolean(options?.force);
336
370
  if (!force && countries.value.length) return countries.value;
337
371
 
372
+ // Shared module-level cache — if any sibling instance already loaded the list,
373
+ // adopt it without re-parsing localStorage or hitting the network.
374
+ if (!force && sharedCountries) {
375
+ upsertCountries(sharedCountries);
376
+ return countries.value;
377
+ }
378
+
379
+ // Shared in-flight promise — if another instance fired the fetch first, await
380
+ // its result instead of starting a duplicate request.
381
+ if (!force && inflightFetch) {
382
+ isCountriesLoading.value = true;
383
+ try {
384
+ const list = await inflightFetch;
385
+ upsertCountries(list);
386
+ return countries.value;
387
+ } finally {
388
+ isCountriesLoading.value = false;
389
+ }
390
+ }
391
+
338
392
  if (!force && isBrowser()) {
339
393
  try {
340
394
  const cached = localStorage.getItem(STORAGE_KEY);
341
395
  if (cached) {
342
396
  const parsed = JSON.parse(cached) as CountryOption[];
343
397
  if (Array.isArray(parsed) && parsed.length) {
398
+ sharedCountries = parsed;
344
399
  upsertCountries(parsed);
345
400
  return countries.value;
346
401
  }
@@ -351,22 +406,30 @@ export function usePhoneValidation(): UsePhoneValidationReturn {
351
406
  }
352
407
 
353
408
  isCountriesLoading.value = true;
354
- try {
355
- const res = await fetch(REST_COUNTRIES_URL);
356
- if (!res.ok) throw new Error(`Failed to fetch countries: ${res.status}`);
357
- const data = (await res.json()) as RestCountry[];
358
- const normalized = normalizeRestCountries(data);
359
- upsertCountries(normalized.length ? normalized : FALLBACK_COUNTRIES);
360
- if (isBrowser()) {
361
- try {
362
- localStorage.setItem(STORAGE_KEY, JSON.stringify(countries.value));
363
- } catch {
364
- /* storage full or disabled */
409
+ inflightFetch = (async (): Promise<CountryOption[]> => {
410
+ try {
411
+ const res = await fetch(REST_COUNTRIES_URL);
412
+ if (!res.ok) throw new Error(`Failed to fetch countries: ${res.status}`);
413
+ const data = (await res.json()) as RestCountry[];
414
+ const normalized = normalizeRestCountries(data);
415
+ const list = normalized.length ? normalized : FALLBACK_COUNTRIES;
416
+ if (isBrowser()) {
417
+ try {
418
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
419
+ } catch {
420
+ /* storage full or disabled */
421
+ }
365
422
  }
423
+ return list;
424
+ } catch {
425
+ return FALLBACK_COUNTRIES;
366
426
  }
367
- return countries.value;
368
- } catch {
369
- upsertCountries(FALLBACK_COUNTRIES);
427
+ })();
428
+
429
+ try {
430
+ const list = await inflightFetch;
431
+ sharedCountries = list;
432
+ upsertCountries(list);
370
433
  return countries.value;
371
434
  } finally {
372
435
  isCountriesLoading.value = false;
@@ -0,0 +1,80 @@
1
+ import { watch, type Ref, type WatchSource } from 'vue';
2
+
3
+ export interface UseSyncedModelOptions<T> {
4
+ /** The `defineModel` ref to keep in sync with internal state. */
5
+ model: Ref<T>;
6
+ /**
7
+ * Internal reactive sources that, when they change, should re-compose and emit
8
+ * a new model value. Typically the refs that {@link compose} reads from.
9
+ */
10
+ triggers: WatchSource[];
11
+ /** Compose the next model value from current internal state. */
12
+ compose: () => T;
13
+ /** Apply an externally-written model value into internal state. */
14
+ apply: (next: T) => void;
15
+ /** Equality test for the model value. Defaults to `Object.is`. */
16
+ isEqual?: (a: T, b: T) => boolean;
17
+ }
18
+
19
+ /**
20
+ * Two-way bidirectional sync between a `defineModel` ref and internal component
21
+ * state — with the **echo-loop guard** built in. Solves a recurring class of
22
+ * bugs in this component where two watchers (external→internal and
23
+ * internal→external) would fight each other and rewrite values the user just
24
+ * typed.
25
+ *
26
+ * Mechanics:
27
+ *
28
+ * 1. When any of `triggers` change AND we're not currently applying an
29
+ * external write, recompute the model value via `compose()` and write it
30
+ * into `model`. Stamp `lastEmitted` first so we recognise the echo.
31
+ * 2. When `model` changes AND the new value isn't the echo of our last emit,
32
+ * apply it into internal state via `apply()`. The `applying` flag is held
33
+ * for the duration of `apply()` so step (1) skips while we mutate.
34
+ *
35
+ * Used for:
36
+ * - `modelValue` (E.164 string) ↔ `phone` + `selectedIso2`.
37
+ * - `country` (dial-number) ↔ `selectedIso2`.
38
+ *
39
+ * The hand-rolled equivalents (`applyingModelValue` / `lastEmittedModelValue`
40
+ * plus the country↔iso2 watcher pair with `autoSettingCountry`) collapse into
41
+ * two calls to this helper.
42
+ */
43
+ export function useSyncedModel<T>(options: UseSyncedModelOptions<T>): void {
44
+ const { model, triggers, compose, apply } = options;
45
+ const isEqual = options.isEqual ?? Object.is;
46
+
47
+ let applying = false;
48
+ let lastEmitted: T | { __unset: true } = { __unset: true };
49
+ const isEcho = (v: T) =>
50
+ typeof lastEmitted === 'object' && lastEmitted !== null && '__unset' in (lastEmitted as object)
51
+ ? false
52
+ : isEqual(v, lastEmitted as T);
53
+
54
+ watch(
55
+ model,
56
+ (next) => {
57
+ if (isEcho(next)) return;
58
+ applying = true;
59
+ try {
60
+ apply(next);
61
+ } finally {
62
+ applying = false;
63
+ }
64
+ },
65
+ { immediate: true }
66
+ );
67
+
68
+ watch(
69
+ triggers,
70
+ () => {
71
+ if (applying) return;
72
+ const next = compose();
73
+ if (!isEqual(next, model.value)) {
74
+ lastEmitted = next;
75
+ model.value = next;
76
+ }
77
+ },
78
+ { flush: 'post' }
79
+ );
80
+ }
@@ -15,13 +15,15 @@ import type { TelInputMessages } from '../types';
15
15
  * component needs:
16
16
  *
17
17
  * - `validation` / `validationState` — the raw + simplified state of the current input.
18
- * - `visibleValidationState` — `validationState` gated by the `hasFinishedTyping` flag
19
- * from {@link useTypingPhase}, so error tints / icons / messages only appear once the
20
- * user has paused. This is the value the template should bind to.
18
+ * - `visibleValidationState` — `validationState` gated by `validateOn` + the
19
+ * `hasFinishedTyping` flag from {@link useTypingPhase}, so error tints / icons /
20
+ * messages only appear at the right moment (after typing pause, after blur, or eagerly).
21
+ * This is the value the template should bind to.
21
22
  * - `errorMessage` — localised error string for the current `validation.reason`, or
22
- * `null` when the input is empty or valid.
23
+ * `null` when the input is empty / valid. When an external `error` is supplied
24
+ * (e.g. from VeeValidate), it wins.
23
25
  * - `showError` / `showHint` — boolean computed properties for conditional rendering
24
- * in the template; both already respect `showValidation` and the typing-pause gate.
26
+ * in the template; both already respect `showValidation` and the visible-state gate.
25
27
  * - `selectedDialCode` — the human-readable dial prefix (`+20`, `+1`, …) for the
26
28
  * selected country, used as an in-input prefix.
27
29
  *
@@ -43,6 +45,17 @@ export interface UseTelInputValidationDeps {
43
45
  getCountryByValue: UsePhoneValidationReturn['getCountryByValue'];
44
46
  }
45
47
 
48
+ /** When to surface validation in the UI.
49
+ * - `'change'` (default) — visible state mirrors the typing-paused state. Errors light up
50
+ * a beat after the user stops typing. Best for inline forms.
51
+ * - `'blur'` — visible state stays `'idle'` until the input has been blurred at least
52
+ * once, then mirrors typing-paused state. Best for VeeValidate / form-library flows
53
+ * that validate on blur.
54
+ * - `'eager'` — visible state mirrors raw `validationState` immediately on every keystroke,
55
+ * no typing pause. Use sparingly; can feel aggressive.
56
+ */
57
+ export type ATelInputValidateOn = 'change' | 'blur' | 'eager';
58
+
46
59
  export interface UseTelInputValidationInputs {
47
60
  /** Digits-only national number model. */
48
61
  phone: Ref<string>;
@@ -50,6 +63,8 @@ export interface UseTelInputValidationInputs {
50
63
  selectedIso2: Ref<string>;
51
64
  /** From {@link useTypingPhase} — gates visible state during the debounce window. */
52
65
  hasFinishedTyping: Readonly<Ref<boolean>>;
66
+ /** Whether the input has been blurred at least once. Drives `validateOn: 'blur'`. */
67
+ hasBlurred: Readonly<Ref<boolean>>;
53
68
  /** Resolved i18n messages (merged defaults + consumer overrides). */
54
69
  messages: ComputedRef<TelInputMessages>;
55
70
  }
@@ -61,6 +76,15 @@ export interface UseTelInputValidationConfig {
61
76
  showValidation: () => boolean | undefined;
62
77
  /** Per-reason error string overrides. From props. */
63
78
  errorMessages: () => Partial<Record<PhoneValidationReason, string>> | undefined;
79
+ /** When to surface validation in the UI. Defaults to `'change'`. */
80
+ validateOn: () => ATelInputValidateOn | undefined;
81
+ /**
82
+ * Externally controlled error (from VeeValidate / Zod / a custom form layer). When set
83
+ * to a non-empty string, the component is forced into `'error'` state and surfaces this
84
+ * message regardless of internal validation. `null` / `undefined` / `''` defers to the
85
+ * internal validator.
86
+ */
87
+ externalError: () => string | null | undefined;
64
88
  }
65
89
 
66
90
  export interface UseTelInputValidationReturn {
@@ -93,25 +117,40 @@ export function useTelInputValidation(
93
117
  })
94
118
  );
95
119
 
120
+ const externalErrorActive = computed<boolean>(() => {
121
+ const e = config.externalError();
122
+ return typeof e === 'string' && e.length > 0;
123
+ });
124
+
96
125
  const validationState = computed<'idle' | 'valid' | 'error'>(() => {
126
+ if (externalErrorActive.value) return 'error';
97
127
  if (!inputs.phone.value) return 'idle';
98
128
  return validation.value.ok ? 'valid' : 'error';
99
129
  });
100
130
 
101
- const visibleValidationState = computed<'idle' | 'valid' | 'error'>(() =>
102
- inputs.hasFinishedTyping.value ? validationState.value : 'idle'
103
- );
131
+ const visibleValidationState = computed<'idle' | 'valid' | 'error'>(() => {
132
+ if (externalErrorActive.value) return 'error';
133
+ const mode = config.validateOn() ?? 'change';
134
+ if (mode === 'eager') return validationState.value;
135
+ if (mode === 'blur' && !inputs.hasBlurred.value) return 'idle';
136
+ return inputs.hasFinishedTyping.value ? validationState.value : 'idle';
137
+ });
104
138
 
105
139
  const errorMessage = computed<string | null>(() => {
140
+ const ext = config.externalError();
141
+ if (typeof ext === 'string' && ext.length > 0) return ext;
106
142
  const v = validation.value;
107
143
  if (v.ok || !v.reason) return null;
108
144
  if (!inputs.phone.value) return null;
109
145
  return config.errorMessages()?.[v.reason] ?? inputs.messages.value.errorMessages[v.reason];
110
146
  });
111
147
 
112
- const showError = computed<boolean>(() =>
113
- Boolean(config.showValidation() && inputs.hasFinishedTyping.value && errorMessage.value)
114
- );
148
+ const showError = computed<boolean>(() => {
149
+ if (!errorMessage.value) return false;
150
+ if (externalErrorActive.value) return true;
151
+ if (!config.showValidation()) return false;
152
+ return visibleValidationState.value === 'error';
153
+ });
115
154
 
116
155
  const showHint = computed<boolean>(
117
156
  () => !showError.value && !inputs.phone.value && !!required.value?.format_hint
package/src/index.ts CHANGED
@@ -34,3 +34,5 @@ export * from './composables/useCountryDetection';
34
34
  export * from './composables/useCountryMatching';
35
35
  export * from './composables/useTypingPhase';
36
36
  export * from './composables/useTelInputValidation';
37
+ export * from './composables/useCountrySelection';
38
+ export * from './composables/useSyncedModel';