@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.
- 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 +444 -683
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -587
- package/dist/index.d.ts +122 -587
- package/dist/index.js +427 -646
- 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 +33 -3
- package/src/components/ATelInput.vue +206 -66
- package/src/composables/useCountryDetection.ts +28 -11
- package/src/composables/useCountryMatching.ts +160 -20
- package/src/composables/useCountrySelection.ts +71 -0
- package/src/composables/usePhoneValidation.ts +81 -18
- package/src/composables/useSyncedModel.ts +80 -0
- package/src/composables/useTelInputValidation.ts +50 -11
- package/src/index.ts +2 -0
- package/src/types.ts +80 -0
- package/src/vee-validate/index.ts +2 -0
- package/src/vee-validate/useTelField.ts +202 -0
- package/src/zod/index.ts +259 -0
- package/web-types.json +44 -1
|
@@ -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
|
|
151
|
-
* formats like Egyptian `01066105963` with no
|
|
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
|
-
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
19
|
-
* from {@link useTypingPhase}, so error tints / icons /
|
|
20
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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';
|