@alikhalilll/a-tel-input 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +585 -72
  2. package/dist/_chunks/types.d.ts +661 -0
  3. package/dist/_chunks/types.js +52 -0
  4. package/dist/_chunks/types.js.map +1 -0
  5. package/dist/_chunks/usePhoneValidation.js +539 -0
  6. package/dist/_chunks/usePhoneValidation.js.map +1 -0
  7. package/dist/index.cjs +444 -683
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +122 -587
  10. package/dist/index.d.ts +122 -587
  11. package/dist/index.js +427 -646
  12. package/dist/index.js.map +1 -1
  13. package/dist/styles.css +3 -2
  14. package/dist/vee-validate/index.cjs +113 -0
  15. package/dist/vee-validate/index.cjs.map +1 -0
  16. package/dist/vee-validate/index.d.cts +86 -0
  17. package/dist/vee-validate/index.d.ts +86 -0
  18. package/dist/vee-validate/index.js +112 -0
  19. package/dist/vee-validate/index.js.map +1 -0
  20. package/dist/zod/index.cjs +211 -0
  21. package/dist/zod/index.cjs.map +1 -0
  22. package/dist/zod/index.d.cts +65 -0
  23. package/dist/zod/index.d.ts +65 -0
  24. package/dist/zod/index.js +208 -0
  25. package/dist/zod/index.js.map +1 -0
  26. package/package.json +33 -3
  27. package/src/components/ATelInput.vue +206 -66
  28. package/src/composables/useCountryDetection.ts +28 -11
  29. package/src/composables/useCountryMatching.ts +160 -20
  30. package/src/composables/useCountrySelection.ts +71 -0
  31. package/src/composables/usePhoneValidation.ts +81 -18
  32. package/src/composables/useSyncedModel.ts +80 -0
  33. package/src/composables/useTelInputValidation.ts +50 -11
  34. package/src/index.ts +2 -0
  35. package/src/types.ts +80 -0
  36. package/src/vee-validate/index.ts +2 -0
  37. package/src/vee-validate/useTelField.ts +202 -0
  38. package/src/zod/index.ts +259 -0
  39. package/web-types.json +44 -1
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
+ import { i as normalizeDigits, n as usePhoneValidation, r as LOCALE_DIGIT_RANGES, t as localizeCountries } from "./_chunks/usePhoneValidation.js";
2
+ import { i as resolveMessages, n as DEFAULT_MESSAGES, r as aTelInputVariants, t as DEFAULT_ERROR_MESSAGES } from "./_chunks/types.js";
1
3
  import * as vue from "vue";
2
4
  import { Comment, Fragment, Teleport, Transition, camelize, cloneVNode, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createSlots, createVNode, defineComponent, getCurrentInstance, guardReactiveProps, h, inject, isRef, mergeDefaults, mergeModels, mergeProps, nextTick, normalizeClass, normalizeProps, normalizeStyle, onBeforeUnmount, onMounted, onUnmounted, onUpdated, openBlock, provide, reactive, readonly, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, toHandlerKey, toRef, toRefs, toValue, triggerRef, unref, useId, useModel, useSlots, vModelText, watch, watchEffect, watchPostEffect, withCtx, withDirectives, withModifiers } from "vue";
3
- import { getExampleNumber, isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
4
- import examples from "libphonenumber-js/examples.mobile.json";
5
+ import { getCountries, parsePhoneNumberFromString } from "libphonenumber-js";
5
6
  import { computedEager, createGlobalState, createSharedComposable, defaultWindow, onKeyStroke, reactiveOmit, unrefElement, useDebounceFn, useEventListener, useMediaQuery, useMounted, useVModel } from "@vueuse/core";
6
- import { cva } from "class-variance-authority";
7
7
  import { hideOthers } from "aria-hidden";
8
8
  //#region ../../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
9
9
  function r$2(e) {
@@ -3404,508 +3404,6 @@ function cn$2(...inputs) {
3404
3404
  return twMerge$2(clsx$2(inputs));
3405
3405
  }
3406
3406
  //#endregion
3407
- //#region src/utils/digits.ts
3408
- /**
3409
- * Alternative-numeral support. Phone numbers are routinely entered with the digits of the
3410
- * user's own script — Arabic-Indic, Persian/Urdu, Devanagari, Bengali. `libphonenumber-js`
3411
- * and our own `\d` cleanup only understand ASCII `0-9`, so anything else silently becomes
3412
- * an empty number. `normalizeDigits` folds those scripts down to ASCII before validation.
3413
- */
3414
- /**
3415
- * Base code points of contiguous decimal-digit blocks. Each block runs `base`‥`base+9`
3416
- * for digit `0`‥`9`, so the ASCII digit is `codePoint - base`. Add a script by appending
3417
- * one entry here.
3418
- */
3419
- const LOCALE_DIGIT_RANGES = [
3420
- {
3421
- name: "arabic-indic",
3422
- base: 1632
3423
- },
3424
- {
3425
- name: "extended-arabic",
3426
- base: 1776
3427
- },
3428
- {
3429
- name: "devanagari",
3430
- base: 2406
3431
- },
3432
- {
3433
- name: "bengali",
3434
- base: 2534
3435
- }
3436
- ];
3437
- /** Lookup of every non-ASCII digit code point → its ASCII character. */
3438
- const DIGIT_MAP = (() => {
3439
- const map = /* @__PURE__ */ new Map();
3440
- for (const { base } of LOCALE_DIGIT_RANGES) for (let d = 0; d <= 9; d++) map.set(base + d, String(d));
3441
- return map;
3442
- })();
3443
- /**
3444
- * Replace any supported non-ASCII decimal digit with its ASCII equivalent. Every other
3445
- * character (spaces, `+`, separators, letters) is left untouched — callers still run their
3446
- * own `\D` cleanup afterwards.
3447
- */
3448
- function normalizeDigits(input) {
3449
- const str = String(input ?? "");
3450
- let out = "";
3451
- for (const ch of str) {
3452
- const cp = ch.codePointAt(0);
3453
- out += cp != null && DIGIT_MAP.get(cp) || ch;
3454
- }
3455
- return out;
3456
- }
3457
- //#endregion
3458
- //#region src/composables/usePhoneValidation.ts
3459
- /**
3460
- * Country list + phone validation, framework-agnostic.
3461
- *
3462
- * Ported from the reference @pkgs/ui ATelInput composable with these cleanups:
3463
- * - Drop Nuxt-only `process.client` checks → use plain `typeof window !== 'undefined'`.
3464
- * - Drop Arabic default placeholder; let consumers pass their own.
3465
- * - Expand the offline fallback list from 2 → ~20 of the most-populated countries.
3466
- * - Keep REST Countries fetch + localStorage cache + libphonenumber-js examples + fast `search_key`.
3467
- */
3468
- const STORAGE_KEY = "ali_ui_phone_countries_v1";
3469
- const REST_COUNTRIES_URL = "https://restcountries.com/v3.1/all?fields=name,cca2,idd,flags";
3470
- const EX = examples;
3471
- const isBrowser$1 = () => typeof window !== "undefined";
3472
- function toDigits(v) {
3473
- return normalizeDigits(String(v ?? "")).replace(/\D/g, "");
3474
- }
3475
- /**
3476
- * Render an ASCII digit string in a locale's numeral system (e.g. `'ar'` → `٠-٩`).
3477
- * Used only for display hints — falls back to ASCII if the locale is unknown.
3478
- */
3479
- function localizeDigits(digits, locale) {
3480
- if (!locale) return digits;
3481
- try {
3482
- const fmt = new Intl.NumberFormat(locale, { useGrouping: false });
3483
- return digits.replace(/[0-9]/g, (d) => fmt.format(Number(d)));
3484
- } catch {
3485
- return digits;
3486
- }
3487
- }
3488
- function ensurePlusDial(dial) {
3489
- const d = toDigits(dial);
3490
- return d ? `+${d}` : "";
3491
- }
3492
- function normalizeIso2(iso2) {
3493
- return String(iso2 ?? "").trim().toUpperCase();
3494
- }
3495
- function dropLeadingZeros(digits) {
3496
- return String(digits ?? "").replace(/^0+/, "");
3497
- }
3498
- function buildFullE164(dial, digits) {
3499
- const dialClean = ensurePlusDial(dial);
3500
- const nsn = dropLeadingZeros(toDigits(digits));
3501
- return dialClean && nsn ? `${dialClean}${nsn}` : null;
3502
- }
3503
- function inferLengthFromExample(national) {
3504
- const d = toDigits(national);
3505
- if (!d) return {
3506
- min: null,
3507
- max: null
3508
- };
3509
- const n = d.length;
3510
- return {
3511
- min: Math.max(4, n - 2),
3512
- max: n + 2
3513
- };
3514
- }
3515
- function buildDialCode(idd) {
3516
- const root = idd?.root?.trim();
3517
- if (!root || !root.startsWith("+")) return null;
3518
- const out = `${root}${idd?.suffixes?.[0]?.trim() ?? ""}`;
3519
- return out.startsWith("+") ? out : null;
3520
- }
3521
- function normalizeSearchKey(input) {
3522
- return String(input ?? "").toLowerCase().replace(/\s+/g, " ").trim().replace(/[^\p{L}\p{N}+ ]/gu, "");
3523
- }
3524
- /**
3525
- * Return a copy of the country list with display names localized to `locale` via
3526
- * `Intl.DisplayNames`. `search_key` is rebuilt (keeping the English name too) so search
3527
- * still matches either spelling. Unknown locales / regions fall back to the English name.
3528
- */
3529
- function localizeCountries(list, locale) {
3530
- if (!locale) return list;
3531
- let display;
3532
- try {
3533
- display = new Intl.DisplayNames([locale], { type: "region" });
3534
- } catch {
3535
- return list;
3536
- }
3537
- return list.map((c) => {
3538
- let localized = c.raw_data.name;
3539
- try {
3540
- localized = display.of(c.raw_data.iso2) || c.raw_data.name;
3541
- } catch {}
3542
- if (localized === c.raw_data.name) return c;
3543
- const dial = c.raw_data.dial_code;
3544
- return {
3545
- ...c,
3546
- label: `${localized} (${dial})`,
3547
- search_key: normalizeSearchKey(`${localized} ${c.raw_data.name} ${dial} ${c.raw_data.iso2} ${c.raw_data.dial_digits}`),
3548
- raw_data: {
3549
- ...c.raw_data,
3550
- name: localized
3551
- }
3552
- };
3553
- });
3554
- }
3555
- function makeFallback(iso2, name, dial) {
3556
- const dialDigits = toDigits(dial);
3557
- return {
3558
- label: `${name} (+${dialDigits})`,
3559
- value: iso2,
3560
- search_key: normalizeSearchKey(`${name} +${dialDigits} ${iso2}`),
3561
- raw_data: {
3562
- iso2,
3563
- dial_code: `+${dialDigits}`,
3564
- dial_digits: dialDigits,
3565
- name,
3566
- flag: `https://flagcdn.com/w40/${iso2.toLowerCase()}.png`,
3567
- source: "fallback",
3568
- original: {}
3569
- }
3570
- };
3571
- }
3572
- const FALLBACK_COUNTRIES = [
3573
- makeFallback("SA", "Saudi Arabia", "+966"),
3574
- makeFallback("EG", "Egypt", "+20"),
3575
- makeFallback("AE", "United Arab Emirates", "+971"),
3576
- makeFallback("US", "United States", "+1"),
3577
- makeFallback("GB", "United Kingdom", "+44"),
3578
- makeFallback("DE", "Germany", "+49"),
3579
- makeFallback("FR", "France", "+33"),
3580
- makeFallback("ES", "Spain", "+34"),
3581
- makeFallback("IT", "Italy", "+39"),
3582
- makeFallback("TR", "Turkey", "+90"),
3583
- makeFallback("RU", "Russia", "+7"),
3584
- makeFallback("CN", "China", "+86"),
3585
- makeFallback("IN", "India", "+91"),
3586
- makeFallback("JP", "Japan", "+81"),
3587
- makeFallback("KR", "South Korea", "+82"),
3588
- makeFallback("BR", "Brazil", "+55"),
3589
- makeFallback("MX", "Mexico", "+52"),
3590
- makeFallback("CA", "Canada", "+1"),
3591
- makeFallback("AU", "Australia", "+61"),
3592
- makeFallback("NG", "Nigeria", "+234"),
3593
- makeFallback("PK", "Pakistan", "+92"),
3594
- makeFallback("ID", "Indonesia", "+62")
3595
- ];
3596
- function usePhoneValidation() {
3597
- const countries = ref([]);
3598
- const isCountriesLoading = ref(false);
3599
- const byValue = ref(/* @__PURE__ */ new Map());
3600
- const byDialDigits = ref(/* @__PURE__ */ new Map());
3601
- function rebuildIndexes(list) {
3602
- const valueMap = /* @__PURE__ */ new Map();
3603
- const dialMap = /* @__PURE__ */ new Map();
3604
- for (const item of list) {
3605
- valueMap.set(item.value, item);
3606
- const dial = item.raw_data.dial_digits;
3607
- if (dial) {
3608
- const bucket = dialMap.get(dial) ?? [];
3609
- bucket.push(item);
3610
- dialMap.set(dial, bucket);
3611
- }
3612
- }
3613
- byValue.value = valueMap;
3614
- byDialDigits.value = dialMap;
3615
- }
3616
- function upsertCountries(list) {
3617
- countries.value = list;
3618
- rebuildIndexes(list);
3619
- }
3620
- function normalizeRestCountries(list) {
3621
- const out = [];
3622
- for (const c of list) {
3623
- const name = c?.name?.common?.trim();
3624
- const iso2 = normalizeIso2(c?.cca2);
3625
- const dial = buildDialCode(c?.idd);
3626
- const flag = c?.flags?.png?.trim() || c?.flags?.svg?.trim() || null;
3627
- if (!name || !iso2 || !dial) continue;
3628
- const dialDigits = toDigits(dial);
3629
- const search_key = normalizeSearchKey(`${name} ${dial} ${iso2} ${dialDigits}`);
3630
- out.push({
3631
- label: `${name} (${dial})`,
3632
- value: iso2,
3633
- search_key,
3634
- raw_data: {
3635
- iso2,
3636
- dial_code: dial,
3637
- dial_digits: dialDigits,
3638
- name,
3639
- flag,
3640
- source: "restcountries",
3641
- original: c
3642
- }
3643
- });
3644
- }
3645
- const map = /* @__PURE__ */ new Map();
3646
- for (const item of out) {
3647
- const prev = map.get(item.value);
3648
- if (!prev) {
3649
- map.set(item.value, item);
3650
- continue;
3651
- }
3652
- const prevScore = (prev.raw_data.flag ? 1 : 0) + (prev.raw_data.dial_code ? 1 : 0);
3653
- if ((item.raw_data.flag ? 1 : 0) + (item.raw_data.dial_code ? 1 : 0) > prevScore) map.set(item.value, item);
3654
- }
3655
- return Array.from(map.values()).sort((a, b) => a.raw_data.name.localeCompare(b.raw_data.name));
3656
- }
3657
- async function getCountries(options) {
3658
- const force = Boolean(options?.force);
3659
- if (!force && countries.value.length) return countries.value;
3660
- if (!force && isBrowser$1()) try {
3661
- const cached = localStorage.getItem(STORAGE_KEY);
3662
- if (cached) {
3663
- const parsed = JSON.parse(cached);
3664
- if (Array.isArray(parsed) && parsed.length) {
3665
- upsertCountries(parsed);
3666
- return countries.value;
3667
- }
3668
- }
3669
- } catch {}
3670
- isCountriesLoading.value = true;
3671
- try {
3672
- const res = await fetch(REST_COUNTRIES_URL);
3673
- if (!res.ok) throw new Error(`Failed to fetch countries: ${res.status}`);
3674
- const normalized = normalizeRestCountries(await res.json());
3675
- upsertCountries(normalized.length ? normalized : FALLBACK_COUNTRIES);
3676
- if (isBrowser$1()) try {
3677
- localStorage.setItem(STORAGE_KEY, JSON.stringify(countries.value));
3678
- } catch {}
3679
- return countries.value;
3680
- } catch {
3681
- upsertCountries(FALLBACK_COUNTRIES);
3682
- return countries.value;
3683
- } finally {
3684
- isCountriesLoading.value = false;
3685
- }
3686
- }
3687
- function searchCountries(keyword, limit = 50) {
3688
- const q = normalizeSearchKey(keyword);
3689
- if (!q) return countries.value.slice(0, limit);
3690
- const res = [];
3691
- for (const item of countries.value) if (item.search_key.includes(q)) {
3692
- res.push(item);
3693
- if (res.length >= limit) break;
3694
- }
3695
- return res;
3696
- }
3697
- function getCountryByValue(value) {
3698
- return byValue.value.get(normalizeIso2(value)) ?? null;
3699
- }
3700
- function getCountriesByDial(dial) {
3701
- return byDialDigits.value.get(toDigits(dial)) ?? [];
3702
- }
3703
- function getRequiredInfo(country, locale) {
3704
- const iso2 = normalizeIso2(country.iso2);
3705
- if (!iso2) return null;
3706
- try {
3707
- const example = getExampleNumber(iso2, EX);
3708
- const exampleNational = example?.formatNational?.() ?? "";
3709
- const exampleE164 = example?.format?.("E.164") ?? "";
3710
- const inferred = inferLengthFromExample(exampleNational);
3711
- const dial_code = country.dial_code ? ensurePlusDial(country.dial_code) : exampleE164 ? `+${example?.countryCallingCode}` : "";
3712
- const digitsExample = toDigits(exampleNational);
3713
- return {
3714
- iso2,
3715
- dial_code,
3716
- placeholder: "",
3717
- example_national: exampleNational,
3718
- example_e164: exampleE164,
3719
- national_number_length: inferred,
3720
- format_hint: digitsExample ? `e.g. ${localizeDigits(digitsExample, locale)}` : ""
3721
- };
3722
- } catch {
3723
- return null;
3724
- }
3725
- }
3726
- function validate(input) {
3727
- const country = input.country ?? null;
3728
- if (!country?.iso2) return {
3729
- ok: false,
3730
- reason: "missing_country",
3731
- country: null,
3732
- phone: {
3733
- raw: ("phone" in input ? input.phone : null) ?? null,
3734
- digits: ""
3735
- },
3736
- full_phone: null,
3737
- required: null
3738
- };
3739
- const iso2 = normalizeIso2(country.iso2);
3740
- const required = getRequiredInfo({
3741
- iso2,
3742
- dial_code: country.dial_code
3743
- }, input.locale);
3744
- if (!required) return {
3745
- ok: false,
3746
- reason: "country_not_supported",
3747
- country: {
3748
- iso2,
3749
- dial_code: ensurePlusDial(country.dial_code)
3750
- },
3751
- phone: {
3752
- raw: ("phone" in input ? input.phone : null) ?? null,
3753
- digits: ""
3754
- },
3755
- full_phone: null,
3756
- required: null
3757
- };
3758
- if (!("phone" in input)) return {
3759
- ok: true,
3760
- reason: null,
3761
- country: {
3762
- iso2: required.iso2,
3763
- dial_code: required.dial_code
3764
- },
3765
- phone: {
3766
- raw: null,
3767
- digits: ""
3768
- },
3769
- full_phone: null,
3770
- required
3771
- };
3772
- const raw = input.phone;
3773
- const digits = toDigits(raw);
3774
- if (!raw || !String(raw).trim()) return {
3775
- ok: true,
3776
- reason: null,
3777
- country: {
3778
- iso2: required.iso2,
3779
- dial_code: required.dial_code
3780
- },
3781
- phone: {
3782
- raw: raw ?? null,
3783
- digits: ""
3784
- },
3785
- full_phone: null,
3786
- required
3787
- };
3788
- if (String(raw).replace(/\s+/g, "").match(/[^\d+]/)) return {
3789
- ok: false,
3790
- reason: "phone_has_non_digits",
3791
- country: {
3792
- iso2: required.iso2,
3793
- dial_code: required.dial_code
3794
- },
3795
- phone: {
3796
- raw,
3797
- digits
3798
- },
3799
- full_phone: buildFullE164(required.dial_code, digits),
3800
- required
3801
- };
3802
- const nsn = dropLeadingZeros(digits);
3803
- const { min, max } = required.national_number_length;
3804
- if (min !== null && nsn.length < min) return {
3805
- ok: false,
3806
- reason: "too_short",
3807
- country: {
3808
- iso2: required.iso2,
3809
- dial_code: required.dial_code
3810
- },
3811
- phone: {
3812
- raw,
3813
- digits
3814
- },
3815
- full_phone: buildFullE164(required.dial_code, digits),
3816
- required,
3817
- details: {
3818
- min,
3819
- actual: nsn.length
3820
- }
3821
- };
3822
- if (max !== null && nsn.length > max) return {
3823
- ok: false,
3824
- reason: "too_long",
3825
- country: {
3826
- iso2: required.iso2,
3827
- dial_code: required.dial_code
3828
- },
3829
- phone: {
3830
- raw,
3831
- digits
3832
- },
3833
- full_phone: buildFullE164(required.dial_code, digits),
3834
- required,
3835
- details: {
3836
- max,
3837
- actual: nsn.length
3838
- }
3839
- };
3840
- const full = buildFullE164(required.dial_code, digits) ?? String(raw);
3841
- try {
3842
- if (!isValidPhoneNumber(full, iso2)) {
3843
- const parsed = parsePhoneNumberFromString(full, iso2);
3844
- return {
3845
- ok: false,
3846
- reason: "invalid_phone",
3847
- country: {
3848
- iso2: required.iso2,
3849
- dial_code: required.dial_code
3850
- },
3851
- phone: {
3852
- raw,
3853
- digits
3854
- },
3855
- full_phone: parsed?.number ?? null,
3856
- required,
3857
- details: {
3858
- type: parsed?.getType?.() ?? null,
3859
- possible: parsed?.isPossible?.() ?? null,
3860
- country: parsed?.country ?? null
3861
- }
3862
- };
3863
- }
3864
- const parsed = parsePhoneNumberFromString(full, iso2);
3865
- return {
3866
- ok: true,
3867
- reason: null,
3868
- country: {
3869
- iso2: required.iso2,
3870
- dial_code: required.dial_code
3871
- },
3872
- phone: {
3873
- raw,
3874
- digits
3875
- },
3876
- full_phone: parsed?.number ?? full,
3877
- required
3878
- };
3879
- } catch (e) {
3880
- return {
3881
- ok: false,
3882
- reason: "parse_failed",
3883
- country: {
3884
- iso2: required.iso2,
3885
- dial_code: required.dial_code
3886
- },
3887
- phone: {
3888
- raw,
3889
- digits
3890
- },
3891
- full_phone: buildFullE164(required.dial_code, digits),
3892
- required,
3893
- details: { error: e?.message ?? String(e) }
3894
- };
3895
- }
3896
- }
3897
- return {
3898
- countries,
3899
- isCountriesLoading,
3900
- getCountries,
3901
- searchCountries,
3902
- getCountryByValue,
3903
- getCountriesByDial,
3904
- getRequiredInfo,
3905
- validate
3906
- };
3907
- }
3908
- //#endregion
3909
3407
  //#region src/composables/useCountryDetection.ts
3910
3408
  /**
3911
3409
  * Best-effort country detection chain: IP geolocation → timezone → navigator.language → fallback.
@@ -4015,24 +3513,32 @@ function tryLocale() {
4015
3513
  return null;
4016
3514
  }
4017
3515
  }
3516
+ const inflightIpFetch = /* @__PURE__ */ new Map();
4018
3517
  async function tryIp(endpoint, timeoutMs) {
4019
3518
  if (!isBrowser() || typeof fetch !== "function") return null;
3519
+ const existing = inflightIpFetch.get(endpoint);
3520
+ if (existing) return existing;
4020
3521
  const controller = new AbortController();
4021
3522
  const timer = setTimeout(() => controller.abort(), timeoutMs);
4022
- try {
4023
- const res = await fetch(endpoint, {
4024
- signal: controller.signal,
4025
- credentials: "omit"
4026
- });
4027
- if (!res.ok) return null;
4028
- const data = await res.json();
4029
- const code = (data.country_code ?? data.country ?? "").toString().toUpperCase();
4030
- return /^[A-Z]{2}$/.test(code) ? code : null;
4031
- } catch {
4032
- return null;
4033
- } finally {
4034
- clearTimeout(timer);
4035
- }
3523
+ const promise = (async () => {
3524
+ try {
3525
+ const res = await fetch(endpoint, {
3526
+ signal: controller.signal,
3527
+ credentials: "omit"
3528
+ });
3529
+ if (!res.ok) return null;
3530
+ const data = await res.json();
3531
+ const code = (data.country_code ?? data.country ?? "").toString().toUpperCase();
3532
+ return /^[A-Z]{2}$/.test(code) ? code : null;
3533
+ } catch {
3534
+ return null;
3535
+ } finally {
3536
+ clearTimeout(timer);
3537
+ inflightIpFetch.delete(endpoint);
3538
+ }
3539
+ })();
3540
+ inflightIpFetch.set(endpoint, promise);
3541
+ return promise;
4036
3542
  }
4037
3543
  function readCache() {
4038
3544
  if (!isBrowser()) return null;
@@ -4098,6 +3604,11 @@ function useCountryDetection(opts = {}) {
4098
3604
  }
4099
3605
  //#endregion
4100
3606
  //#region src/composables/useCountryMatching.ts
3607
+ /** Cached snapshot of every country libphonenumber knows about (~250 ISO2 codes).
3608
+ * Used by tier 2 of `matchLeadingDialCode` as the last-resort iteration so detection
3609
+ * works for *every* country, not just the popular ones in the bundled fallback list.
3610
+ * Cached at module load — `getCountries()` is a static metadata table, no I/O. */
3611
+ const ALL_LIBPHONENUMBER_ISO2 = getCountries();
4101
3612
  /** Synchronous dial-digit → ISO2 fallback for common countries, used when the async
4102
3613
  * REST Countries fetch hasn't populated `getCountriesByDial`'s index yet at setup. */
4103
3614
  const DIAL_TO_ISO2_FALLBACK = {
@@ -4164,6 +3675,58 @@ const DIAL_TO_ISO2_FALLBACK = {
4164
3675
  /** localStorage key for the user's most recently picked countries. Used as a
4165
3676
  * tie-breaker when multiple countries share a dial code (e.g. all NANP). */
4166
3677
  const COUNTRY_RECENTS_KEY = "ali_ui_country_recents_v1";
3678
+ /** ISO2 codes iterated by tier 2 of `matchLeadingDialCode` when looking for a country
3679
+ * that accepts a local-format input as valid. Mirrors the `FALLBACK_COUNTRIES` list in
3680
+ * {@link usePhoneValidation} (kept in sync by tests + by being short and obvious).
3681
+ * Order matters — earlier entries get priority when multiple countries would each
3682
+ * validate the same input. Built around the most-populated / most-likely countries. */
3683
+ const FALLBACK_ISO2_LIST = [
3684
+ "SA",
3685
+ "EG",
3686
+ "AE",
3687
+ "US",
3688
+ "GB",
3689
+ "DE",
3690
+ "FR",
3691
+ "ES",
3692
+ "IT",
3693
+ "TR",
3694
+ "RU",
3695
+ "CN",
3696
+ "IN",
3697
+ "JP",
3698
+ "KR",
3699
+ "BR",
3700
+ "MX",
3701
+ "CA",
3702
+ "AU",
3703
+ "NG",
3704
+ "PK",
3705
+ "ID"
3706
+ ];
3707
+ /** Build a minimal `CountryOption` from libphonenumber metadata when the async REST
3708
+ * Countries list hasn't loaded the entry yet. Used so country **detection** works
3709
+ * generically for any libphonenumber country, not just the ~22 in the offline
3710
+ * fallback list. The picker will overwrite this synthetic record with the real one
3711
+ * (with localized name + flag) as soon as `getCountries()` resolves. */
3712
+ function buildSyntheticCountry(iso2, dialDigits) {
3713
+ const ISO2 = iso2.toUpperCase();
3714
+ const digits = String(dialDigits).replace(/\D/g, "");
3715
+ return {
3716
+ label: `${ISO2} (+${digits})`,
3717
+ value: ISO2,
3718
+ search_key: `${ISO2.toLowerCase()} +${digits} ${digits}`,
3719
+ raw_data: {
3720
+ iso2: ISO2,
3721
+ dial_code: `+${digits}`,
3722
+ dial_digits: digits,
3723
+ name: ISO2,
3724
+ flag: `https://flagcdn.com/w40/${ISO2.toLowerCase()}.png`,
3725
+ source: "fallback",
3726
+ original: {}
3727
+ }
3728
+ };
3729
+ }
4167
3730
  function readRecents() {
4168
3731
  if (typeof window === "undefined") return [];
4169
3732
  try {
@@ -4209,38 +3772,75 @@ function useCountryMatching(deps) {
4209
3772
  const n = Number(digits);
4210
3773
  return Number.isFinite(n) ? n : null;
4211
3774
  }
3775
+ const MATCHER_CACHE_MAX = 128;
3776
+ const matcherCache = /* @__PURE__ */ new Map();
3777
+ function readMatcherCache(key) {
3778
+ if (!matcherCache.has(key)) return void 0;
3779
+ const value = matcherCache.get(key);
3780
+ matcherCache.delete(key);
3781
+ matcherCache.set(key, value);
3782
+ return value;
3783
+ }
3784
+ function writeMatcherCache(key, value) {
3785
+ if (matcherCache.size >= MATCHER_CACHE_MAX) {
3786
+ const oldest = matcherCache.keys().next().value;
3787
+ if (oldest !== void 0) matcherCache.delete(oldest);
3788
+ }
3789
+ matcherCache.set(key, value);
3790
+ }
4212
3791
  /** Three-tier match of the leading digits to a country:
4213
3792
  * 1. libphonenumber international parse (handles NANP disambiguation).
4214
- * 2. libphonenumber national-format parse using `hintCountry` (handles local
4215
- * formats like Egyptian `01066105963` with no dial-code prefix).
3793
+ * 2. libphonenumber national-format parse, iterating through candidate hint
3794
+ * countries (handles local formats like Egyptian `01066105963` with no
3795
+ * dial-code prefix). Universal coverage via `getCountries()`.
4216
3796
  * 3. Longest-prefix match against the dial-digits index, with the current
4217
- * selection / recents as tie-breakers when multiple countries share a code. */
3797
+ * selection / recents as tie-breakers when multiple countries share a code.
3798
+ *
3799
+ * Results are LRU-cached per input + context to avoid re-paying tier-2 iteration
3800
+ * cost when the user backspaces and retypes the same prefix. */
4218
3801
  function matchLeadingDialCode(digits, options = {}) {
4219
3802
  if (!digits) return null;
4220
3803
  const { hintCountry, currentIso2 } = options;
3804
+ const cacheKey = `${digits}|${hintCountry ?? ""}|${currentIso2 ?? ""}`;
3805
+ const cached = readMatcherCache(cacheKey);
3806
+ if (cached !== void 0) return cached;
3807
+ const result = runMatch(digits, hintCountry, currentIso2);
3808
+ writeMatcherCache(cacheKey, result);
3809
+ return result;
3810
+ }
3811
+ function runMatch(digits, hintCountry, currentIso2) {
4221
3812
  try {
4222
3813
  const parsed = parsePhoneNumberFromString(`+${digits}`);
4223
- if (parsed?.country && parsed.countryCallingCode) {
4224
- const parsedCountry = getCountryByValue(parsed.country);
4225
- if (parsedCountry) return {
4226
- country: parsedCountry,
4227
- nationalNumber: String(parsed.nationalNumber ?? "")
4228
- };
4229
- }
4230
- } catch {}
4231
- if (hintCountry && digits.length >= 4) try {
4232
- const parsed = parsePhoneNumberFromString(digits, hintCountry);
4233
- if (parsed?.isValid()) {
4234
- const matched = getCountryByValue(parsed.country || hintCountry);
4235
- if (matched) return {
4236
- country: matched,
4237
- nationalNumber: String(parsed.nationalNumber ?? "")
4238
- };
4239
- }
3814
+ if (parsed?.country && parsed.countryCallingCode) return {
3815
+ country: getCountryByValue(parsed.country) ?? buildSyntheticCountry(parsed.country, String(parsed.countryCallingCode)),
3816
+ nationalNumber: String(parsed.nationalNumber ?? "")
3817
+ };
4240
3818
  } catch {}
3819
+ if (digits.length >= 4) {
3820
+ const candidates = /* @__PURE__ */ new Set();
3821
+ if (hintCountry) candidates.add(hintCountry.toUpperCase());
3822
+ if (currentIso2) candidates.add(currentIso2.toUpperCase());
3823
+ for (const recent of readRecents()) candidates.add(recent.toUpperCase());
3824
+ for (const fallback of FALLBACK_ISO2_LIST) candidates.add(fallback);
3825
+ for (const all of ALL_LIBPHONENUMBER_ISO2) candidates.add(all);
3826
+ for (const iso2 of candidates) try {
3827
+ const parsed = parsePhoneNumberFromString(digits, iso2);
3828
+ if (parsed?.isValid()) {
3829
+ const resolvedIso2 = parsed.country || iso2;
3830
+ return {
3831
+ country: getCountryByValue(resolvedIso2) ?? buildSyntheticCountry(resolvedIso2, String(parsed.countryCallingCode ?? "")),
3832
+ nationalNumber: String(parsed.nationalNumber ?? "")
3833
+ };
3834
+ }
3835
+ } catch {}
3836
+ }
4241
3837
  for (let len = Math.min(3, digits.length); len >= 1; len--) {
4242
3838
  const prefix = digits.slice(0, len);
4243
- const group = getCountriesByDial(prefix);
3839
+ let group = getCountriesByDial(prefix);
3840
+ if (!group.length) {
3841
+ const iso2 = DIAL_TO_ISO2_FALLBACK[prefix];
3842
+ if (iso2) group = [getCountryByValue(iso2) ?? buildSyntheticCountry(iso2, prefix)];
3843
+ }
4244
3844
  if (!group.length) continue;
4245
3845
  const nationalNumber = digits.slice(prefix.length);
4246
3846
  if (group.length === 1) return {
@@ -4312,18 +3912,36 @@ function useTelInputValidation(deps, inputs, config) {
4312
3912
  phone: inputs.phone.value ?? "",
4313
3913
  locale: config.locale()
4314
3914
  }));
3915
+ const externalErrorActive = computed(() => {
3916
+ const e = config.externalError();
3917
+ return typeof e === "string" && e.length > 0;
3918
+ });
4315
3919
  const validationState = computed(() => {
3920
+ if (externalErrorActive.value) return "error";
4316
3921
  if (!inputs.phone.value) return "idle";
4317
3922
  return validation.value.ok ? "valid" : "error";
4318
3923
  });
4319
- const visibleValidationState = computed(() => inputs.hasFinishedTyping.value ? validationState.value : "idle");
3924
+ const visibleValidationState = computed(() => {
3925
+ if (externalErrorActive.value) return "error";
3926
+ const mode = config.validateOn() ?? "change";
3927
+ if (mode === "eager") return validationState.value;
3928
+ if (mode === "blur" && !inputs.hasBlurred.value) return "idle";
3929
+ return inputs.hasFinishedTyping.value ? validationState.value : "idle";
3930
+ });
4320
3931
  const errorMessage = computed(() => {
3932
+ const ext = config.externalError();
3933
+ if (typeof ext === "string" && ext.length > 0) return ext;
4321
3934
  const v = validation.value;
4322
3935
  if (v.ok || !v.reason) return null;
4323
3936
  if (!inputs.phone.value) return null;
4324
3937
  return config.errorMessages()?.[v.reason] ?? inputs.messages.value.errorMessages[v.reason];
4325
3938
  });
4326
- const showError = computed(() => Boolean(config.showValidation() && inputs.hasFinishedTyping.value && errorMessage.value));
3939
+ const showError = computed(() => {
3940
+ if (!errorMessage.value) return false;
3941
+ if (externalErrorActive.value) return true;
3942
+ if (!config.showValidation()) return false;
3943
+ return visibleValidationState.value === "error";
3944
+ });
4327
3945
  return {
4328
3946
  validation,
4329
3947
  required,
@@ -4339,54 +3957,88 @@ function useTelInputValidation(deps, inputs, config) {
4339
3957
  };
4340
3958
  }
4341
3959
  //#endregion
4342
- //#region src/types.ts
4343
- const aTelInputVariants = cva("a-tel-input__field", {
4344
- variants: { size: {
4345
- xs: "",
4346
- sm: "",
4347
- md: "",
4348
- lg: "",
4349
- xl: ""
4350
- } },
4351
- defaultVariants: { size: "md" }
4352
- });
4353
- const DEFAULT_ERROR_MESSAGES = {
4354
- missing_country: "Please select a country.",
4355
- country_not_supported: "This country is not supported.",
4356
- phone_has_non_digits: "Phone number can only contain digits.",
4357
- too_short: "Phone number is too short.",
4358
- too_long: "Phone number is too long.",
4359
- invalid_phone: "Phone number is invalid.",
4360
- parse_failed: "Could not parse phone number."
4361
- };
4362
- /** English defaults for every {@link TelInputMessages} key. */
4363
- const DEFAULT_MESSAGES = {
4364
- searchPlaceholder: "Search country or +code…",
4365
- emptyText: "No countries found.",
4366
- loadingText: "Loading countries…",
4367
- suggestedLabel: "Suggested",
4368
- allCountriesLabel: "All countries",
4369
- errorMessages: DEFAULT_ERROR_MESSAGES,
4370
- countryLabel: "Country",
4371
- selectCountryLabel: "Select country",
4372
- phoneInputLabel: "Phone number"
4373
- };
3960
+ //#region src/composables/useCountrySelection.ts
4374
3961
  /**
4375
- * Merge a partial `messages` override onto the English defaults. Used internally by
4376
- * `ATelInput` to resolve a complete {@link TelInputMessages} object.
3962
+ * The picker selection state machine for {@link ATelInput}, consolidated into a
3963
+ * single composable so the component doesn't have to juggle three boolean flags
3964
+ * (`userPickedCountry` / `autoSettingCountry` / `inputDetectionApplied`) and
3965
+ * reason about their pairwise interactions.
3966
+ *
3967
+ * Every write to the selection goes through {@link UseCountrySelectionReturn.set},
3968
+ * which records both the new ISO2 and the *origin* of the change. That makes the
3969
+ * downstream decision — should detection re-route the picker on the next typed-input
3970
+ * burst? — a one-liner: `if (detectionLocked.value) return;`.
4377
3971
  */
4378
- function resolveMessages(input) {
4379
- if (!input) return DEFAULT_MESSAGES;
3972
+ function useCountrySelection() {
3973
+ const iso2 = ref("");
3974
+ const source = ref("none");
3975
+ function set(nextIso2, nextSource) {
3976
+ iso2.value = nextIso2;
3977
+ source.value = nextSource;
3978
+ }
3979
+ function clear() {
3980
+ iso2.value = "";
3981
+ source.value = "none";
3982
+ }
4380
3983
  return {
4381
- ...DEFAULT_MESSAGES,
4382
- ...input,
4383
- errorMessages: {
4384
- ...DEFAULT_ERROR_MESSAGES,
4385
- ...input.errorMessages
4386
- }
3984
+ iso2,
3985
+ source,
3986
+ set,
3987
+ clear,
3988
+ detectionLocked: computed(() => source.value === "picker" || source.value === "input")
4387
3989
  };
4388
3990
  }
4389
3991
  //#endregion
3992
+ //#region src/composables/useSyncedModel.ts
3993
+ /**
3994
+ * Two-way bidirectional sync between a `defineModel` ref and internal component
3995
+ * state — with the **echo-loop guard** built in. Solves a recurring class of
3996
+ * bugs in this component where two watchers (external→internal and
3997
+ * internal→external) would fight each other and rewrite values the user just
3998
+ * typed.
3999
+ *
4000
+ * Mechanics:
4001
+ *
4002
+ * 1. When any of `triggers` change AND we're not currently applying an
4003
+ * external write, recompute the model value via `compose()` and write it
4004
+ * into `model`. Stamp `lastEmitted` first so we recognise the echo.
4005
+ * 2. When `model` changes AND the new value isn't the echo of our last emit,
4006
+ * apply it into internal state via `apply()`. The `applying` flag is held
4007
+ * for the duration of `apply()` so step (1) skips while we mutate.
4008
+ *
4009
+ * Used for:
4010
+ * - `modelValue` (E.164 string) ↔ `phone` + `selectedIso2`.
4011
+ * - `country` (dial-number) ↔ `selectedIso2`.
4012
+ *
4013
+ * The hand-rolled equivalents (`applyingModelValue` / `lastEmittedModelValue`
4014
+ * plus the country↔iso2 watcher pair with `autoSettingCountry`) collapse into
4015
+ * two calls to this helper.
4016
+ */
4017
+ function useSyncedModel(options) {
4018
+ const { model, triggers, compose, apply } = options;
4019
+ const isEqual = options.isEqual ?? Object.is;
4020
+ let applying = false;
4021
+ let lastEmitted = { __unset: true };
4022
+ const isEcho = (v) => typeof lastEmitted === "object" && lastEmitted !== null && "__unset" in lastEmitted ? false : isEqual(v, lastEmitted);
4023
+ watch(model, (next) => {
4024
+ if (isEcho(next)) return;
4025
+ applying = true;
4026
+ try {
4027
+ apply(next);
4028
+ } finally {
4029
+ applying = false;
4030
+ }
4031
+ }, { immediate: true });
4032
+ watch(triggers, () => {
4033
+ if (applying) return;
4034
+ const next = compose();
4035
+ if (!isEqual(next, model.value)) {
4036
+ lastEmitted = next;
4037
+ model.value = next;
4038
+ }
4039
+ }, { flush: "post" });
4040
+ }
4041
+ //#endregion
4390
4042
  //#region ../AResponsivePopover/dist/index.js
4391
4043
  var __defProp = Object.defineProperty;
4392
4044
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -17116,7 +16768,7 @@ const _sfc_main$9 = /* @__PURE__ */ defineComponent({
17116
16768
  if (typeof document === "undefined") return [];
17117
16769
  return Array.from(document.querySelectorAll("[data-responsive-popover-scroll-container=\"true\"]"));
17118
16770
  },
17119
- active: computed(() => !!ctx?.open.value && isDesktop.value && scrollLockMode.value === "events")
16771
+ active: computed(() => !!ctx?.open.value && scrollLockMode.value === "events")
17120
16772
  });
17121
16773
  const __returned__ = {
17122
16774
  props,
@@ -17162,7 +16814,8 @@ function _sfc_render$9(_ctx, _cache, $props, $setup, $data, $options) {
17162
16814
  ])) : (openBlock(), createBlock($setup["ADrawerContent"], {
17163
16815
  key: 1,
17164
16816
  class: normalizeClass($setup.mergedClass),
17165
- "data-slot": "responsive-popover-content"
16817
+ "data-slot": "responsive-popover-content",
16818
+ "data-responsive-popover-scroll-container": "true"
17166
16819
  }, {
17167
16820
  default: withCtx(() => [renderSlot(_ctx.$slots, "default")]),
17168
16821
  _: 3
@@ -17821,7 +17474,7 @@ const _hoisted_6$1 = {
17821
17474
  };
17822
17475
  const _hoisted_7$1 = { class: "a-country-select__list" };
17823
17476
  const _hoisted_8$1 = { class: "a-country-select__loading" };
17824
- const _hoisted_9 = { class: "a-country-select__empty" };
17477
+ const _hoisted_9$1 = { class: "a-country-select__empty" };
17825
17478
  const _hoisted_10 = {
17826
17479
  key: 0,
17827
17480
  "data-slot": "country-select-group",
@@ -17919,7 +17572,7 @@ function _sfc_render$1(_ctx, _cache, $props, $setup, $data, $options) {
17919
17572
  createElementVNode("div", _hoisted_7$1, [$setup.isCountriesLoading && $setup.effectiveCountries.length === 0 ? renderSlot(_ctx.$slots, "loading", { key: 0 }, () => [createElementVNode("div", _hoisted_8$1, toDisplayString($setup.props.loadingText), 1)], true) : $setup.isSearching && $setup.filtered.length === 0 ? renderSlot(_ctx.$slots, "empty", {
17920
17573
  key: 1,
17921
17574
  query: $setup.search
17922
- }, () => [createElementVNode("div", _hoisted_9, toDisplayString($setup.props.emptyText), 1)], true) : (openBlock(), createElementBlock(Fragment, { key: 2 }, [
17575
+ }, () => [createElementVNode("div", _hoisted_9$1, toDisplayString($setup.props.emptyText), 1)], true) : (openBlock(), createElementBlock(Fragment, { key: 2 }, [
17923
17576
  createCommentVNode(" Suggested group "),
17924
17577
  $setup.suggested.length > 0 ? (openBlock(), createElementBlock("section", _hoisted_10, [renderSlot(_ctx.$slots, "group-header", {
17925
17578
  label: $setup.props.suggestedLabel,
@@ -18048,6 +17701,27 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18048
17701
  required: false,
18049
17702
  skipCheck: true
18050
17703
  },
17704
+ modelValue: {
17705
+ type: String,
17706
+ required: false
17707
+ },
17708
+ name: {
17709
+ type: String,
17710
+ required: false
17711
+ },
17712
+ error: {
17713
+ type: [String, null],
17714
+ required: false
17715
+ },
17716
+ validating: {
17717
+ type: Boolean,
17718
+ required: false
17719
+ },
17720
+ validateOn: {
17721
+ type: String,
17722
+ required: false,
17723
+ default: "change"
17724
+ },
18051
17725
  placeholder: {
18052
17726
  type: String,
18053
17727
  required: false,
@@ -18190,28 +17864,65 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18190
17864
  type: [Number, null],
18191
17865
  default: null
18192
17866
  },
18193
- "countryModifiers": {}
17867
+ "countryModifiers": {},
17868
+ "modelValue": {
17869
+ type: String,
17870
+ default: ""
17871
+ },
17872
+ "modelModifiers": {}
18194
17873
  }),
18195
- emits: ["update:phone", "update:country"],
18196
- setup(__props, { expose: __expose }) {
17874
+ emits: /* @__PURE__ */ mergeModels([
17875
+ "update:modelValue",
17876
+ "update:phone",
17877
+ "update:country",
17878
+ "blur",
17879
+ "focus"
17880
+ ], [
17881
+ "update:phone",
17882
+ "update:country",
17883
+ "update:modelValue"
17884
+ ]),
17885
+ setup(__props, { expose: __expose, emit: __emit }) {
18197
17886
  const props = __props;
17887
+ const emit = __emit;
18198
17888
  const phone = useModel(__props, "phone");
18199
17889
  /** Public `v-model:country` — the **dial number** (e.g. `20` for Egypt, `44` for the UK,
18200
17890
  * `1` for the NANP block). `null` means no country selected. Internally the component
18201
17891
  * tracks a richer ISO2 code (`selectedIso2`) because dial codes alone can't disambiguate
18202
17892
  * NANP (`+1` covers 25+ countries) — the picker still needs an exact country. */
18203
17893
  const country = useModel(__props, "country");
18204
- /** Internal source of truth — the ISO2 alpha-2 code of the picker selection. Synced with
18205
- * `country` (dial number) via watchers below. */
18206
- const selectedIso2 = ref("");
17894
+ /**
17895
+ * Default v-model the canonical **E.164** string (e.g. `'+201066105963'`).
17896
+ *
17897
+ * Single-string contract for VeeValidate's `<Field v-slot="{ field }">` pattern
17898
+ * (`v-bind="field"`), native `<form>` submission, or any `v-model="phoneE164"`
17899
+ * consumer. Bind it with:
17900
+ *
17901
+ * <ATelInput v-model="phoneE164" />
17902
+ *
17903
+ * <VeeField v-slot="{ field, errors }" name="phone">
17904
+ * <ATelInput v-bind="field" :error="errors[0]" />
17905
+ * </VeeField>
17906
+ *
17907
+ * When set externally, the value is parsed via libphonenumber-js → the country
17908
+ * picker and the digits-only `phone` model are derived from it. When the user
17909
+ * types or picks a country, the composed E.164 is written back out. Stays in
17910
+ * sync with `v-model:phone` / `v-model:country` — you can use either contract.
17911
+ */
17912
+ const modelValue = useModel(__props, "modelValue");
17913
+ /** The picker selection state machine — `iso2` is the internal source of truth, `source`
17914
+ * records where the current selection came from, `detectionLocked` answers "should
17915
+ * typed-input detection re-route the picker on the next burst?". Single mutator: `set`.
17916
+ * Replaces the historical flag soup (`userPickedCountry` / `autoSettingCountry` /
17917
+ * `inputDetectionApplied`). */
17918
+ const selection = useCountrySelection();
17919
+ const selectedIso2 = selection.iso2;
18207
17920
  const { getCountries, validate, getRequiredInfo, getCountryByValue, getCountriesByDial } = usePhoneValidation();
18208
17921
  const { resolveCountryIdentifier, dialNumberFor, matchLeadingDialCode } = useCountryMatching({
18209
17922
  getCountryByValue,
18210
17923
  getCountriesByDial
18211
17924
  });
18212
17925
  getCountries();
18213
- const userPickedCountry = ref(false);
18214
- const autoSettingCountry = ref(false);
18215
17926
  /** Silently resolved via IP/timezone/locale when `detectFromInput` is on — used as a hint
18216
17927
  * so local-format numbers (e.g. Egyptian `01066105963`) can be parsed without a `+` prefix.
18217
17928
  * Seeded from `defaultCountry` so it has a usable value before async detection resolves. */
@@ -18225,18 +17936,28 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18225
17936
  currentIso2: selectedIso2.value
18226
17937
  });
18227
17938
  }
17939
+ /** User explicitly picked a country from the picker — locks the selection so subsequent
17940
+ * typed-input detection cannot churn the picker. */
17941
+ function onPickerPick(iso2) {
17942
+ selection.set(iso2, "picker");
17943
+ }
18228
17944
  const typing = useTypingPhase({
18229
17945
  debounceMs: computed(() => Math.max(0, props.detectDebounceMs)),
18230
17946
  onSettle: () => {
18231
17947
  if (!props.detectFromInput) return;
18232
- if (userPickedCountry.value || selectedIso2.value) return;
17948
+ if (selection.detectionLocked.value) return;
18233
17949
  const current = phone.value;
18234
17950
  if (!current) return;
17951
+ const typedInternational = (displayValue.value ?? "").trimStart().startsWith("+");
17952
+ if (selectedIso2.value && !typedInternational) return;
18235
17953
  typing.markDetectionAttempt();
18236
17954
  const match = tryMatchPhone(current);
18237
17955
  if (!match) return;
18238
- autoSettingCountry.value = true;
18239
- selectedIso2.value = match.country.value;
17956
+ if (match.country.value === selectedIso2.value && match.nationalNumber === phone.value) {
17957
+ selection.source.value = "input";
17958
+ return;
17959
+ }
17960
+ selection.set(match.country.value, "input");
18240
17961
  phone.value = match.nationalNumber;
18241
17962
  }
18242
17963
  });
@@ -18247,8 +17968,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18247
17968
  const seed = resolveCountryIdentifier(props.defaultCountry);
18248
17969
  if (seed) {
18249
17970
  inferredCountry.value = seed;
18250
- autoSettingCountry.value = true;
18251
- selectedIso2.value = seed;
17971
+ selection.set(seed, "default");
18252
17972
  return;
18253
17973
  }
18254
17974
  }
@@ -18267,43 +17987,31 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18267
17987
  const iso2 = detected ? detected.toUpperCase() : "";
18268
17988
  if (props.detectFromInput) {
18269
17989
  inferredCountry.value = iso2;
18270
- if (phone.value && !userPickedCountry.value && !selectedIso2.value) {
17990
+ if (phone.value && !selection.detectionLocked.value && !selectedIso2.value) {
18271
17991
  const match = tryMatchPhone(phone.value);
18272
17992
  if (match) {
18273
- autoSettingCountry.value = true;
18274
- selectedIso2.value = match.country.value;
17993
+ selection.set(match.country.value, "input");
18275
17994
  phone.value = match.nationalNumber;
18276
17995
  }
18277
17996
  }
18278
17997
  return;
18279
17998
  }
18280
- if (!selectedIso2.value && iso2) {
18281
- autoSettingCountry.value = true;
18282
- selectedIso2.value = iso2;
18283
- }
17999
+ if (!selectedIso2.value && iso2) selection.set(iso2, "env");
18284
18000
  });
18285
- /** External → internal: when the caller mutates `v-model:country` (dial number), resolve
18286
- * it to an ISO2. If the current ISO2 already maps to this dial (e.g. user has Canada
18287
- * selected and the caller writes back `1`), keep the existing selection — don't churn it. */
18288
- watch(country, (next) => {
18289
- if (next == null) {
18290
- if (selectedIso2.value) selectedIso2.value = "";
18291
- return;
18001
+ useSyncedModel({
18002
+ model: country,
18003
+ triggers: [selectedIso2],
18004
+ compose: () => selectedIso2.value ? dialNumberFor(selectedIso2.value) : null,
18005
+ apply: (next) => {
18006
+ if (next == null) {
18007
+ selection.clear();
18008
+ return;
18009
+ }
18010
+ if (dialNumberFor(selectedIso2.value) === next) return;
18011
+ const iso2 = resolveCountryIdentifier(String(next));
18012
+ if (iso2) selection.set(iso2, "external");
18292
18013
  }
18293
- if (dialNumberFor(selectedIso2.value) === next) return;
18294
- const iso2 = resolveCountryIdentifier(String(next));
18295
- if (iso2) selectedIso2.value = iso2;
18296
- }, { immediate: true });
18297
- /** Internal → external: keep `country` (dial number) in lockstep with `selectedIso2`, and
18298
- * flag "user manually picked from picker" when the change isn't one we initiated.
18299
- * `flush: 'sync'` so the `autoSettingCountry` guard is reliable. */
18300
- watch(selectedIso2, (iso2, prev) => {
18301
- const wasAutoSet = autoSettingCountry.value;
18302
- autoSettingCountry.value = false;
18303
- const nextDial = dialNumberFor(iso2);
18304
- if (country.value !== nextDial) country.value = nextDial;
18305
- if (!wasAutoSet && props.detectFromInput && iso2 && prev !== iso2) userPickedCountry.value = true;
18306
- }, { flush: "sync" });
18014
+ });
18307
18015
  /** The string shown in the `<input>`. Deliberately decoupled from `phone` (the digits-only
18308
18016
  * model) so the visible field is NOT rewritten mid-edit — non-digits / alternative numerals
18309
18017
  * are normalized into `phone` immediately, but the displayed value is only cleaned up once
@@ -18312,6 +18020,29 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18312
18020
  /** Set when the in-flight `phone` change came from the user typing — tells the `phone`
18313
18021
  * watcher to leave `displayValue` alone (the user is still editing it). */
18314
18022
  let phoneEditedByInput = false;
18023
+ useSyncedModel({
18024
+ model: modelValue,
18025
+ triggers: [phone, selectedIso2],
18026
+ compose: () => {
18027
+ if (!selectedIso2.value || !phone.value) return "";
18028
+ return validate({
18029
+ country: { iso2: selectedIso2.value },
18030
+ phone: phone.value
18031
+ }).full_phone ?? "";
18032
+ },
18033
+ apply: (next) => {
18034
+ const trimmed = String(next ?? "").trim();
18035
+ if (!trimmed) {
18036
+ if (phone.value !== "") phone.value = "";
18037
+ if (selectedIso2.value !== "") selection.clear();
18038
+ return;
18039
+ }
18040
+ const parsed = parsePhoneNumberFromString(trimmed.startsWith("+") ? trimmed : `+${trimmed.replace(/^\+/, "")}`);
18041
+ if (!parsed || !parsed.country) return;
18042
+ if (selectedIso2.value !== parsed.country) selection.set(parsed.country, "external");
18043
+ if (phone.value !== parsed.nationalNumber) phone.value = parsed.nationalNumber;
18044
+ }
18045
+ });
18315
18046
  function commitPhone(value) {
18316
18047
  phoneEditedByInput = true;
18317
18048
  phone.value = value;
@@ -18322,11 +18053,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18322
18053
  const cleaned = normalizeDigits(target.value).replace(/\D/g, "");
18323
18054
  if (!cleaned) {
18324
18055
  typing.reset();
18325
- if (props.detectFromInput) {
18326
- autoSettingCountry.value = true;
18327
- selectedIso2.value = "";
18328
- userPickedCountry.value = false;
18329
- }
18056
+ if (props.detectFromInput) selection.clear();
18330
18057
  commitPhone("");
18331
18058
  return;
18332
18059
  }
@@ -18359,6 +18086,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18359
18086
  * `undefined` so it inherits from the page. The field row itself is always LTR so the
18360
18087
  * dial prefix / digits / flag trigger keep a consistent order. */
18361
18088
  const dirAttr = computed(() => props.dir === "ltr" || props.dir === "rtl" ? props.dir : void 0);
18089
+ /** Set to `true` the first time the input is blurred. Drives `validateOn: 'blur'`. */
18090
+ const hasBlurred = ref(false);
18362
18091
  const { validation, required, validationState, visibleValidationState, errorMessage, showError, showHint, selectedDialCode } = useTelInputValidation({
18363
18092
  validate,
18364
18093
  getRequiredInfo,
@@ -18367,15 +18096,35 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18367
18096
  phone,
18368
18097
  selectedIso2,
18369
18098
  hasFinishedTyping,
18099
+ hasBlurred,
18370
18100
  messages
18371
18101
  }, {
18372
18102
  locale: () => props.locale,
18373
18103
  showValidation: () => props.showValidation,
18374
- errorMessages: () => props.errorMessages
18104
+ errorMessages: () => props.errorMessages,
18105
+ validateOn: () => props.validateOn,
18106
+ externalError: () => props.error
18375
18107
  });
18376
18108
  const effectivePlaceholder = computed(() => props.placeholder || required.value?.format_hint || messages.value.phoneInputLabel);
18377
18109
  const helperId = useId();
18378
18110
  const describedBy = computed(() => showError.value || showHint.value ? helperId : void 0);
18111
+ const inputRef = ref(null);
18112
+ function handleBlur(e) {
18113
+ hasBlurred.value = true;
18114
+ emit("blur", e);
18115
+ }
18116
+ function handleFocus(e) {
18117
+ emit("focus", e);
18118
+ }
18119
+ function focus(options) {
18120
+ inputRef.value?.focus(options);
18121
+ }
18122
+ function blur() {
18123
+ inputRef.value?.blur();
18124
+ }
18125
+ function select() {
18126
+ inputRef.value?.select();
18127
+ }
18379
18128
  __expose({
18380
18129
  validation,
18381
18130
  required,
@@ -18384,12 +18133,18 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18384
18133
  visibleValidationState,
18385
18134
  isDetecting,
18386
18135
  hasFinishedTyping,
18387
- detectionAttempted
18136
+ detectionAttempted,
18137
+ focus,
18138
+ blur,
18139
+ select
18388
18140
  });
18389
18141
  const __returned__ = {
18390
18142
  props,
18143
+ emit,
18391
18144
  phone,
18392
18145
  country,
18146
+ modelValue,
18147
+ selection,
18393
18148
  selectedIso2,
18394
18149
  getCountries,
18395
18150
  validate,
@@ -18399,10 +18154,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18399
18154
  resolveCountryIdentifier,
18400
18155
  dialNumberFor,
18401
18156
  matchLeadingDialCode,
18402
- userPickedCountry,
18403
- autoSettingCountry,
18404
18157
  inferredCountry,
18405
18158
  tryMatchPhone,
18159
+ onPickerPick,
18406
18160
  typing,
18407
18161
  isDetecting,
18408
18162
  hasFinishedTyping,
@@ -18419,6 +18173,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18419
18173
  handlePhoneChange,
18420
18174
  messages,
18421
18175
  dirAttr,
18176
+ hasBlurred,
18422
18177
  validation,
18423
18178
  required,
18424
18179
  validationState,
@@ -18430,6 +18185,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
18430
18185
  effectivePlaceholder,
18431
18186
  helperId,
18432
18187
  describedBy,
18188
+ inputRef,
18189
+ handleBlur,
18190
+ handleFocus,
18191
+ focus,
18192
+ blur,
18193
+ select,
18433
18194
  get cn() {
18434
18195
  return cn$2;
18435
18196
  },
@@ -18471,25 +18232,34 @@ const _hoisted_4 = {
18471
18232
  };
18472
18233
  const _hoisted_5 = [
18473
18234
  "value",
18235
+ "name",
18474
18236
  "disabled",
18475
18237
  "placeholder",
18476
18238
  "aria-label",
18477
18239
  "aria-invalid",
18478
18240
  "aria-describedby",
18241
+ "aria-errormessage",
18242
+ "aria-busy",
18479
18243
  "data-has-dial"
18480
18244
  ];
18481
18245
  const _hoisted_6 = {
18246
+ key: 0,
18247
+ class: "a-tel-input__validating",
18248
+ "data-slot": "tel-input-validating",
18249
+ "aria-hidden": "true"
18250
+ };
18251
+ const _hoisted_7 = {
18482
18252
  key: 0,
18483
18253
  class: "a-tel-input__detecting",
18484
18254
  "aria-hidden": "true",
18485
18255
  "data-slot": "tel-input-detecting"
18486
18256
  };
18487
- const _hoisted_7 = {
18257
+ const _hoisted_8 = {
18488
18258
  key: 0,
18489
18259
  class: "a-tel-input__country-wrapper",
18490
18260
  "data-slot": "tel-input-country-wrapper"
18491
18261
  };
18492
- const _hoisted_8 = ["id"];
18262
+ const _hoisted_9 = ["id"];
18493
18263
  function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
18494
18264
  return openBlock(), createElementBlock("div", {
18495
18265
  class: normalizeClass($setup.cn("a-tel-input", _ctx.$attrs.class)),
@@ -18507,31 +18277,41 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
18507
18277
  renderSlot(_ctx.$slots, "prefix", {}, void 0, true),
18508
18278
  $setup.selectedDialCode ? (openBlock(), createElementBlock("span", _hoisted_4, toDisplayString($setup.selectedDialCode), 1)) : createCommentVNode("v-if", true),
18509
18279
  createElementVNode("input", {
18280
+ ref: "inputRef",
18510
18281
  value: $setup.displayValue,
18511
18282
  type: "tel",
18512
18283
  inputmode: "numeric",
18513
18284
  autocomplete: "tel",
18514
18285
  dir: "ltr",
18515
18286
  "data-slot": "tel-input-field",
18287
+ name: $setup.props.name,
18516
18288
  disabled: $setup.props.disabled || $setup.props.loading,
18517
18289
  placeholder: $setup.effectivePlaceholder,
18518
18290
  "aria-label": $setup.messages.phoneInputLabel,
18519
18291
  "aria-invalid": $setup.visibleValidationState === "error" || void 0,
18520
18292
  "aria-describedby": $setup.describedBy,
18293
+ "aria-errormessage": $setup.visibleValidationState === "error" ? $setup.helperId : void 0,
18294
+ "aria-busy": $setup.props.validating || void 0,
18521
18295
  class: normalizeClass($setup.cn("a-tel-input__input", $setup.props.inputClass)),
18522
18296
  "data-has-dial": $setup.selectedDialCode ? "" : void 0,
18523
18297
  onInput: $setup.handlePhoneInput,
18524
- onChange: $setup.handlePhoneChange
18298
+ onChange: $setup.handlePhoneChange,
18299
+ onBlur: $setup.handleBlur,
18300
+ onFocus: $setup.handleFocus
18525
18301
  }, null, 42, _hoisted_5),
18302
+ createCommentVNode(" Async-validation spinner (e.g. server-side \"phone exists?\" check). Independent\n of `isDetecting` (which is for country detection) so both can be shown without\n interfering. Lives next to the input and never disables it. "),
18303
+ createVNode(Transition, { name: "a-tell-detect" }, {
18304
+ default: withCtx(() => [$setup.props.validating ? (openBlock(), createElementBlock("div", _hoisted_6, [renderSlot(_ctx.$slots, "validating", {}, () => [createVNode($setup["SpinnerIcon"], { class: "a-tel-input__detecting-icon" })], true)])) : createCommentVNode("v-if", true)]),
18305
+ _: 3
18306
+ }),
18526
18307
  createCommentVNode(" Detection-in-flight spinner — shown only during the first debounce window,\n before the picker has appeared. Once the picker is visible (success OR a failed\n attempt that revealed the empty picker) we stop re-flashing on every keystroke. "),
18527
18308
  createVNode(Transition, { name: "a-tell-detect" }, {
18528
- default: withCtx(() => [$setup.isDetecting && !$setup.selectedIso2 && !$setup.detectionAttempted ? (openBlock(), createElementBlock("div", _hoisted_6, [renderSlot(_ctx.$slots, "detecting", {}, () => [createVNode($setup["SpinnerIcon"], { class: "a-tel-input__detecting-icon" })], true)])) : createCommentVNode("v-if", true)]),
18309
+ default: withCtx(() => [$setup.isDetecting && !$setup.selectedIso2 && !$setup.detectionAttempted ? (openBlock(), createElementBlock("div", _hoisted_7, [renderSlot(_ctx.$slots, "detecting", {}, () => [createVNode($setup["SpinnerIcon"], { class: "a-tel-input__detecting-icon" })], true)])) : createCommentVNode("v-if", true)]),
18529
18310
  _: 3
18530
18311
  }),
18531
18312
  createVNode(Transition, { name: "a-tell-country" }, {
18532
- default: withCtx(() => [!$setup.props.detectFromInput || $setup.selectedIso2 || $setup.detectionAttempted ? (openBlock(), createElementBlock("div", _hoisted_7, [createVNode($setup["ACountrySelect"], {
18313
+ default: withCtx(() => [!$setup.props.detectFromInput || $setup.selectedIso2 || $setup.detectionAttempted ? (openBlock(), createElementBlock("div", _hoisted_8, [createVNode($setup["ACountrySelect"], {
18533
18314
  selected: $setup.selectedIso2,
18534
- "onUpdate:selected": _cache[0] || (_cache[0] = ($event) => $setup.selectedIso2 = $event),
18535
18315
  "allowed-dial-codes": $setup.props.allowedDialCodes,
18536
18316
  disabled: $setup.props.disabled || $setup.props.loading,
18537
18317
  size: $setup.props.size,
@@ -18542,6 +18322,7 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
18542
18322
  "suggested-label": $setup.messages.suggestedLabel,
18543
18323
  "all-countries-label": $setup.messages.allCountriesLabel,
18544
18324
  "country-label": $setup.messages.countryLabel,
18325
+ "onUpdate:selected": $setup.onPickerPick,
18545
18326
  "select-country-label": $setup.messages.selectCountryLabel,
18546
18327
  "flag-url": $setup.props.flagUrl,
18547
18328
  searcher: $setup.props.searcher,
@@ -18648,7 +18429,7 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
18648
18429
  }, () => [createElementVNode("p", {
18649
18430
  "data-slot": "tel-input-hint",
18650
18431
  class: normalizeClass($setup.cn("a-tel-input__hint", $setup.props.hintClass))
18651
- }, toDisplayString($setup.required.format_hint), 3)], true) : createCommentVNode("v-if", true)], 8, _hoisted_8)
18432
+ }, toDisplayString($setup.required.format_hint), 3)], true) : createCommentVNode("v-if", true)], 8, _hoisted_9)
18652
18433
  ], 10, _hoisted_1);
18653
18434
  }
18654
18435
  var ATelInput_default = /* @__PURE__ */ export_helper_default(_sfc_main, [
@@ -18657,6 +18438,6 @@ var ATelInput_default = /* @__PURE__ */ export_helper_default(_sfc_main, [
18657
18438
  ["__file", "/Users/alikhalill/Desktop/my-projects/ali-nuxt-toolkit/packages/ui-components/ATelInput/src/components/ATelInput.vue"]
18658
18439
  ]);
18659
18440
  //#endregion
18660
- export { ACountryFlag_default as ACountryFlag, ACountrySelect_default as ACountrySelect, ATelInput_default as ATelInput, COUNTRY_RECENTS_KEY, DEFAULT_ERROR_MESSAGES, DEFAULT_MESSAGES, DIAL_TO_ISO2_FALLBACK, LOCALE_DIGIT_RANGES, aTelInputVariants, defaultFlagUrl, detectCountry, localizeCountries, normalizeDigits, resolveMessages, useCountryDetection, useCountryMatching, usePhoneValidation, useTelInputValidation, useTypingPhase };
18441
+ export { ACountryFlag_default as ACountryFlag, ACountrySelect_default as ACountrySelect, ATelInput_default as ATelInput, COUNTRY_RECENTS_KEY, DEFAULT_ERROR_MESSAGES, DEFAULT_MESSAGES, DIAL_TO_ISO2_FALLBACK, FALLBACK_ISO2_LIST, LOCALE_DIGIT_RANGES, aTelInputVariants, defaultFlagUrl, detectCountry, localizeCountries, normalizeDigits, resolveMessages, useCountryDetection, useCountryMatching, useCountrySelection, usePhoneValidation, useSyncedModel, useTelInputValidation, useTypingPhase };
18661
18442
 
18662
18443
  //# sourceMappingURL=index.js.map