@alikhalilll/ui 1.2.3 → 1.2.4
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/entries/drawer/components/ADrawer.vue +16 -0
- package/entries/drawer/components/ADrawerContent.vue +35 -0
- package/entries/drawer/components/ADrawerOverlay.vue +25 -0
- package/entries/drawer/components/ADrawerTrigger.vue +13 -0
- package/entries/drawer/index.ts +4 -0
- package/entries/input/components/AInput.vue +111 -0
- package/entries/input/index.ts +1 -0
- package/entries/popover/components/APopover.vue +19 -0
- package/entries/popover/components/APopoverContent.vue +65 -0
- package/entries/popover/components/APopoverOverlay.vue +69 -0
- package/entries/popover/components/APopoverTrigger.vue +13 -0
- package/entries/popover/composables/useEventScrollLock.ts +193 -0
- package/entries/popover/index.ts +8 -0
- package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
- package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
- package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
- package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
- package/entries/responsive-popover/index.ts +3 -0
- package/entries/tell-input/components/ACountryFlag.vue +68 -0
- package/entries/tell-input/components/ACountrySelect.vue +522 -0
- package/entries/tell-input/components/ATellInput.vue +616 -0
- package/entries/tell-input/composables/useCountryDetection.ts +247 -0
- package/entries/tell-input/composables/useCountryMatching.ts +213 -0
- package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
- package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
- package/entries/tell-input/composables/useTypingPhase.ts +88 -0
- package/entries/tell-input/index.ts +29 -0
- package/entries/tell-input/utils/digits.ts +42 -0
- package/entries/tell-input/utils/flag-url.ts +10 -0
- package/entries/tell-input/utils/types.ts +169 -0
- package/package.json +4 -1
- package/utils/cn.ts +6 -0
- package/utils/index.ts +10 -0
- package/utils/sizes.ts +48 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Country list + phone validation, framework-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Ported from the reference @pkgs/ui ATellInput composable with these cleanups:
|
|
5
|
+
* - Drop Nuxt-only `process.client` checks → use plain `typeof window !== 'undefined'`.
|
|
6
|
+
* - Drop Arabic default placeholder; let consumers pass their own.
|
|
7
|
+
* - Expand the offline fallback list from 2 → ~20 of the most-populated countries.
|
|
8
|
+
* - Keep REST Countries fetch + localStorage cache + libphonenumber-js examples + fast `search_key`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ref, type Ref } from 'vue';
|
|
12
|
+
import {
|
|
13
|
+
type CountryCode,
|
|
14
|
+
type Examples,
|
|
15
|
+
getExampleNumber,
|
|
16
|
+
isValidPhoneNumber,
|
|
17
|
+
parsePhoneNumberFromString,
|
|
18
|
+
} from 'libphonenumber-js';
|
|
19
|
+
import examples from 'libphonenumber-js/examples.mobile.json';
|
|
20
|
+
import { normalizeDigits } from '../utils/digits';
|
|
21
|
+
|
|
22
|
+
/* -----------------------------------------------------------------------------
|
|
23
|
+
* Public types
|
|
24
|
+
* -------------------------------------------------------------------------- */
|
|
25
|
+
export interface RestCountry {
|
|
26
|
+
name?: { common?: string };
|
|
27
|
+
cca2?: string;
|
|
28
|
+
idd?: { root?: string; suffixes?: string[] };
|
|
29
|
+
flags?: { png?: string; svg?: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CountryOption<T = RestCountry> {
|
|
33
|
+
/** Display label, e.g. "Egypt (+20)". */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Stable unique ID — the ISO 3166-1 alpha-2 code, e.g. "EG". */
|
|
36
|
+
value: string;
|
|
37
|
+
/** Precomputed normalized string for fast substring search. */
|
|
38
|
+
search_key: string;
|
|
39
|
+
raw_data: {
|
|
40
|
+
iso2: string;
|
|
41
|
+
dial_code: string;
|
|
42
|
+
dial_digits: string;
|
|
43
|
+
name: string;
|
|
44
|
+
flag: string | null;
|
|
45
|
+
source: 'restcountries' | 'fallback';
|
|
46
|
+
original: T;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PhoneRequiredInfo {
|
|
51
|
+
iso2: string;
|
|
52
|
+
dial_code: string;
|
|
53
|
+
/** Empty by default — consumer passes a placeholder via the component prop. */
|
|
54
|
+
placeholder: string;
|
|
55
|
+
example_national: string;
|
|
56
|
+
example_e164: string;
|
|
57
|
+
national_number_length: { min: number | null; max: number | null };
|
|
58
|
+
format_hint: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type PhoneValidationReason =
|
|
62
|
+
| 'missing_country'
|
|
63
|
+
| 'country_not_supported'
|
|
64
|
+
| 'phone_has_non_digits'
|
|
65
|
+
| 'too_short'
|
|
66
|
+
| 'too_long'
|
|
67
|
+
| 'invalid_phone'
|
|
68
|
+
| 'parse_failed';
|
|
69
|
+
|
|
70
|
+
export interface PhoneValidationResult {
|
|
71
|
+
ok: boolean;
|
|
72
|
+
reason: PhoneValidationReason | null;
|
|
73
|
+
country: { iso2: string; dial_code: string } | null;
|
|
74
|
+
phone: { raw: string | null; digits: string };
|
|
75
|
+
full_phone: string | null;
|
|
76
|
+
required: PhoneRequiredInfo | null;
|
|
77
|
+
details?: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ValidateArgs =
|
|
81
|
+
| {
|
|
82
|
+
country: { iso2: string; dial_code?: string } | null | undefined;
|
|
83
|
+
phone?: undefined;
|
|
84
|
+
/** BCP-47 locale — localizes the numerals in the returned `required.format_hint`. */
|
|
85
|
+
locale?: string;
|
|
86
|
+
}
|
|
87
|
+
| {
|
|
88
|
+
country: { iso2: string; dial_code?: string } | null | undefined;
|
|
89
|
+
phone: string | null;
|
|
90
|
+
/** BCP-47 locale — localizes the numerals in the returned `required.format_hint`. */
|
|
91
|
+
locale?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const STORAGE_KEY = 'ali_ui_phone_countries_v1';
|
|
95
|
+
const REST_COUNTRIES_URL = 'https://restcountries.com/v3.1/all?fields=name,cca2,idd,flags';
|
|
96
|
+
|
|
97
|
+
const EX = examples as unknown as Examples;
|
|
98
|
+
|
|
99
|
+
const isBrowser = () => typeof window !== 'undefined';
|
|
100
|
+
|
|
101
|
+
function toDigits(v: unknown) {
|
|
102
|
+
// Fold alternative numeral systems (Arabic-Indic, Persian, Devanagari, Bengali) down to
|
|
103
|
+
// ASCII first, so a number typed in the user's own script still validates.
|
|
104
|
+
return normalizeDigits(String(v ?? '')).replace(/\D/g, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render an ASCII digit string in a locale's numeral system (e.g. `'ar'` → `٠-٩`).
|
|
109
|
+
* Used only for display hints — falls back to ASCII if the locale is unknown.
|
|
110
|
+
*/
|
|
111
|
+
function localizeDigits(digits: string, locale?: string): string {
|
|
112
|
+
if (!locale) return digits;
|
|
113
|
+
try {
|
|
114
|
+
const fmt = new Intl.NumberFormat(locale, { useGrouping: false });
|
|
115
|
+
return digits.replace(/[0-9]/g, (d) => fmt.format(Number(d)));
|
|
116
|
+
} catch {
|
|
117
|
+
return digits;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensurePlusDial(dial: unknown) {
|
|
122
|
+
const d = toDigits(dial);
|
|
123
|
+
return d ? `+${d}` : '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeIso2(iso2: unknown) {
|
|
127
|
+
return String(iso2 ?? '')
|
|
128
|
+
.trim()
|
|
129
|
+
.toUpperCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dropLeadingZeros(digits: string) {
|
|
133
|
+
return String(digits ?? '').replace(/^0+/, '');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildFullE164(dial: string, digits: string) {
|
|
137
|
+
const dialClean = ensurePlusDial(dial);
|
|
138
|
+
const nsn = dropLeadingZeros(toDigits(digits));
|
|
139
|
+
return dialClean && nsn ? `${dialClean}${nsn}` : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function inferLengthFromExample(national: string) {
|
|
143
|
+
const d = toDigits(national);
|
|
144
|
+
if (!d) return { min: null, max: null };
|
|
145
|
+
const n = d.length;
|
|
146
|
+
return { min: Math.max(4, n - 2), max: n + 2 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildDialCode(idd?: RestCountry['idd']): string | null {
|
|
150
|
+
const root = idd?.root?.trim();
|
|
151
|
+
if (!root || !root.startsWith('+')) return null;
|
|
152
|
+
const suffix = idd?.suffixes?.[0]?.trim() ?? '';
|
|
153
|
+
const out = `${root}${suffix}`;
|
|
154
|
+
return out.startsWith('+') ? out : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeSearchKey(input: string) {
|
|
158
|
+
return (
|
|
159
|
+
String(input ?? '')
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/\s+/g, ' ')
|
|
162
|
+
.trim()
|
|
163
|
+
// Keep letters of every script (so localized names — Arabic, etc. — stay searchable),
|
|
164
|
+
// digits, `+`, and spaces; drop punctuation/symbols.
|
|
165
|
+
.replace(/[^\p{L}\p{N}+ ]/gu, '')
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Return a copy of the country list with display names localized to `locale` via
|
|
171
|
+
* `Intl.DisplayNames`. `search_key` is rebuilt (keeping the English name too) so search
|
|
172
|
+
* still matches either spelling. Unknown locales / regions fall back to the English name.
|
|
173
|
+
*/
|
|
174
|
+
export function localizeCountries(list: CountryOption[], locale?: string): CountryOption[] {
|
|
175
|
+
if (!locale) return list;
|
|
176
|
+
let display: Intl.DisplayNames;
|
|
177
|
+
try {
|
|
178
|
+
display = new Intl.DisplayNames([locale], { type: 'region' });
|
|
179
|
+
} catch {
|
|
180
|
+
return list;
|
|
181
|
+
}
|
|
182
|
+
return list.map((c) => {
|
|
183
|
+
let localized = c.raw_data.name;
|
|
184
|
+
try {
|
|
185
|
+
localized = display.of(c.raw_data.iso2) || c.raw_data.name;
|
|
186
|
+
} catch {
|
|
187
|
+
/* region not in CLDR data — keep English name */
|
|
188
|
+
}
|
|
189
|
+
if (localized === c.raw_data.name) return c;
|
|
190
|
+
const dial = c.raw_data.dial_code;
|
|
191
|
+
return {
|
|
192
|
+
...c,
|
|
193
|
+
label: `${localized} (${dial})`,
|
|
194
|
+
search_key: normalizeSearchKey(
|
|
195
|
+
`${localized} ${c.raw_data.name} ${dial} ${c.raw_data.iso2} ${c.raw_data.dial_digits}`
|
|
196
|
+
),
|
|
197
|
+
raw_data: { ...c.raw_data, name: localized },
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* -----------------------------------------------------------------------------
|
|
203
|
+
* Offline fallback — used when the REST Countries fetch fails. ~20 most-populated
|
|
204
|
+
* countries so the picker is still useful when offline.
|
|
205
|
+
* -------------------------------------------------------------------------- */
|
|
206
|
+
function makeFallback(iso2: string, name: string, dial: string): CountryOption {
|
|
207
|
+
const dialDigits = toDigits(dial);
|
|
208
|
+
return {
|
|
209
|
+
label: `${name} (+${dialDigits})`,
|
|
210
|
+
value: iso2,
|
|
211
|
+
search_key: normalizeSearchKey(`${name} +${dialDigits} ${iso2}`),
|
|
212
|
+
raw_data: {
|
|
213
|
+
iso2,
|
|
214
|
+
dial_code: `+${dialDigits}`,
|
|
215
|
+
dial_digits: dialDigits,
|
|
216
|
+
name,
|
|
217
|
+
flag: `https://flagcdn.com/w40/${iso2.toLowerCase()}.png`,
|
|
218
|
+
source: 'fallback',
|
|
219
|
+
original: {},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const FALLBACK_COUNTRIES: CountryOption[] = [
|
|
225
|
+
makeFallback('SA', 'Saudi Arabia', '+966'),
|
|
226
|
+
makeFallback('EG', 'Egypt', '+20'),
|
|
227
|
+
makeFallback('AE', 'United Arab Emirates', '+971'),
|
|
228
|
+
makeFallback('US', 'United States', '+1'),
|
|
229
|
+
makeFallback('GB', 'United Kingdom', '+44'),
|
|
230
|
+
makeFallback('DE', 'Germany', '+49'),
|
|
231
|
+
makeFallback('FR', 'France', '+33'),
|
|
232
|
+
makeFallback('ES', 'Spain', '+34'),
|
|
233
|
+
makeFallback('IT', 'Italy', '+39'),
|
|
234
|
+
makeFallback('TR', 'Turkey', '+90'),
|
|
235
|
+
makeFallback('RU', 'Russia', '+7'),
|
|
236
|
+
makeFallback('CN', 'China', '+86'),
|
|
237
|
+
makeFallback('IN', 'India', '+91'),
|
|
238
|
+
makeFallback('JP', 'Japan', '+81'),
|
|
239
|
+
makeFallback('KR', 'South Korea', '+82'),
|
|
240
|
+
makeFallback('BR', 'Brazil', '+55'),
|
|
241
|
+
makeFallback('MX', 'Mexico', '+52'),
|
|
242
|
+
makeFallback('CA', 'Canada', '+1'),
|
|
243
|
+
makeFallback('AU', 'Australia', '+61'),
|
|
244
|
+
makeFallback('NG', 'Nigeria', '+234'),
|
|
245
|
+
makeFallback('PK', 'Pakistan', '+92'),
|
|
246
|
+
makeFallback('ID', 'Indonesia', '+62'),
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/* -----------------------------------------------------------------------------
|
|
250
|
+
* Composable
|
|
251
|
+
* -------------------------------------------------------------------------- */
|
|
252
|
+
export interface UsePhoneValidationReturn {
|
|
253
|
+
countries: Ref<CountryOption[]>;
|
|
254
|
+
isCountriesLoading: Ref<boolean>;
|
|
255
|
+
getCountries(options?: { force?: boolean }): Promise<CountryOption[]>;
|
|
256
|
+
searchCountries(keyword: string, limit?: number): CountryOption[];
|
|
257
|
+
getCountryByValue(value: string): CountryOption | null;
|
|
258
|
+
getCountriesByDial(dial: string): CountryOption[];
|
|
259
|
+
getRequiredInfo(
|
|
260
|
+
country: { iso2: string; dial_code?: string },
|
|
261
|
+
locale?: string
|
|
262
|
+
): PhoneRequiredInfo | null;
|
|
263
|
+
validate(input: ValidateArgs): PhoneValidationResult;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function usePhoneValidation(): UsePhoneValidationReturn {
|
|
267
|
+
const countries = ref<CountryOption[]>([]);
|
|
268
|
+
const isCountriesLoading = ref(false);
|
|
269
|
+
|
|
270
|
+
const byValue = ref<Map<string, CountryOption>>(new Map());
|
|
271
|
+
const byDialDigits = ref<Map<string, CountryOption[]>>(new Map());
|
|
272
|
+
|
|
273
|
+
function rebuildIndexes(list: CountryOption[]) {
|
|
274
|
+
const valueMap = new Map<string, CountryOption>();
|
|
275
|
+
const dialMap = new Map<string, CountryOption[]>();
|
|
276
|
+
for (const item of list) {
|
|
277
|
+
valueMap.set(item.value, item);
|
|
278
|
+
const dial = item.raw_data.dial_digits;
|
|
279
|
+
if (dial) {
|
|
280
|
+
const bucket = dialMap.get(dial) ?? [];
|
|
281
|
+
bucket.push(item);
|
|
282
|
+
dialMap.set(dial, bucket);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
byValue.value = valueMap;
|
|
286
|
+
byDialDigits.value = dialMap;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function upsertCountries(list: CountryOption[]) {
|
|
290
|
+
countries.value = list;
|
|
291
|
+
rebuildIndexes(list);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeRestCountries(list: RestCountry[]): CountryOption[] {
|
|
295
|
+
const out: CountryOption[] = [];
|
|
296
|
+
for (const c of list) {
|
|
297
|
+
const name = c?.name?.common?.trim();
|
|
298
|
+
const iso2 = normalizeIso2(c?.cca2);
|
|
299
|
+
const dial = buildDialCode(c?.idd);
|
|
300
|
+
const flag = c?.flags?.png?.trim() || c?.flags?.svg?.trim() || null;
|
|
301
|
+
if (!name || !iso2 || !dial) continue;
|
|
302
|
+
const dialDigits = toDigits(dial);
|
|
303
|
+
const search_key = normalizeSearchKey(`${name} ${dial} ${iso2} ${dialDigits}`);
|
|
304
|
+
out.push({
|
|
305
|
+
label: `${name} (${dial})`,
|
|
306
|
+
value: iso2,
|
|
307
|
+
search_key,
|
|
308
|
+
raw_data: {
|
|
309
|
+
iso2,
|
|
310
|
+
dial_code: dial,
|
|
311
|
+
dial_digits: dialDigits,
|
|
312
|
+
name,
|
|
313
|
+
flag,
|
|
314
|
+
source: 'restcountries',
|
|
315
|
+
original: c,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const map = new Map<string, CountryOption>();
|
|
321
|
+
for (const item of out) {
|
|
322
|
+
const prev = map.get(item.value);
|
|
323
|
+
if (!prev) {
|
|
324
|
+
map.set(item.value, item);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const prevScore = (prev.raw_data.flag ? 1 : 0) + (prev.raw_data.dial_code ? 1 : 0);
|
|
328
|
+
const nextScore = (item.raw_data.flag ? 1 : 0) + (item.raw_data.dial_code ? 1 : 0);
|
|
329
|
+
if (nextScore > prevScore) map.set(item.value, item);
|
|
330
|
+
}
|
|
331
|
+
return Array.from(map.values()).sort((a, b) => a.raw_data.name.localeCompare(b.raw_data.name));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function getCountries(options?: { force?: boolean }) {
|
|
335
|
+
const force = Boolean(options?.force);
|
|
336
|
+
if (!force && countries.value.length) return countries.value;
|
|
337
|
+
|
|
338
|
+
if (!force && isBrowser()) {
|
|
339
|
+
try {
|
|
340
|
+
const cached = localStorage.getItem(STORAGE_KEY);
|
|
341
|
+
if (cached) {
|
|
342
|
+
const parsed = JSON.parse(cached) as CountryOption[];
|
|
343
|
+
if (Array.isArray(parsed) && parsed.length) {
|
|
344
|
+
upsertCountries(parsed);
|
|
345
|
+
return countries.value;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
/* ignore parse errors */
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
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 */
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return countries.value;
|
|
368
|
+
} catch {
|
|
369
|
+
upsertCountries(FALLBACK_COUNTRIES);
|
|
370
|
+
return countries.value;
|
|
371
|
+
} finally {
|
|
372
|
+
isCountriesLoading.value = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function searchCountries(keyword: string, limit = 50) {
|
|
377
|
+
const q = normalizeSearchKey(keyword);
|
|
378
|
+
if (!q) return countries.value.slice(0, limit);
|
|
379
|
+
const res: CountryOption[] = [];
|
|
380
|
+
for (const item of countries.value) {
|
|
381
|
+
if (item.search_key.includes(q)) {
|
|
382
|
+
res.push(item);
|
|
383
|
+
if (res.length >= limit) break;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return res;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getCountryByValue(value: string) {
|
|
390
|
+
return byValue.value.get(normalizeIso2(value)) ?? null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function getCountriesByDial(dial: string) {
|
|
394
|
+
return byDialDigits.value.get(toDigits(dial)) ?? [];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getRequiredInfo(
|
|
398
|
+
country: { iso2: string; dial_code?: string },
|
|
399
|
+
locale?: string
|
|
400
|
+
): PhoneRequiredInfo | null {
|
|
401
|
+
const iso2 = normalizeIso2(country.iso2);
|
|
402
|
+
if (!iso2) return null;
|
|
403
|
+
try {
|
|
404
|
+
const example = getExampleNumber(iso2 as CountryCode, EX);
|
|
405
|
+
const exampleNational = example?.formatNational?.() ?? '';
|
|
406
|
+
const exampleE164 = example?.format?.('E.164') ?? '';
|
|
407
|
+
const inferred = inferLengthFromExample(exampleNational);
|
|
408
|
+
const dial_code = country.dial_code
|
|
409
|
+
? ensurePlusDial(country.dial_code)
|
|
410
|
+
: exampleE164
|
|
411
|
+
? `+${example?.countryCallingCode}`
|
|
412
|
+
: '';
|
|
413
|
+
const digitsExample = toDigits(exampleNational);
|
|
414
|
+
return {
|
|
415
|
+
iso2,
|
|
416
|
+
dial_code,
|
|
417
|
+
placeholder: '',
|
|
418
|
+
example_national: exampleNational,
|
|
419
|
+
example_e164: exampleE164,
|
|
420
|
+
national_number_length: inferred,
|
|
421
|
+
format_hint: digitsExample ? `e.g. ${localizeDigits(digitsExample, locale)}` : '',
|
|
422
|
+
};
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function validate(input: ValidateArgs): PhoneValidationResult {
|
|
429
|
+
const country = input.country ?? null;
|
|
430
|
+
if (!country?.iso2) {
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: 'missing_country',
|
|
434
|
+
country: null,
|
|
435
|
+
phone: { raw: ('phone' in input ? input.phone : null) ?? null, digits: '' },
|
|
436
|
+
full_phone: null,
|
|
437
|
+
required: null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const iso2 = normalizeIso2(country.iso2);
|
|
442
|
+
const required = getRequiredInfo({ iso2, dial_code: country.dial_code }, input.locale);
|
|
443
|
+
if (!required) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
reason: 'country_not_supported',
|
|
447
|
+
country: { iso2, dial_code: ensurePlusDial(country.dial_code) },
|
|
448
|
+
phone: { raw: ('phone' in input ? input.phone : null) ?? null, digits: '' },
|
|
449
|
+
full_phone: null,
|
|
450
|
+
required: null,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!('phone' in input)) {
|
|
455
|
+
return {
|
|
456
|
+
ok: true,
|
|
457
|
+
reason: null,
|
|
458
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
459
|
+
phone: { raw: null, digits: '' },
|
|
460
|
+
full_phone: null,
|
|
461
|
+
required,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const raw = input.phone;
|
|
466
|
+
const digits = toDigits(raw);
|
|
467
|
+
|
|
468
|
+
if (!raw || !String(raw).trim()) {
|
|
469
|
+
return {
|
|
470
|
+
ok: true,
|
|
471
|
+
reason: null,
|
|
472
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
473
|
+
phone: { raw: raw ?? null, digits: '' },
|
|
474
|
+
full_phone: null,
|
|
475
|
+
required,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (
|
|
480
|
+
String(raw)
|
|
481
|
+
.replace(/\s+/g, '')
|
|
482
|
+
.match(/[^\d+]/)
|
|
483
|
+
) {
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
reason: 'phone_has_non_digits',
|
|
487
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
488
|
+
phone: { raw, digits },
|
|
489
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
490
|
+
required,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const nsn = dropLeadingZeros(digits);
|
|
495
|
+
const { min, max } = required.national_number_length;
|
|
496
|
+
|
|
497
|
+
if (min !== null && nsn.length < min) {
|
|
498
|
+
return {
|
|
499
|
+
ok: false,
|
|
500
|
+
reason: 'too_short',
|
|
501
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
502
|
+
phone: { raw, digits },
|
|
503
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
504
|
+
required,
|
|
505
|
+
details: { min, actual: nsn.length },
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (max !== null && nsn.length > max) {
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
reason: 'too_long',
|
|
513
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
514
|
+
phone: { raw, digits },
|
|
515
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
516
|
+
required,
|
|
517
|
+
details: { max, actual: nsn.length },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const full = buildFullE164(required.dial_code, digits) ?? String(raw);
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const ok = isValidPhoneNumber(full, iso2 as CountryCode);
|
|
525
|
+
if (!ok) {
|
|
526
|
+
const parsed = parsePhoneNumberFromString(full, iso2 as CountryCode);
|
|
527
|
+
return {
|
|
528
|
+
ok: false,
|
|
529
|
+
reason: 'invalid_phone',
|
|
530
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
531
|
+
phone: { raw, digits },
|
|
532
|
+
full_phone: parsed?.number ?? null,
|
|
533
|
+
required,
|
|
534
|
+
details: {
|
|
535
|
+
type: parsed?.getType?.() ?? null,
|
|
536
|
+
possible: parsed?.isPossible?.() ?? null,
|
|
537
|
+
country: parsed?.country ?? null,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
const parsed = parsePhoneNumberFromString(full, iso2 as CountryCode);
|
|
542
|
+
return {
|
|
543
|
+
ok: true,
|
|
544
|
+
reason: null,
|
|
545
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
546
|
+
phone: { raw, digits },
|
|
547
|
+
full_phone: parsed?.number ?? full,
|
|
548
|
+
required,
|
|
549
|
+
};
|
|
550
|
+
} catch (e) {
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
reason: 'parse_failed',
|
|
554
|
+
country: { iso2: required.iso2, dial_code: required.dial_code },
|
|
555
|
+
phone: { raw, digits },
|
|
556
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
557
|
+
required,
|
|
558
|
+
details: { error: (e as Error)?.message ?? String(e) },
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
countries,
|
|
565
|
+
isCountriesLoading,
|
|
566
|
+
getCountries,
|
|
567
|
+
searchCountries,
|
|
568
|
+
getCountryByValue,
|
|
569
|
+
getCountriesByDial,
|
|
570
|
+
getRequiredInfo,
|
|
571
|
+
validate,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { computed, type ComputedRef, type Ref } from 'vue';
|
|
2
|
+
import type {
|
|
3
|
+
CountryOption,
|
|
4
|
+
PhoneValidationReason,
|
|
5
|
+
PhoneValidationResult,
|
|
6
|
+
PhoneRequiredInfo,
|
|
7
|
+
UsePhoneValidationReturn,
|
|
8
|
+
} from './usePhoneValidation';
|
|
9
|
+
import type { TellInputMessages } from '../utils/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validation surfacing facade for ATellInput.
|
|
13
|
+
*
|
|
14
|
+
* Wraps the raw `usePhoneValidation()` calls and produces the *view-layer* surface the
|
|
15
|
+
* component needs:
|
|
16
|
+
*
|
|
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.
|
|
21
|
+
* - `errorMessage` — localised error string for the current `validation.reason`, or
|
|
22
|
+
* `null` when the input is empty or valid.
|
|
23
|
+
* - `showError` / `showHint` — boolean computed properties for conditional rendering
|
|
24
|
+
* in the template; both already respect `showValidation` and the typing-pause gate.
|
|
25
|
+
* - `selectedDialCode` — the human-readable dial prefix (`+20`, `+1`, …) for the
|
|
26
|
+
* selected country, used as an in-input prefix.
|
|
27
|
+
*
|
|
28
|
+
* Design notes:
|
|
29
|
+
*
|
|
30
|
+
* - The composable takes the `usePhoneValidation()` return value as a *dependency*
|
|
31
|
+
* rather than calling `usePhoneValidation()` itself. That function creates a fresh
|
|
32
|
+
* country index per invocation; calling it here would produce a second, empty index
|
|
33
|
+
* that never gets populated by the caller's `getCountries()` (the same bug pattern
|
|
34
|
+
* {@link useCountryMatching} avoids).
|
|
35
|
+
*
|
|
36
|
+
* - All inputs are `Ref` / `ComputedRef` so reactivity flows correctly. Method
|
|
37
|
+
* references on the validation singleton (`validate`, `getRequiredInfo`,
|
|
38
|
+
* `getCountryByValue`) are passed verbatim — their backing state is reactive.
|
|
39
|
+
*/
|
|
40
|
+
export interface UseTellInputValidationDeps {
|
|
41
|
+
validate: UsePhoneValidationReturn['validate'];
|
|
42
|
+
getRequiredInfo: UsePhoneValidationReturn['getRequiredInfo'];
|
|
43
|
+
getCountryByValue: UsePhoneValidationReturn['getCountryByValue'];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface UseTellInputValidationInputs {
|
|
47
|
+
/** Digits-only national number model. */
|
|
48
|
+
phone: Ref<string>;
|
|
49
|
+
/** Currently selected ISO2 — empty string when no country chosen. */
|
|
50
|
+
selectedIso2: Ref<string>;
|
|
51
|
+
/** From {@link useTypingPhase} — gates visible state during the debounce window. */
|
|
52
|
+
hasFinishedTyping: Readonly<Ref<boolean>>;
|
|
53
|
+
/** Resolved i18n messages (merged defaults + consumer overrides). */
|
|
54
|
+
messages: ComputedRef<TellInputMessages>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface UseTellInputValidationConfig {
|
|
58
|
+
/** BCP-47 locale; affects `format_hint` numeral rendering. */
|
|
59
|
+
locale: () => string | undefined;
|
|
60
|
+
/** Light up field tinting + error message line. From props. */
|
|
61
|
+
showValidation: () => boolean | undefined;
|
|
62
|
+
/** Per-reason error string overrides. From props. */
|
|
63
|
+
errorMessages: () => Partial<Record<PhoneValidationReason, string>> | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface UseTellInputValidationReturn {
|
|
67
|
+
validation: ComputedRef<PhoneValidationResult>;
|
|
68
|
+
required: ComputedRef<PhoneRequiredInfo | null>;
|
|
69
|
+
validationState: ComputedRef<'idle' | 'valid' | 'error'>;
|
|
70
|
+
visibleValidationState: ComputedRef<'idle' | 'valid' | 'error'>;
|
|
71
|
+
errorMessage: ComputedRef<string | null>;
|
|
72
|
+
showError: ComputedRef<boolean>;
|
|
73
|
+
showHint: ComputedRef<boolean>;
|
|
74
|
+
selectedDialCode: ComputedRef<string | null>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function useTellInputValidation(
|
|
78
|
+
deps: UseTellInputValidationDeps,
|
|
79
|
+
inputs: UseTellInputValidationInputs,
|
|
80
|
+
config: UseTellInputValidationConfig
|
|
81
|
+
): UseTellInputValidationReturn {
|
|
82
|
+
const required = computed(() =>
|
|
83
|
+
inputs.selectedIso2.value
|
|
84
|
+
? deps.getRequiredInfo({ iso2: inputs.selectedIso2.value }, config.locale())
|
|
85
|
+
: null
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const validation = computed<PhoneValidationResult>(() =>
|
|
89
|
+
deps.validate({
|
|
90
|
+
country: inputs.selectedIso2.value ? { iso2: inputs.selectedIso2.value } : null,
|
|
91
|
+
phone: inputs.phone.value ?? '',
|
|
92
|
+
locale: config.locale(),
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const validationState = computed<'idle' | 'valid' | 'error'>(() => {
|
|
97
|
+
if (!inputs.phone.value) return 'idle';
|
|
98
|
+
return validation.value.ok ? 'valid' : 'error';
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const visibleValidationState = computed<'idle' | 'valid' | 'error'>(() =>
|
|
102
|
+
inputs.hasFinishedTyping.value ? validationState.value : 'idle'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const errorMessage = computed<string | null>(() => {
|
|
106
|
+
const v = validation.value;
|
|
107
|
+
if (v.ok || !v.reason) return null;
|
|
108
|
+
if (!inputs.phone.value) return null;
|
|
109
|
+
return config.errorMessages()?.[v.reason] ?? inputs.messages.value.errorMessages[v.reason];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const showError = computed<boolean>(() =>
|
|
113
|
+
Boolean(config.showValidation() && inputs.hasFinishedTyping.value && errorMessage.value)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const showHint = computed<boolean>(
|
|
117
|
+
() => !showError.value && !inputs.phone.value && !!required.value?.format_hint
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const selectedDialCode = computed<string | null>(() => {
|
|
121
|
+
if (!inputs.selectedIso2.value) return null;
|
|
122
|
+
const country: CountryOption | null = deps.getCountryByValue(inputs.selectedIso2.value);
|
|
123
|
+
return country?.raw_data.dial_code ?? null;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
validation,
|
|
128
|
+
required,
|
|
129
|
+
validationState,
|
|
130
|
+
visibleValidationState,
|
|
131
|
+
errorMessage,
|
|
132
|
+
showError,
|
|
133
|
+
showHint,
|
|
134
|
+
selectedDialCode,
|
|
135
|
+
};
|
|
136
|
+
}
|