@alikhalilll/a-tel-input 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +585 -72
- package/dist/_chunks/types.d.ts +661 -0
- package/dist/_chunks/types.js +52 -0
- package/dist/_chunks/types.js.map +1 -0
- package/dist/_chunks/usePhoneValidation.js +539 -0
- package/dist/_chunks/usePhoneValidation.js.map +1 -0
- package/dist/index.cjs +444 -683
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -587
- package/dist/index.d.ts +122 -587
- package/dist/index.js +427 -646
- package/dist/index.js.map +1 -1
- package/dist/styles.css +3 -2
- package/dist/vee-validate/index.cjs +113 -0
- package/dist/vee-validate/index.cjs.map +1 -0
- package/dist/vee-validate/index.d.cts +86 -0
- package/dist/vee-validate/index.d.ts +86 -0
- package/dist/vee-validate/index.js +112 -0
- package/dist/vee-validate/index.js.map +1 -0
- package/dist/zod/index.cjs +211 -0
- package/dist/zod/index.cjs.map +1 -0
- package/dist/zod/index.d.cts +65 -0
- package/dist/zod/index.d.ts +65 -0
- package/dist/zod/index.js +208 -0
- package/dist/zod/index.js.map +1 -0
- package/package.json +33 -3
- package/src/components/ATelInput.vue +206 -66
- package/src/composables/useCountryDetection.ts +28 -11
- package/src/composables/useCountryMatching.ts +160 -20
- package/src/composables/useCountrySelection.ts +71 -0
- package/src/composables/usePhoneValidation.ts +81 -18
- package/src/composables/useSyncedModel.ts +80 -0
- package/src/composables/useTelInputValidation.ts +50 -11
- package/src/index.ts +2 -0
- package/src/types.ts +80 -0
- package/src/vee-validate/index.ts +2 -0
- package/src/vee-validate/useTelField.ts +202 -0
- package/src/zod/index.ts +259 -0
- package/web-types.json +44 -1
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
//#region src/types.ts
|
|
3
|
+
const aTelInputVariants = cva("a-tel-input__field", {
|
|
4
|
+
variants: { size: {
|
|
5
|
+
xs: "",
|
|
6
|
+
sm: "",
|
|
7
|
+
md: "",
|
|
8
|
+
lg: "",
|
|
9
|
+
xl: ""
|
|
10
|
+
} },
|
|
11
|
+
defaultVariants: { size: "md" }
|
|
12
|
+
});
|
|
13
|
+
const DEFAULT_ERROR_MESSAGES = {
|
|
14
|
+
missing_country: "Please select a country.",
|
|
15
|
+
country_not_supported: "This country is not supported.",
|
|
16
|
+
phone_has_non_digits: "Phone number can only contain digits.",
|
|
17
|
+
too_short: "Phone number is too short.",
|
|
18
|
+
too_long: "Phone number is too long.",
|
|
19
|
+
invalid_phone: "Phone number is invalid.",
|
|
20
|
+
parse_failed: "Could not parse phone number."
|
|
21
|
+
};
|
|
22
|
+
/** English defaults for every {@link TelInputMessages} key. */
|
|
23
|
+
const DEFAULT_MESSAGES = {
|
|
24
|
+
searchPlaceholder: "Search country or +code…",
|
|
25
|
+
emptyText: "No countries found.",
|
|
26
|
+
loadingText: "Loading countries…",
|
|
27
|
+
suggestedLabel: "Suggested",
|
|
28
|
+
allCountriesLabel: "All countries",
|
|
29
|
+
errorMessages: DEFAULT_ERROR_MESSAGES,
|
|
30
|
+
countryLabel: "Country",
|
|
31
|
+
selectCountryLabel: "Select country",
|
|
32
|
+
phoneInputLabel: "Phone number"
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Merge a partial `messages` override onto the English defaults. Used internally by
|
|
36
|
+
* `ATelInput` to resolve a complete {@link TelInputMessages} object.
|
|
37
|
+
*/
|
|
38
|
+
function resolveMessages(input) {
|
|
39
|
+
if (!input) return DEFAULT_MESSAGES;
|
|
40
|
+
return {
|
|
41
|
+
...DEFAULT_MESSAGES,
|
|
42
|
+
...input,
|
|
43
|
+
errorMessages: {
|
|
44
|
+
...DEFAULT_ERROR_MESSAGES,
|
|
45
|
+
...input.errorMessages
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { resolveMessages as i, DEFAULT_MESSAGES as n, aTelInputVariants as r, DEFAULT_ERROR_MESSAGES as t };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","names":[],"sources":["../../src/types.ts"],"sourcesContent":["import type { HTMLAttributes } from 'vue';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport type { DetectionStrategy, DetectCountryOptions } from './composables/useCountryDetection';\nimport type {\n CountryOption,\n PhoneValidationReason,\n PhoneValidationResult,\n} from './composables/usePhoneValidation';\nimport type { ATelInputValidateOn } from './composables/useTelInputValidation';\nimport type { FlagUrlBuilder } from './utils/flag-url';\nimport type { Size } from '@alikhalilll/a-ui-base';\n\nexport type { ATelInputValidateOn } from './composables/useTelInputValidation';\n\n/** Alias for the shared `Size` scale — kept for backwards-friendly naming. */\nexport type ATelInputSize = Size;\n\n// Field styling now lives entirely in ATelInput.vue's scoped CSS — sizes/states are driven\n// by the `data-size` / `data-state` attributes set in the template. The cva wrapper survives\n// so consumers can still call `aTelInputVariants({ size: 'md' })` (returns the field class);\n// the per-size class slots are empty placeholders that exist only to preserve the type.\nexport const aTelInputVariants = cva('a-tel-input__field', {\n variants: {\n size: { xs: '', sm: '', md: '', lg: '', xl: '' },\n },\n defaultVariants: { size: 'md' },\n});\n\nexport type ATelInputVariants = VariantProps<typeof aTelInputVariants>;\n\n/** Text direction for the field. `'auto'` (or omitting the prop) inherits from the\n * nearest `[dir]` ancestor / `<html dir>`; `'ltr'` / `'rtl'` force it. */\nexport type ATelInputDir = 'ltr' | 'rtl' | 'auto';\n\n/**\n * Every user-facing string in the tel-input UI, bundled so a consumer can localize the\n * component in one prop. Each key has an English default in {@link DEFAULT_MESSAGES}.\n */\nexport interface TelInputMessages {\n /** Placeholder of the country-picker search box. */\n searchPlaceholder: string;\n /** Shown when a search yields no countries. */\n emptyText: string;\n /** Shown while the country list is loading. */\n loadingText: string;\n /** Header of the \"Suggested\" group (current + recent picks). */\n suggestedLabel: string;\n /** Header of the full country list. */\n allCountriesLabel: string;\n /** Validation error text, keyed by reason. */\n errorMessages: Record<PhoneValidationReason, string>;\n /** Prefix of the country trigger's `aria-label`, e.g. `\"Country: Egypt\"`. */\n countryLabel: string;\n /** `aria-label` of the country trigger when no country is selected. */\n selectCountryLabel: string;\n /** `aria-label` of the phone input element. */\n phoneInputLabel: string;\n}\n\n/** Partial override shape for the `messages` prop — every key (and every error reason) is optional. */\nexport type TelInputMessagesInput = Partial<Omit<TelInputMessages, 'errorMessages'>> & {\n errorMessages?: Partial<Record<PhoneValidationReason, string>>;\n};\n\nexport interface ATelInputProps {\n class?: HTMLAttributes['class'];\n /**\n * Default `v-model` — the canonical **E.164** string (e.g. `'+201066105963'`).\n *\n * Reads + writes the full international number as a single value. Designed to drop\n * straight into VeeValidate's `<Field v-slot=\"{ field }\">` pattern (use\n * `v-bind=\"field\"`), native `<form>` submission, or any `v-model=\"phoneE164\"` consumer.\n *\n * Stays in sync with the split `v-model:phone` + `v-model:country` contract — use\n * either, or both.\n */\n modelValue?: string;\n /**\n * Forwarded to the inner `<input name=\"\">`. Set this when participating in a native\n * `<form>` submission, or when a form library (VeeValidate, etc.) wants a stable name.\n */\n name?: string;\n /**\n * Externally controlled error message. When set to a non-empty string the component\n * is forced into the error visual state and renders this message via the `#error`\n * slot — overriding internal libphonenumber validation. Wire this from VeeValidate,\n * Zod, an async server check (\"this phone is already registered\"), or any custom\n * validation layer.\n *\n * Pass `null` / `undefined` / `''` to defer to internal validation.\n */\n error?: string | null;\n /**\n * `true` while an async validation is in flight (e.g. a server-side uniqueness\n * check). Renders a small spinner inside the field and sets `aria-busy=\"true\"` on\n * the input. Does **not** disable the field — use `loading` for that. Replace the\n * spinner via the `#validating` slot.\n *\n * Designed to be bound to the `validating` ref returned by `useTelField()`.\n */\n validating?: boolean;\n /**\n * When to surface validation in the UI.\n * - `'change'` (default) — visible state mirrors the typing-paused state.\n * - `'blur'` — stays idle until the input has been blurred once (form-library friendly).\n * - `'eager'` — mirror raw validation immediately, no typing pause.\n */\n validateOn?: ATelInputValidateOn;\n placeholder?: string;\n disabled?: boolean;\n loading?: boolean;\n size?: ATelInputSize;\n /**\n * Text direction. Omit (or pass `'auto'`) to inherit from the page — RTL pages get an\n * RTL field automatically. Pass `'ltr'` / `'rtl'` to force it.\n */\n dir?: ATelInputDir;\n /**\n * BCP-47 locale (e.g. `'ar'`, `'fr'`). When set, country names render localized via\n * `Intl.DisplayNames` and the format hint uses the locale's numerals.\n */\n locale?: string;\n /**\n * Localized UI strings. A single bag covering the picker, validation errors, and a11y\n * labels. Individual props (`searchPlaceholder`, `emptyText`, `loadingText`,\n * `errorMessages`) take precedence over the matching `messages` key when both are set.\n */\n messages?: TelInputMessagesInput;\n /**\n * Whitelist of allowed dial-digit codes (no `+`), e.g. `['20', '966']`.\n * Countries outside this list are still shown in the picker but rendered as disabled.\n */\n allowedDialCodes?: string[];\n /** Light up the field's validation styling — coloured border + ring on the input and the\n * error message line below — when the number is valid / invalid. Default `false`, so the\n * field stays neutral and validation surfacing is left to the consumer (via the\n * `validation` ref exposure). */\n showValidation?: boolean;\n /** Show the green check / red alert icon at the end of the field. Default `false`; opt\n * in with `true`. Independent of `showValidation` — you can show the icon without the\n * coloured field, or vice versa. The slots `#valid-icon` / `#error-icon` still apply. */\n showValidationIcon?: boolean;\n /**\n * Country auto-detect strategy. Defaults to `'auto'` — try IP geolocation first, then\n * timezone, then `navigator.language`, finally `defaultCountry`.\n */\n detectCountry?: DetectionStrategy;\n /**\n * Initial country. Accepts either an ISO2 code (`'EG'`) or a dial-digit string\n * (`'20'`, `'+20'`). When set, the picker is visible at mount with this country\n * pre-selected — overrides the hidden-until-detected default.\n */\n defaultCountry?: string;\n /** Override the IP geolocation endpoint. Must return JSON with `country_code` or `country`. */\n ipEndpoint?: string;\n /** Localized strings for the country picker UI. */\n searchPlaceholder?: string;\n emptyText?: string;\n loadingText?: string;\n /** Error labels keyed by reason. Each gets a sensible English default. */\n errorMessages?: Partial<Record<PhoneValidationReason, string>>;\n /**\n * When true, the country picker is hidden until a leading dial code is detected in the\n * phone input. Every keystroke runs a longest-prefix match against known dial codes; on\n * first match the picker reveals with that country and the matched dial digits are\n * stripped from `phone`. Skips the onMount IP/timezone/locale detection chain.\n */\n detectFromInput?: boolean;\n /**\n * Debounce window (ms) for `detectFromInput` detection. Each keystroke schedules the\n * libphonenumber parse + lookup; bursts of typing/paste collapse into a single call.\n * Clearing the input is not debounced — the picker hides immediately. Default 150ms.\n */\n detectDebounceMs?: number;\n /** Override the flag URL builder, forwarded to ACountrySelect. */\n flagUrl?: (iso2: string, width: number) => string;\n /** Custom search predicate, forwarded to ACountrySelect. */\n searcher?: (query: string, country: CountryOption) => boolean;\n /** Provide your own country list, forwarded to ACountrySelect. */\n countries?: CountryOption[];\n /**\n * Fully custom country detection. When provided, this function runs in place of the\n * built-in chain — `detectCountry`-style options are still honored but the function\n * receives them and is free to ignore them.\n */\n detector?: (options: DetectCountryOptions) => Promise<string | null | undefined>;\n /** Forwarded to ACountrySelect: classes for the popover content surface. */\n contentClass?: string;\n /** Forwarded to ACountrySelect: classes for the desktop popover surface. */\n popoverClass?: string;\n /** Forwarded to ACountrySelect: classes for the mobile drawer surface. */\n drawerClass?: string;\n /** Classes for the inner phone field input element. */\n inputClass?: string;\n /** Classes for the outer wrapper that holds country select + input. */\n fieldClass?: string;\n /** Classes for the helper hint line. */\n hintClass?: string;\n /** Classes for the error message line. */\n errorClass?: string;\n /**\n * How page scroll is blocked while the country popover is open. Defaults to `'events'`\n * (sticky-safe document-level lock). Pass `'body'` for the legacy\n * `body { overflow: hidden }` lock, or `'none'` to leave page scrolling alone.\n */\n scrollLock?: 'events' | 'body' | 'none';\n}\n\n/**\n * Props for {@link ACountryFlag} — the standalone flag image component. Renders a\n * `flagcdn` image for an ISO2 code with an automatic text-badge fallback when the\n * image fails to load. Surface separately so it can be used outside `ATelInput`\n * (e.g., in a custom country picker).\n */\nexport interface ACountryFlagProps {\n /** ISO 3166-1 alpha-2 country code, case-insensitive. */\n iso2: string;\n /** Pixel width served by flagcdn. 40 is crisp at retina up to ~24px wide. */\n width?: number;\n /** Optional explicit URL override. When set, `iso2` / `width` / `flagUrl` are ignored. */\n src?: string | null;\n /** Function `(iso2, width) => string` — fully replace the URL builder. */\n flagUrl?: FlagUrlBuilder;\n alt?: string;\n class?: HTMLAttributes['class'];\n}\n\n/**\n * Slot prop shape for {@link ACountryFlag}. The `empty` slot is rendered when no\n * flag URL is available and no ISO2 fallback can be derived.\n */\nexport interface ACountryFlagSlots {\n /** Rendered when the flag URL is unavailable and no ISO2 text fallback can be derived. */\n empty?: () => unknown;\n}\n\nexport const DEFAULT_ERROR_MESSAGES: Record<PhoneValidationReason, string> = {\n missing_country: 'Please select a country.',\n country_not_supported: 'This country is not supported.',\n phone_has_non_digits: 'Phone number can only contain digits.',\n too_short: 'Phone number is too short.',\n too_long: 'Phone number is too long.',\n invalid_phone: 'Phone number is invalid.',\n parse_failed: 'Could not parse phone number.',\n};\n\n/** English defaults for every {@link TelInputMessages} key. */\nexport const DEFAULT_MESSAGES: TelInputMessages = {\n searchPlaceholder: 'Search country or +code…',\n emptyText: 'No countries found.',\n loadingText: 'Loading countries…',\n suggestedLabel: 'Suggested',\n allCountriesLabel: 'All countries',\n errorMessages: DEFAULT_ERROR_MESSAGES,\n countryLabel: 'Country',\n selectCountryLabel: 'Select country',\n phoneInputLabel: 'Phone number',\n};\n\n/**\n * Merge a partial `messages` override onto the English defaults. Used internally by\n * `ATelInput` to resolve a complete {@link TelInputMessages} object.\n */\nexport function resolveMessages(input?: TelInputMessagesInput): TelInputMessages {\n if (!input) return DEFAULT_MESSAGES;\n return {\n ...DEFAULT_MESSAGES,\n ...input,\n errorMessages: { ...DEFAULT_ERROR_MESSAGES, ...input.errorMessages },\n };\n}\n\n/**\n * Slot prop shape for {@link ATelInput}. Use to get full slot-prop type inference\n * when overriding slots in a consumer template:\n *\n * <ATelInput #suffix=\"{ validationState }\">…</ATelInput>\n * ↑ inferred as `'idle' | 'valid' | 'error'`\n *\n * Or in TypeScript code:\n * type SuffixProps = Parameters<NonNullable<ATelInputSlots['suffix']>>[0];\n */\nexport interface ATelInputSlots {\n /** Content before the country select trigger (e.g. an icon). */\n prefix?: () => unknown;\n /** Content between the input and the validation icons. */\n suffix?: (props: {\n validationState: 'idle' | 'valid' | 'error';\n validation: PhoneValidationResult;\n }) => unknown;\n /** Replace the green check shown when the number validates. */\n 'valid-icon'?: () => unknown;\n /** Replace the warning icon shown when the number fails validation. */\n 'error-icon'?: (props: { reason: string }) => unknown;\n /** Replace the dim helper line shown below the input when empty. */\n hint?: (props: { country: string; formatHint: string; example: string | null }) => unknown;\n /** Replace the error message rendered when invalid. */\n error?: (props: {\n message: string;\n reason: string;\n validation: PhoneValidationResult;\n }) => unknown;\n /** Forwarded to ACountrySelect — replace the trigger button. */\n trigger?: (props: {\n selectedCountry: CountryOption | null;\n open: boolean;\n sizeClasses: string;\n }) => unknown;\n /** Forwarded to ACountrySelect — replace the chevron. */\n chevron?: (props: { open: boolean }) => unknown;\n /** Forwarded — replace any flag rendering. */\n flag?: (props: { country: CountryOption; context: 'trigger' | 'item' }) => unknown;\n /** Forwarded — replace each country list row. */\n item?: (props: {\n country: CountryOption;\n selected: boolean;\n disabled: boolean;\n select: () => void;\n }) => unknown;\n /** Forwarded — section header. */\n 'group-header'?: (props: { label: string; group: 'suggested' | 'all' }) => unknown;\n /** Forwarded — search bar. */\n search?: (props: {\n value: string;\n setValue: (v: string) => void;\n isSearching: boolean;\n }) => unknown;\n loading?: () => unknown;\n empty?: (props: { query: string }) => unknown;\n /** Replace the spinner shown in the picker slot during the debounce window. */\n detecting?: () => unknown;\n /** Replace the spinner shown inside the field while async validation is in flight. */\n validating?: () => unknown;\n}\n\n/**\n * Emit map for {@link ATelInput}. `update:phone` carries the digits-only string,\n * `update:country` carries the dial-number (not ISO2). Surface for consumers who\n * wire the events manually instead of via `v-model:phone` / `v-model:country`.\n *\n * `blur` / `focus` mirror the inner input's native events — useful for form\n * libraries (VeeValidate's `handleBlur`, etc.).\n */\nexport type ATelInputEmits = {\n 'update:modelValue': [value: string];\n 'update:phone': [value: string];\n 'update:country': [value: number | null];\n blur: [event: FocusEvent];\n focus: [event: FocusEvent];\n};\n\n/**\n * Imperative API exposed by {@link ATelInput} via `defineExpose`. Grab it with\n * `ref=\"tellRef\"` + `tellRef.value?.focus()` — useful for form libraries that want\n * to focus the offending field after a failed submit.\n */\nexport interface ATelInputExpose {\n /** Full {@link PhoneValidationResult} for the current input. */\n validation: import('vue').ComputedRef<PhoneValidationResult>;\n /** Format hint + example for the currently selected country, or `null`. */\n required: import('vue').ComputedRef<unknown>;\n /** Selected country's dial code as a `+`-prefixed string (e.g. `+20`), or `null`. */\n selectedDialCode: import('vue').ComputedRef<string | null>;\n /** Raw validation state — not gated by typing pause / blur / `showValidation`. */\n validationState: import('vue').ComputedRef<'idle' | 'valid' | 'error'>;\n /** Surfacing-gated validation state — the one the UI actually displays. */\n visibleValidationState: import('vue').ComputedRef<'idle' | 'valid' | 'error'>;\n isDetecting: Readonly<import('vue').Ref<boolean>>;\n hasFinishedTyping: Readonly<import('vue').Ref<boolean>>;\n detectionAttempted: Readonly<import('vue').Ref<boolean>>;\n /** Programmatically focus the inner `<input>`. */\n focus(options?: FocusOptions): void;\n /** Programmatically blur the inner `<input>`. */\n blur(): void;\n /** Programmatically select the inner `<input>`'s text. */\n select(): void;\n}\n\n/**\n * Props for {@link ACountrySelect} — the standalone country picker. Surface\n * separately so it can be used outside `ATelInput` with full type support.\n */\nexport interface ACountrySelectProps {\n class?: HTMLAttributes['class'];\n triggerClass?: HTMLAttributes['class'];\n contentClass?: HTMLAttributes['class'];\n popoverClass?: HTMLAttributes['class'];\n drawerClass?: HTMLAttributes['class'];\n searchPlaceholder?: string;\n emptyText?: string;\n loadingText?: string;\n suggestedLabel?: string;\n allCountriesLabel?: string;\n /** ISO2 codes that are selectable. Others are listed but disabled. */\n allowedDialCodes?: string[];\n disabled?: boolean;\n /** Drives the trigger button padding + text size. Matches ATelInput's `size`. */\n size?: ATelInputSize;\n /** Max items rendered under the \"Suggested\" header (current + recents, deduped). */\n suggestedLimit?: number;\n /** Cap the number of matching countries shown in search results. */\n maxResults?: number;\n /** Override the flag URL builder, e.g. `(iso, w) => `/flags/${iso}.svg``. */\n flagUrl?: (iso2: string, width: number) => string;\n /** Custom search predicate. Default: substring match on the precomputed `search_key`. */\n searcher?: (query: string, country: CountryOption) => boolean;\n /** Provide your own country list (bypasses the REST Countries fetch). */\n countries?: CountryOption[];\n /** Override the right-side kbd hints. Pass `null` to hide. */\n kbdOpen?: string | null;\n kbdClose?: string | null;\n /** BCP-47 locale — country names render localized via `Intl.DisplayNames`. */\n locale?: string;\n /** Prefix of the trigger's `aria-label` when a country is selected, e.g. `\"Country\"`. */\n countryLabel?: string;\n /** Trigger's `aria-label` when no country is selected. */\n selectCountryLabel?: string;\n /** How page scroll is blocked while the popover is open. Default `'events'`. */\n scrollLock?: 'events' | 'body' | 'none';\n}\n\n/**\n * Slot prop shape for {@link ACountrySelect}. Forwarded versions of these slots\n * also appear on {@link ATelInputSlots} (`trigger`, `chevron`, `flag`, `item`,\n * etc.) — keep them in sync when changing one.\n */\nexport interface ACountrySelectSlots {\n /** Replace the entire country picker trigger button. */\n trigger?: (props: {\n selectedCountry: CountryOption | null;\n open: boolean;\n sizeClasses: string;\n }) => unknown;\n /** Replace the chevron icon. */\n chevron?: (props: { open: boolean }) => unknown;\n /** Replace just the flag rendered in the trigger and items. */\n flag?: (props: { country: CountryOption; context: 'trigger' | 'item' }) => unknown;\n /** Replace the entire search bar (input + icon + kbd). */\n search?: (props: {\n value: string;\n setValue: (v: string) => void;\n isSearching: boolean;\n }) => unknown;\n /** Replace the search-bar leading icon. */\n 'search-icon'?: () => unknown;\n /** Replace the loading state. */\n loading?: () => unknown;\n /** Replace the empty/no-results state. */\n empty?: (props: { query: string }) => unknown;\n /** Replace a section header. */\n 'group-header'?: (props: { label: string; group: 'suggested' | 'all' }) => unknown;\n /** Replace each country list row. */\n item?: (props: {\n country: CountryOption;\n selected: boolean;\n disabled: boolean;\n select: () => void;\n }) => unknown;\n /** Replace just the right-side check icon for the selected row. */\n 'item-check'?: (props: { country: CountryOption }) => unknown;\n}\n\n/**\n * Emit map for {@link ACountrySelect}. The `selected` is `v-model:selected`,\n * carrying the ISO2 code of the picked country.\n */\nexport type ACountrySelectEmits = {\n 'update:selected': [value: string];\n};\n"],"mappings":";;AAqBA,MAAa,oBAAoB,IAAI,sBAAsB;CACzD,UAAU,EACR,MAAM;EAAE,IAAI;EAAI,IAAI;EAAI,IAAI;EAAI,IAAI;EAAI,IAAI;CAAG,EACjD;CACA,iBAAiB,EAAE,MAAM,KAAK;AAChC,CAAC;AAkND,MAAa,yBAAgE;CAC3E,iBAAiB;CACjB,uBAAuB;CACvB,sBAAsB;CACtB,WAAW;CACX,UAAU;CACV,eAAe;CACf,cAAc;AAChB;;AAGA,MAAa,mBAAqC;CAChD,mBAAmB;CACnB,WAAW;CACX,aAAa;CACb,gBAAgB;CAChB,mBAAmB;CACnB,eAAe;CACf,cAAc;CACd,oBAAoB;CACpB,iBAAiB;AACnB;;;;;AAMA,SAAgB,gBAAgB,OAAiD;CAC/E,IAAI,CAAC,OAAO,OAAO;CACnB,OAAO;EACL,GAAG;EACH,GAAG;EACH,eAAe;GAAE,GAAG;GAAwB,GAAG,MAAM;EAAc;CACrE;AACF"}
|
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { getExampleNumber, isValidPhoneNumber, parsePhoneNumberFromString } from "libphonenumber-js";
|
|
3
|
+
import examples from "libphonenumber-js/examples.mobile.json";
|
|
4
|
+
//#region src/utils/digits.ts
|
|
5
|
+
/**
|
|
6
|
+
* Alternative-numeral support. Phone numbers are routinely entered with the digits of the
|
|
7
|
+
* user's own script — Arabic-Indic, Persian/Urdu, Devanagari, Bengali. `libphonenumber-js`
|
|
8
|
+
* and our own `\d` cleanup only understand ASCII `0-9`, so anything else silently becomes
|
|
9
|
+
* an empty number. `normalizeDigits` folds those scripts down to ASCII before validation.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Base code points of contiguous decimal-digit blocks. Each block runs `base`‥`base+9`
|
|
13
|
+
* for digit `0`‥`9`, so the ASCII digit is `codePoint - base`. Add a script by appending
|
|
14
|
+
* one entry here.
|
|
15
|
+
*/
|
|
16
|
+
const LOCALE_DIGIT_RANGES = [
|
|
17
|
+
{
|
|
18
|
+
name: "arabic-indic",
|
|
19
|
+
base: 1632
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "extended-arabic",
|
|
23
|
+
base: 1776
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "devanagari",
|
|
27
|
+
base: 2406
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "bengali",
|
|
31
|
+
base: 2534
|
|
32
|
+
}
|
|
33
|
+
];
|
|
34
|
+
/** Lookup of every non-ASCII digit code point → its ASCII character. */
|
|
35
|
+
const DIGIT_MAP = (() => {
|
|
36
|
+
const map = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const { base } of LOCALE_DIGIT_RANGES) for (let d = 0; d <= 9; d++) map.set(base + d, String(d));
|
|
38
|
+
return map;
|
|
39
|
+
})();
|
|
40
|
+
/**
|
|
41
|
+
* Replace any supported non-ASCII decimal digit with its ASCII equivalent. Every other
|
|
42
|
+
* character (spaces, `+`, separators, letters) is left untouched — callers still run their
|
|
43
|
+
* own `\D` cleanup afterwards.
|
|
44
|
+
*/
|
|
45
|
+
function normalizeDigits(input) {
|
|
46
|
+
const str = String(input ?? "");
|
|
47
|
+
let out = "";
|
|
48
|
+
for (const ch of str) {
|
|
49
|
+
const cp = ch.codePointAt(0);
|
|
50
|
+
out += cp != null && DIGIT_MAP.get(cp) || ch;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/composables/usePhoneValidation.ts
|
|
56
|
+
/**
|
|
57
|
+
* Country list + phone validation, framework-agnostic.
|
|
58
|
+
*
|
|
59
|
+
* Ported from the reference @pkgs/ui ATelInput composable with these cleanups:
|
|
60
|
+
* - Drop Nuxt-only `process.client` checks → use plain `typeof window !== 'undefined'`.
|
|
61
|
+
* - Drop Arabic default placeholder; let consumers pass their own.
|
|
62
|
+
* - Expand the offline fallback list from 2 → ~20 of the most-populated countries.
|
|
63
|
+
* - Keep REST Countries fetch + localStorage cache + libphonenumber-js examples + fast `search_key`.
|
|
64
|
+
*/
|
|
65
|
+
const STORAGE_KEY = "ali_ui_phone_countries_v1";
|
|
66
|
+
const REST_COUNTRIES_URL = "https://restcountries.com/v3.1/all?fields=name,cca2,idd,flags";
|
|
67
|
+
let sharedCountries = null;
|
|
68
|
+
let inflightFetch = null;
|
|
69
|
+
const EX = examples;
|
|
70
|
+
const isBrowser = () => typeof window !== "undefined";
|
|
71
|
+
function toDigits(v) {
|
|
72
|
+
return normalizeDigits(String(v ?? "")).replace(/\D/g, "");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Render an ASCII digit string in a locale's numeral system (e.g. `'ar'` → `٠-٩`).
|
|
76
|
+
* Used only for display hints — falls back to ASCII if the locale is unknown.
|
|
77
|
+
*/
|
|
78
|
+
function localizeDigits(digits, locale) {
|
|
79
|
+
if (!locale) return digits;
|
|
80
|
+
try {
|
|
81
|
+
const fmt = new Intl.NumberFormat(locale, { useGrouping: false });
|
|
82
|
+
return digits.replace(/[0-9]/g, (d) => fmt.format(Number(d)));
|
|
83
|
+
} catch {
|
|
84
|
+
return digits;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function ensurePlusDial(dial) {
|
|
88
|
+
const d = toDigits(dial);
|
|
89
|
+
return d ? `+${d}` : "";
|
|
90
|
+
}
|
|
91
|
+
function normalizeIso2(iso2) {
|
|
92
|
+
return String(iso2 ?? "").trim().toUpperCase();
|
|
93
|
+
}
|
|
94
|
+
function dropLeadingZeros(digits) {
|
|
95
|
+
return String(digits ?? "").replace(/^0+/, "");
|
|
96
|
+
}
|
|
97
|
+
function buildFullE164(dial, digits) {
|
|
98
|
+
const dialClean = ensurePlusDial(dial);
|
|
99
|
+
const nsn = dropLeadingZeros(toDigits(digits));
|
|
100
|
+
return dialClean && nsn ? `${dialClean}${nsn}` : null;
|
|
101
|
+
}
|
|
102
|
+
function inferLengthFromExample(national) {
|
|
103
|
+
const d = toDigits(national);
|
|
104
|
+
if (!d) return {
|
|
105
|
+
min: null,
|
|
106
|
+
max: null
|
|
107
|
+
};
|
|
108
|
+
const n = d.length;
|
|
109
|
+
return {
|
|
110
|
+
min: Math.max(4, n - 2),
|
|
111
|
+
max: n + 2
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function buildDialCode(idd) {
|
|
115
|
+
const root = idd?.root?.trim();
|
|
116
|
+
if (!root || !root.startsWith("+")) return null;
|
|
117
|
+
const out = `${root}${idd?.suffixes?.[0]?.trim() ?? ""}`;
|
|
118
|
+
return out.startsWith("+") ? out : null;
|
|
119
|
+
}
|
|
120
|
+
function normalizeSearchKey(input) {
|
|
121
|
+
return String(input ?? "").toLowerCase().replace(/\s+/g, " ").trim().replace(/[^\p{L}\p{N}+ ]/gu, "");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Return a copy of the country list with display names localized to `locale` via
|
|
125
|
+
* `Intl.DisplayNames`. `search_key` is rebuilt (keeping the English name too) so search
|
|
126
|
+
* still matches either spelling. Unknown locales / regions fall back to the English name.
|
|
127
|
+
*/
|
|
128
|
+
function localizeCountries(list, locale) {
|
|
129
|
+
if (!locale) return list;
|
|
130
|
+
let display;
|
|
131
|
+
try {
|
|
132
|
+
display = new Intl.DisplayNames([locale], { type: "region" });
|
|
133
|
+
} catch {
|
|
134
|
+
return list;
|
|
135
|
+
}
|
|
136
|
+
return list.map((c) => {
|
|
137
|
+
let localized = c.raw_data.name;
|
|
138
|
+
try {
|
|
139
|
+
localized = display.of(c.raw_data.iso2) || c.raw_data.name;
|
|
140
|
+
} catch {}
|
|
141
|
+
if (localized === c.raw_data.name) return c;
|
|
142
|
+
const dial = c.raw_data.dial_code;
|
|
143
|
+
return {
|
|
144
|
+
...c,
|
|
145
|
+
label: `${localized} (${dial})`,
|
|
146
|
+
search_key: normalizeSearchKey(`${localized} ${c.raw_data.name} ${dial} ${c.raw_data.iso2} ${c.raw_data.dial_digits}`),
|
|
147
|
+
raw_data: {
|
|
148
|
+
...c.raw_data,
|
|
149
|
+
name: localized
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function makeFallback(iso2, name, dial) {
|
|
155
|
+
const dialDigits = toDigits(dial);
|
|
156
|
+
return {
|
|
157
|
+
label: `${name} (+${dialDigits})`,
|
|
158
|
+
value: iso2,
|
|
159
|
+
search_key: normalizeSearchKey(`${name} +${dialDigits} ${iso2}`),
|
|
160
|
+
raw_data: {
|
|
161
|
+
iso2,
|
|
162
|
+
dial_code: `+${dialDigits}`,
|
|
163
|
+
dial_digits: dialDigits,
|
|
164
|
+
name,
|
|
165
|
+
flag: `https://flagcdn.com/w40/${iso2.toLowerCase()}.png`,
|
|
166
|
+
source: "fallback",
|
|
167
|
+
original: {}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const FALLBACK_COUNTRIES = [
|
|
172
|
+
makeFallback("SA", "Saudi Arabia", "+966"),
|
|
173
|
+
makeFallback("EG", "Egypt", "+20"),
|
|
174
|
+
makeFallback("AE", "United Arab Emirates", "+971"),
|
|
175
|
+
makeFallback("US", "United States", "+1"),
|
|
176
|
+
makeFallback("GB", "United Kingdom", "+44"),
|
|
177
|
+
makeFallback("DE", "Germany", "+49"),
|
|
178
|
+
makeFallback("FR", "France", "+33"),
|
|
179
|
+
makeFallback("ES", "Spain", "+34"),
|
|
180
|
+
makeFallback("IT", "Italy", "+39"),
|
|
181
|
+
makeFallback("TR", "Turkey", "+90"),
|
|
182
|
+
makeFallback("RU", "Russia", "+7"),
|
|
183
|
+
makeFallback("CN", "China", "+86"),
|
|
184
|
+
makeFallback("IN", "India", "+91"),
|
|
185
|
+
makeFallback("JP", "Japan", "+81"),
|
|
186
|
+
makeFallback("KR", "South Korea", "+82"),
|
|
187
|
+
makeFallback("BR", "Brazil", "+55"),
|
|
188
|
+
makeFallback("MX", "Mexico", "+52"),
|
|
189
|
+
makeFallback("CA", "Canada", "+1"),
|
|
190
|
+
makeFallback("AU", "Australia", "+61"),
|
|
191
|
+
makeFallback("NG", "Nigeria", "+234"),
|
|
192
|
+
makeFallback("PK", "Pakistan", "+92"),
|
|
193
|
+
makeFallback("ID", "Indonesia", "+62")
|
|
194
|
+
];
|
|
195
|
+
function usePhoneValidation() {
|
|
196
|
+
const countries = ref([]);
|
|
197
|
+
const isCountriesLoading = ref(false);
|
|
198
|
+
function buildIndexes(list) {
|
|
199
|
+
const valueMap = /* @__PURE__ */ new Map();
|
|
200
|
+
const dialMap = /* @__PURE__ */ new Map();
|
|
201
|
+
for (const item of list) {
|
|
202
|
+
valueMap.set(item.value, item);
|
|
203
|
+
const dial = item.raw_data.dial_digits;
|
|
204
|
+
if (dial) {
|
|
205
|
+
const bucket = dialMap.get(dial) ?? [];
|
|
206
|
+
bucket.push(item);
|
|
207
|
+
dialMap.set(dial, bucket);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
valueMap,
|
|
212
|
+
dialMap
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const _seed = buildIndexes(FALLBACK_COUNTRIES);
|
|
216
|
+
const byValue = ref(_seed.valueMap);
|
|
217
|
+
const byDialDigits = ref(_seed.dialMap);
|
|
218
|
+
function rebuildIndexes(list) {
|
|
219
|
+
const { valueMap, dialMap } = buildIndexes(list);
|
|
220
|
+
byValue.value = valueMap;
|
|
221
|
+
byDialDigits.value = dialMap;
|
|
222
|
+
}
|
|
223
|
+
function upsertCountries(list) {
|
|
224
|
+
countries.value = list;
|
|
225
|
+
rebuildIndexes(list);
|
|
226
|
+
}
|
|
227
|
+
function normalizeRestCountries(list) {
|
|
228
|
+
const out = [];
|
|
229
|
+
for (const c of list) {
|
|
230
|
+
const name = c?.name?.common?.trim();
|
|
231
|
+
const iso2 = normalizeIso2(c?.cca2);
|
|
232
|
+
const dial = buildDialCode(c?.idd);
|
|
233
|
+
const flag = c?.flags?.png?.trim() || c?.flags?.svg?.trim() || null;
|
|
234
|
+
if (!name || !iso2 || !dial) continue;
|
|
235
|
+
const dialDigits = toDigits(dial);
|
|
236
|
+
const search_key = normalizeSearchKey(`${name} ${dial} ${iso2} ${dialDigits}`);
|
|
237
|
+
out.push({
|
|
238
|
+
label: `${name} (${dial})`,
|
|
239
|
+
value: iso2,
|
|
240
|
+
search_key,
|
|
241
|
+
raw_data: {
|
|
242
|
+
iso2,
|
|
243
|
+
dial_code: dial,
|
|
244
|
+
dial_digits: dialDigits,
|
|
245
|
+
name,
|
|
246
|
+
flag,
|
|
247
|
+
source: "restcountries",
|
|
248
|
+
original: c
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const map = /* @__PURE__ */ new Map();
|
|
253
|
+
for (const item of out) {
|
|
254
|
+
const prev = map.get(item.value);
|
|
255
|
+
if (!prev) {
|
|
256
|
+
map.set(item.value, item);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const prevScore = (prev.raw_data.flag ? 1 : 0) + (prev.raw_data.dial_code ? 1 : 0);
|
|
260
|
+
if ((item.raw_data.flag ? 1 : 0) + (item.raw_data.dial_code ? 1 : 0) > prevScore) map.set(item.value, item);
|
|
261
|
+
}
|
|
262
|
+
return Array.from(map.values()).sort((a, b) => a.raw_data.name.localeCompare(b.raw_data.name));
|
|
263
|
+
}
|
|
264
|
+
async function getCountries(options) {
|
|
265
|
+
const force = Boolean(options?.force);
|
|
266
|
+
if (!force && countries.value.length) return countries.value;
|
|
267
|
+
if (!force && sharedCountries) {
|
|
268
|
+
upsertCountries(sharedCountries);
|
|
269
|
+
return countries.value;
|
|
270
|
+
}
|
|
271
|
+
if (!force && inflightFetch) {
|
|
272
|
+
isCountriesLoading.value = true;
|
|
273
|
+
try {
|
|
274
|
+
upsertCountries(await inflightFetch);
|
|
275
|
+
return countries.value;
|
|
276
|
+
} finally {
|
|
277
|
+
isCountriesLoading.value = false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!force && isBrowser()) try {
|
|
281
|
+
const cached = localStorage.getItem(STORAGE_KEY);
|
|
282
|
+
if (cached) {
|
|
283
|
+
const parsed = JSON.parse(cached);
|
|
284
|
+
if (Array.isArray(parsed) && parsed.length) {
|
|
285
|
+
sharedCountries = parsed;
|
|
286
|
+
upsertCountries(parsed);
|
|
287
|
+
return countries.value;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
isCountriesLoading.value = true;
|
|
292
|
+
inflightFetch = (async () => {
|
|
293
|
+
try {
|
|
294
|
+
const res = await fetch(REST_COUNTRIES_URL);
|
|
295
|
+
if (!res.ok) throw new Error(`Failed to fetch countries: ${res.status}`);
|
|
296
|
+
const normalized = normalizeRestCountries(await res.json());
|
|
297
|
+
const list = normalized.length ? normalized : FALLBACK_COUNTRIES;
|
|
298
|
+
if (isBrowser()) try {
|
|
299
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
|
300
|
+
} catch {}
|
|
301
|
+
return list;
|
|
302
|
+
} catch {
|
|
303
|
+
return FALLBACK_COUNTRIES;
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
try {
|
|
307
|
+
const list = await inflightFetch;
|
|
308
|
+
sharedCountries = list;
|
|
309
|
+
upsertCountries(list);
|
|
310
|
+
return countries.value;
|
|
311
|
+
} finally {
|
|
312
|
+
isCountriesLoading.value = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function searchCountries(keyword, limit = 50) {
|
|
316
|
+
const q = normalizeSearchKey(keyword);
|
|
317
|
+
if (!q) return countries.value.slice(0, limit);
|
|
318
|
+
const res = [];
|
|
319
|
+
for (const item of countries.value) if (item.search_key.includes(q)) {
|
|
320
|
+
res.push(item);
|
|
321
|
+
if (res.length >= limit) break;
|
|
322
|
+
}
|
|
323
|
+
return res;
|
|
324
|
+
}
|
|
325
|
+
function getCountryByValue(value) {
|
|
326
|
+
return byValue.value.get(normalizeIso2(value)) ?? null;
|
|
327
|
+
}
|
|
328
|
+
function getCountriesByDial(dial) {
|
|
329
|
+
return byDialDigits.value.get(toDigits(dial)) ?? [];
|
|
330
|
+
}
|
|
331
|
+
function getRequiredInfo(country, locale) {
|
|
332
|
+
const iso2 = normalizeIso2(country.iso2);
|
|
333
|
+
if (!iso2) return null;
|
|
334
|
+
try {
|
|
335
|
+
const example = getExampleNumber(iso2, EX);
|
|
336
|
+
const exampleNational = example?.formatNational?.() ?? "";
|
|
337
|
+
const exampleE164 = example?.format?.("E.164") ?? "";
|
|
338
|
+
const inferred = inferLengthFromExample(exampleNational);
|
|
339
|
+
const dial_code = country.dial_code ? ensurePlusDial(country.dial_code) : exampleE164 ? `+${example?.countryCallingCode}` : "";
|
|
340
|
+
const digitsExample = toDigits(exampleNational);
|
|
341
|
+
return {
|
|
342
|
+
iso2,
|
|
343
|
+
dial_code,
|
|
344
|
+
placeholder: "",
|
|
345
|
+
example_national: exampleNational,
|
|
346
|
+
example_e164: exampleE164,
|
|
347
|
+
national_number_length: inferred,
|
|
348
|
+
format_hint: digitsExample ? `e.g. ${localizeDigits(digitsExample, locale)}` : ""
|
|
349
|
+
};
|
|
350
|
+
} catch {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function validate(input) {
|
|
355
|
+
const country = input.country ?? null;
|
|
356
|
+
if (!country?.iso2) return {
|
|
357
|
+
ok: false,
|
|
358
|
+
reason: "missing_country",
|
|
359
|
+
country: null,
|
|
360
|
+
phone: {
|
|
361
|
+
raw: ("phone" in input ? input.phone : null) ?? null,
|
|
362
|
+
digits: ""
|
|
363
|
+
},
|
|
364
|
+
full_phone: null,
|
|
365
|
+
required: null
|
|
366
|
+
};
|
|
367
|
+
const iso2 = normalizeIso2(country.iso2);
|
|
368
|
+
const required = getRequiredInfo({
|
|
369
|
+
iso2,
|
|
370
|
+
dial_code: country.dial_code
|
|
371
|
+
}, input.locale);
|
|
372
|
+
if (!required) return {
|
|
373
|
+
ok: false,
|
|
374
|
+
reason: "country_not_supported",
|
|
375
|
+
country: {
|
|
376
|
+
iso2,
|
|
377
|
+
dial_code: ensurePlusDial(country.dial_code)
|
|
378
|
+
},
|
|
379
|
+
phone: {
|
|
380
|
+
raw: ("phone" in input ? input.phone : null) ?? null,
|
|
381
|
+
digits: ""
|
|
382
|
+
},
|
|
383
|
+
full_phone: null,
|
|
384
|
+
required: null
|
|
385
|
+
};
|
|
386
|
+
if (!("phone" in input)) return {
|
|
387
|
+
ok: true,
|
|
388
|
+
reason: null,
|
|
389
|
+
country: {
|
|
390
|
+
iso2: required.iso2,
|
|
391
|
+
dial_code: required.dial_code
|
|
392
|
+
},
|
|
393
|
+
phone: {
|
|
394
|
+
raw: null,
|
|
395
|
+
digits: ""
|
|
396
|
+
},
|
|
397
|
+
full_phone: null,
|
|
398
|
+
required
|
|
399
|
+
};
|
|
400
|
+
const raw = input.phone;
|
|
401
|
+
const digits = toDigits(raw);
|
|
402
|
+
if (!raw || !String(raw).trim()) return {
|
|
403
|
+
ok: true,
|
|
404
|
+
reason: null,
|
|
405
|
+
country: {
|
|
406
|
+
iso2: required.iso2,
|
|
407
|
+
dial_code: required.dial_code
|
|
408
|
+
},
|
|
409
|
+
phone: {
|
|
410
|
+
raw: raw ?? null,
|
|
411
|
+
digits: ""
|
|
412
|
+
},
|
|
413
|
+
full_phone: null,
|
|
414
|
+
required
|
|
415
|
+
};
|
|
416
|
+
if (String(raw).replace(/\s+/g, "").match(/[^\d+]/)) return {
|
|
417
|
+
ok: false,
|
|
418
|
+
reason: "phone_has_non_digits",
|
|
419
|
+
country: {
|
|
420
|
+
iso2: required.iso2,
|
|
421
|
+
dial_code: required.dial_code
|
|
422
|
+
},
|
|
423
|
+
phone: {
|
|
424
|
+
raw,
|
|
425
|
+
digits
|
|
426
|
+
},
|
|
427
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
428
|
+
required
|
|
429
|
+
};
|
|
430
|
+
const nsn = dropLeadingZeros(digits);
|
|
431
|
+
const { min, max } = required.national_number_length;
|
|
432
|
+
if (min !== null && nsn.length < min) return {
|
|
433
|
+
ok: false,
|
|
434
|
+
reason: "too_short",
|
|
435
|
+
country: {
|
|
436
|
+
iso2: required.iso2,
|
|
437
|
+
dial_code: required.dial_code
|
|
438
|
+
},
|
|
439
|
+
phone: {
|
|
440
|
+
raw,
|
|
441
|
+
digits
|
|
442
|
+
},
|
|
443
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
444
|
+
required,
|
|
445
|
+
details: {
|
|
446
|
+
min,
|
|
447
|
+
actual: nsn.length
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
if (max !== null && nsn.length > max) return {
|
|
451
|
+
ok: false,
|
|
452
|
+
reason: "too_long",
|
|
453
|
+
country: {
|
|
454
|
+
iso2: required.iso2,
|
|
455
|
+
dial_code: required.dial_code
|
|
456
|
+
},
|
|
457
|
+
phone: {
|
|
458
|
+
raw,
|
|
459
|
+
digits
|
|
460
|
+
},
|
|
461
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
462
|
+
required,
|
|
463
|
+
details: {
|
|
464
|
+
max,
|
|
465
|
+
actual: nsn.length
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
const full = buildFullE164(required.dial_code, digits) ?? String(raw);
|
|
469
|
+
try {
|
|
470
|
+
if (!isValidPhoneNumber(full, iso2)) {
|
|
471
|
+
const parsed = parsePhoneNumberFromString(full, iso2);
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
reason: "invalid_phone",
|
|
475
|
+
country: {
|
|
476
|
+
iso2: required.iso2,
|
|
477
|
+
dial_code: required.dial_code
|
|
478
|
+
},
|
|
479
|
+
phone: {
|
|
480
|
+
raw,
|
|
481
|
+
digits
|
|
482
|
+
},
|
|
483
|
+
full_phone: parsed?.number ?? null,
|
|
484
|
+
required,
|
|
485
|
+
details: {
|
|
486
|
+
type: parsed?.getType?.() ?? null,
|
|
487
|
+
possible: parsed?.isPossible?.() ?? null,
|
|
488
|
+
country: parsed?.country ?? null
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const parsed = parsePhoneNumberFromString(full, iso2);
|
|
493
|
+
return {
|
|
494
|
+
ok: true,
|
|
495
|
+
reason: null,
|
|
496
|
+
country: {
|
|
497
|
+
iso2: required.iso2,
|
|
498
|
+
dial_code: required.dial_code
|
|
499
|
+
},
|
|
500
|
+
phone: {
|
|
501
|
+
raw,
|
|
502
|
+
digits
|
|
503
|
+
},
|
|
504
|
+
full_phone: parsed?.number ?? full,
|
|
505
|
+
required
|
|
506
|
+
};
|
|
507
|
+
} catch (e) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
reason: "parse_failed",
|
|
511
|
+
country: {
|
|
512
|
+
iso2: required.iso2,
|
|
513
|
+
dial_code: required.dial_code
|
|
514
|
+
},
|
|
515
|
+
phone: {
|
|
516
|
+
raw,
|
|
517
|
+
digits
|
|
518
|
+
},
|
|
519
|
+
full_phone: buildFullE164(required.dial_code, digits),
|
|
520
|
+
required,
|
|
521
|
+
details: { error: e?.message ?? String(e) }
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
countries,
|
|
527
|
+
isCountriesLoading,
|
|
528
|
+
getCountries,
|
|
529
|
+
searchCountries,
|
|
530
|
+
getCountryByValue,
|
|
531
|
+
getCountriesByDial,
|
|
532
|
+
getRequiredInfo,
|
|
533
|
+
validate
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
export { normalizeDigits as i, usePhoneValidation as n, LOCALE_DIGIT_RANGES as r, localizeCountries as t };
|
|
538
|
+
|
|
539
|
+
//# sourceMappingURL=usePhoneValidation.js.map
|