@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.
Files changed (34) hide show
  1. package/entries/drawer/components/ADrawer.vue +16 -0
  2. package/entries/drawer/components/ADrawerContent.vue +35 -0
  3. package/entries/drawer/components/ADrawerOverlay.vue +25 -0
  4. package/entries/drawer/components/ADrawerTrigger.vue +13 -0
  5. package/entries/drawer/index.ts +4 -0
  6. package/entries/input/components/AInput.vue +111 -0
  7. package/entries/input/index.ts +1 -0
  8. package/entries/popover/components/APopover.vue +19 -0
  9. package/entries/popover/components/APopoverContent.vue +65 -0
  10. package/entries/popover/components/APopoverOverlay.vue +69 -0
  11. package/entries/popover/components/APopoverTrigger.vue +13 -0
  12. package/entries/popover/composables/useEventScrollLock.ts +193 -0
  13. package/entries/popover/index.ts +8 -0
  14. package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
  15. package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
  16. package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
  17. package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
  18. package/entries/responsive-popover/index.ts +3 -0
  19. package/entries/tell-input/components/ACountryFlag.vue +68 -0
  20. package/entries/tell-input/components/ACountrySelect.vue +522 -0
  21. package/entries/tell-input/components/ATellInput.vue +616 -0
  22. package/entries/tell-input/composables/useCountryDetection.ts +247 -0
  23. package/entries/tell-input/composables/useCountryMatching.ts +213 -0
  24. package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
  25. package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
  26. package/entries/tell-input/composables/useTypingPhase.ts +88 -0
  27. package/entries/tell-input/index.ts +29 -0
  28. package/entries/tell-input/utils/digits.ts +42 -0
  29. package/entries/tell-input/utils/flag-url.ts +10 -0
  30. package/entries/tell-input/utils/types.ts +169 -0
  31. package/package.json +4 -1
  32. package/utils/cn.ts +6 -0
  33. package/utils/index.ts +10 -0
  34. 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
+ }