@alikhalilll/a-tel-input 1.0.2 → 1.1.1
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/.media/README.md +3 -0
- package/.media/hero.png +0 -0
- package/README.md +597 -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 +471 -695
- 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 +454 -658
- package/dist/index.js.map +1 -1
- package/dist/styles.css +20 -5
- 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 +34 -3
- package/src/components/ACountrySelect.vue +17 -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
package/src/types.ts
CHANGED
|
@@ -6,9 +6,12 @@ import type {
|
|
|
6
6
|
PhoneValidationReason,
|
|
7
7
|
PhoneValidationResult,
|
|
8
8
|
} from './composables/usePhoneValidation';
|
|
9
|
+
import type { ATelInputValidateOn } from './composables/useTelInputValidation';
|
|
9
10
|
import type { FlagUrlBuilder } from './utils/flag-url';
|
|
10
11
|
import type { Size } from '@alikhalilll/a-ui-base';
|
|
11
12
|
|
|
13
|
+
export type { ATelInputValidateOn } from './composables/useTelInputValidation';
|
|
14
|
+
|
|
12
15
|
/** Alias for the shared `Size` scale — kept for backwards-friendly naming. */
|
|
13
16
|
export type ATelInputSize = Size;
|
|
14
17
|
|
|
@@ -61,6 +64,48 @@ export type TelInputMessagesInput = Partial<Omit<TelInputMessages, 'errorMessage
|
|
|
61
64
|
|
|
62
65
|
export interface ATelInputProps {
|
|
63
66
|
class?: HTMLAttributes['class'];
|
|
67
|
+
/**
|
|
68
|
+
* Default `v-model` — the canonical **E.164** string (e.g. `'+201066105963'`).
|
|
69
|
+
*
|
|
70
|
+
* Reads + writes the full international number as a single value. Designed to drop
|
|
71
|
+
* straight into VeeValidate's `<Field v-slot="{ field }">` pattern (use
|
|
72
|
+
* `v-bind="field"`), native `<form>` submission, or any `v-model="phoneE164"` consumer.
|
|
73
|
+
*
|
|
74
|
+
* Stays in sync with the split `v-model:phone` + `v-model:country` contract — use
|
|
75
|
+
* either, or both.
|
|
76
|
+
*/
|
|
77
|
+
modelValue?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Forwarded to the inner `<input name="">`. Set this when participating in a native
|
|
80
|
+
* `<form>` submission, or when a form library (VeeValidate, etc.) wants a stable name.
|
|
81
|
+
*/
|
|
82
|
+
name?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Externally controlled error message. When set to a non-empty string the component
|
|
85
|
+
* is forced into the error visual state and renders this message via the `#error`
|
|
86
|
+
* slot — overriding internal libphonenumber validation. Wire this from VeeValidate,
|
|
87
|
+
* Zod, an async server check ("this phone is already registered"), or any custom
|
|
88
|
+
* validation layer.
|
|
89
|
+
*
|
|
90
|
+
* Pass `null` / `undefined` / `''` to defer to internal validation.
|
|
91
|
+
*/
|
|
92
|
+
error?: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* `true` while an async validation is in flight (e.g. a server-side uniqueness
|
|
95
|
+
* check). Renders a small spinner inside the field and sets `aria-busy="true"` on
|
|
96
|
+
* the input. Does **not** disable the field — use `loading` for that. Replace the
|
|
97
|
+
* spinner via the `#validating` slot.
|
|
98
|
+
*
|
|
99
|
+
* Designed to be bound to the `validating` ref returned by `useTelField()`.
|
|
100
|
+
*/
|
|
101
|
+
validating?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* When to surface validation in the UI.
|
|
104
|
+
* - `'change'` (default) — visible state mirrors the typing-paused state.
|
|
105
|
+
* - `'blur'` — stays idle until the input has been blurred once (form-library friendly).
|
|
106
|
+
* - `'eager'` — mirror raw validation immediately, no typing pause.
|
|
107
|
+
*/
|
|
108
|
+
validateOn?: ATelInputValidateOn;
|
|
64
109
|
placeholder?: string;
|
|
65
110
|
disabled?: boolean;
|
|
66
111
|
loading?: boolean;
|
|
@@ -284,18 +329,53 @@ export interface ATelInputSlots {
|
|
|
284
329
|
empty?: (props: { query: string }) => unknown;
|
|
285
330
|
/** Replace the spinner shown in the picker slot during the debounce window. */
|
|
286
331
|
detecting?: () => unknown;
|
|
332
|
+
/** Replace the spinner shown inside the field while async validation is in flight. */
|
|
333
|
+
validating?: () => unknown;
|
|
287
334
|
}
|
|
288
335
|
|
|
289
336
|
/**
|
|
290
337
|
* Emit map for {@link ATelInput}. `update:phone` carries the digits-only string,
|
|
291
338
|
* `update:country` carries the dial-number (not ISO2). Surface for consumers who
|
|
292
339
|
* wire the events manually instead of via `v-model:phone` / `v-model:country`.
|
|
340
|
+
*
|
|
341
|
+
* `blur` / `focus` mirror the inner input's native events — useful for form
|
|
342
|
+
* libraries (VeeValidate's `handleBlur`, etc.).
|
|
293
343
|
*/
|
|
294
344
|
export type ATelInputEmits = {
|
|
345
|
+
'update:modelValue': [value: string];
|
|
295
346
|
'update:phone': [value: string];
|
|
296
347
|
'update:country': [value: number | null];
|
|
348
|
+
blur: [event: FocusEvent];
|
|
349
|
+
focus: [event: FocusEvent];
|
|
297
350
|
};
|
|
298
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Imperative API exposed by {@link ATelInput} via `defineExpose`. Grab it with
|
|
354
|
+
* `ref="tellRef"` + `tellRef.value?.focus()` — useful for form libraries that want
|
|
355
|
+
* to focus the offending field after a failed submit.
|
|
356
|
+
*/
|
|
357
|
+
export interface ATelInputExpose {
|
|
358
|
+
/** Full {@link PhoneValidationResult} for the current input. */
|
|
359
|
+
validation: import('vue').ComputedRef<PhoneValidationResult>;
|
|
360
|
+
/** Format hint + example for the currently selected country, or `null`. */
|
|
361
|
+
required: import('vue').ComputedRef<unknown>;
|
|
362
|
+
/** Selected country's dial code as a `+`-prefixed string (e.g. `+20`), or `null`. */
|
|
363
|
+
selectedDialCode: import('vue').ComputedRef<string | null>;
|
|
364
|
+
/** Raw validation state — not gated by typing pause / blur / `showValidation`. */
|
|
365
|
+
validationState: import('vue').ComputedRef<'idle' | 'valid' | 'error'>;
|
|
366
|
+
/** Surfacing-gated validation state — the one the UI actually displays. */
|
|
367
|
+
visibleValidationState: import('vue').ComputedRef<'idle' | 'valid' | 'error'>;
|
|
368
|
+
isDetecting: Readonly<import('vue').Ref<boolean>>;
|
|
369
|
+
hasFinishedTyping: Readonly<import('vue').Ref<boolean>>;
|
|
370
|
+
detectionAttempted: Readonly<import('vue').Ref<boolean>>;
|
|
371
|
+
/** Programmatically focus the inner `<input>`. */
|
|
372
|
+
focus(options?: FocusOptions): void;
|
|
373
|
+
/** Programmatically blur the inner `<input>`. */
|
|
374
|
+
blur(): void;
|
|
375
|
+
/** Programmatically select the inner `<input>`'s text. */
|
|
376
|
+
select(): void;
|
|
377
|
+
}
|
|
378
|
+
|
|
299
379
|
/**
|
|
300
380
|
* Props for {@link ACountrySelect} — the standalone country picker. Surface
|
|
301
381
|
* separately so it can be used outside `ATelInput` with full type support.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VeeValidate adapter for `@alikhalilll/a-tel-input`.
|
|
3
|
+
*
|
|
4
|
+
* `useTelField()` owns the two v-models (`phone` + `country`) and the canonical
|
|
5
|
+
* E.164 string used by VeeValidate / Zod / yup, and returns a single ready-to-bind
|
|
6
|
+
* object so a consumer doesn't have to glue the pieces together.
|
|
7
|
+
*
|
|
8
|
+
* Quick start:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { useTelField } from '@alikhalilll/a-tel-input/vee-validate';
|
|
12
|
+
* import { toTypedSchema } from '@vee-validate/zod';
|
|
13
|
+
* import { z } from 'zod';
|
|
14
|
+
* import { zPhone } from '@alikhalilll/a-tel-input/zod';
|
|
15
|
+
*
|
|
16
|
+
* const { phone, country, error, handleBlur, fieldProps } = useTelField('phone', {
|
|
17
|
+
* rules: toTypedSchema(zPhone()),
|
|
18
|
+
* validateOn: 'blur',
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ```vue
|
|
23
|
+
* <ATelInput
|
|
24
|
+
* v-model:phone="phone"
|
|
25
|
+
* v-model:country="country"
|
|
26
|
+
* v-bind="fieldProps"
|
|
27
|
+
* @blur="handleBlur"
|
|
28
|
+
* />
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Server-side validation (e.g. "does this phone already exist?") plugs in via the
|
|
32
|
+
* `rules` callback — VeeValidate already supports async rules:
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const { phone, country, error, handleBlur, fieldProps, validating } = useTelField('phone', {
|
|
36
|
+
* rules: async (value: string) => {
|
|
37
|
+
* const sync = await zPhone().safeParseAsync(value);
|
|
38
|
+
* if (!sync.success) return sync.error.issues[0]!.message;
|
|
39
|
+
* const res = await $fetch('/api/phone/exists', { query: { phone: value } });
|
|
40
|
+
* return res.exists ? 'This phone is already registered.' : true;
|
|
41
|
+
* },
|
|
42
|
+
* validateOn: 'blur',
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* The `validating` ref is `true` while VeeValidate's async pipeline is in flight —
|
|
47
|
+
* bind it to ATelInput's `:validating` prop to surface a spinner inside the field.
|
|
48
|
+
*
|
|
49
|
+
* `vee-validate` is an **optional peer dependency** — install it yourself in your app.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { computed, ref, watch, type ComputedRef, type MaybeRefOrGetter } from 'vue';
|
|
53
|
+
import { toValue } from 'vue';
|
|
54
|
+
import { useField, type FieldOptions, type RuleExpression } from 'vee-validate';
|
|
55
|
+
import { usePhoneValidation } from '../composables/usePhoneValidation';
|
|
56
|
+
import type { ATelInputValidateOn } from '../composables/useTelInputValidation';
|
|
57
|
+
|
|
58
|
+
export interface UseTelFieldOptions {
|
|
59
|
+
/**
|
|
60
|
+
* VeeValidate rules — a function, schema, or string. Use when the field stands
|
|
61
|
+
* alone (no `useForm` / no form-level `validationSchema`).
|
|
62
|
+
*
|
|
63
|
+
* **Important**: vee-validate **ignores field-level `rules` whenever the parent
|
|
64
|
+
* `useForm` is configured with `validationSchema`**. If you need async / server-
|
|
65
|
+
* side validation inside a form, chain it onto the form schema via
|
|
66
|
+
* `z.refine(async)` instead — that's what `handleSubmit` actually awaits and what
|
|
67
|
+
* drives this composable's `validating` ref (via `meta.pending`). See the README
|
|
68
|
+
* section "Server-side validation" for the full pattern.
|
|
69
|
+
*/
|
|
70
|
+
rules?: RuleExpression<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Initial digits-only national number, e.g. `'1066105963'`. Defaults to `''`.
|
|
73
|
+
*/
|
|
74
|
+
initialPhone?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Initial dial-digit number, e.g. `20` for Egypt. Defaults to `null`.
|
|
77
|
+
*/
|
|
78
|
+
initialCountry?: number | null;
|
|
79
|
+
/**
|
|
80
|
+
* Default country (ISO2 like `'EG'` or dial code like `'20'`). Forwarded as the
|
|
81
|
+
* `defaultCountry` prop on `<ATelInput>` via `fieldProps`.
|
|
82
|
+
*/
|
|
83
|
+
defaultCountry?: string;
|
|
84
|
+
/**
|
|
85
|
+
* When to surface validation in the UI. Defaults to `'blur'` (the typical form-library
|
|
86
|
+
* UX). Forwarded as the `validateOn` prop on `<ATelInput>` via `fieldProps`.
|
|
87
|
+
*/
|
|
88
|
+
validateOn?: ATelInputValidateOn;
|
|
89
|
+
/**
|
|
90
|
+
* Forwarded to `useField` — extra options passed verbatim to VeeValidate. Use to
|
|
91
|
+
* configure `keepValueOnUnmount`, `syncVModel`, etc.
|
|
92
|
+
*/
|
|
93
|
+
fieldOptions?: Omit<FieldOptions<string>, 'initialValue'>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface UseTelFieldReturn {
|
|
97
|
+
/** `v-model:phone` source — digits-only national number. Bind to `<ATelInput>`. */
|
|
98
|
+
phone: import('vue').Ref<string>;
|
|
99
|
+
/** `v-model:country` source — dial-digit number (e.g., `20`). Bind to `<ATelInput>`. */
|
|
100
|
+
country: import('vue').Ref<number | null>;
|
|
101
|
+
/** The canonical E.164 string fed to VeeValidate's schema (read-only). */
|
|
102
|
+
e164: ComputedRef<string>;
|
|
103
|
+
/** Current validation error message, or `undefined` when valid. From `useField`. */
|
|
104
|
+
error: import('vue').Ref<string | undefined>;
|
|
105
|
+
/** `true` while VeeValidate is running an async rule (e.g. server-side check). */
|
|
106
|
+
validating: ComputedRef<boolean>;
|
|
107
|
+
/** Whether the field has been blurred / dirtied / submitted (from VeeValidate `meta`). */
|
|
108
|
+
meta: ReturnType<typeof useField<string>>['meta'];
|
|
109
|
+
/** Forward this to `<ATelInput @blur="handleBlur">` so VeeValidate's blur trigger fires. */
|
|
110
|
+
handleBlur: ReturnType<typeof useField<string>>['handleBlur'];
|
|
111
|
+
/** Imperatively trigger validation (e.g., after a programmatic value change). */
|
|
112
|
+
validate: ReturnType<typeof useField<string>>['validate'];
|
|
113
|
+
/** Imperatively set the error message — useful for server errors not raised by `rules`. */
|
|
114
|
+
setErrors: ReturnType<typeof useField<string>>['setErrors'];
|
|
115
|
+
/** Reset the field to its initial state. */
|
|
116
|
+
resetField: ReturnType<typeof useField<string>>['resetField'];
|
|
117
|
+
/**
|
|
118
|
+
* Ready-to-bind prop bag for `<ATelInput v-bind="fieldProps">`. Carries `name`,
|
|
119
|
+
* `error`, `validateOn`, `validating`, and `defaultCountry`.
|
|
120
|
+
*/
|
|
121
|
+
fieldProps: ComputedRef<{
|
|
122
|
+
name: string;
|
|
123
|
+
error: string | null;
|
|
124
|
+
validateOn: ATelInputValidateOn;
|
|
125
|
+
validating: boolean;
|
|
126
|
+
defaultCountry?: string;
|
|
127
|
+
}>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Register a phone field with VeeValidate. See file header for usage examples.
|
|
132
|
+
*
|
|
133
|
+
* @param name The field name (used by VeeValidate, also forwarded to the inner
|
|
134
|
+
* `<input name="">` for native form submission).
|
|
135
|
+
* @param options See {@link UseTelFieldOptions}.
|
|
136
|
+
*/
|
|
137
|
+
export function useTelField(
|
|
138
|
+
name: MaybeRefOrGetter<string>,
|
|
139
|
+
options: UseTelFieldOptions = {}
|
|
140
|
+
): UseTelFieldReturn {
|
|
141
|
+
const phone = ref<string>(options.initialPhone ?? '');
|
|
142
|
+
const country = ref<number | null>(options.initialCountry ?? null);
|
|
143
|
+
|
|
144
|
+
const v = usePhoneValidation();
|
|
145
|
+
void v.getCountries();
|
|
146
|
+
|
|
147
|
+
// Compose E.164 from the two v-models — this is the canonical value VeeValidate
|
|
148
|
+
// tracks. NANP (`+1`) resolves to `'US'` for validation; libphonenumber-js applies
|
|
149
|
+
// the same rule set to every NANP country so this is correct.
|
|
150
|
+
function composeE164(): string {
|
|
151
|
+
if (!phone.value) return '';
|
|
152
|
+
const dial = country.value;
|
|
153
|
+
if (dial == null) return '';
|
|
154
|
+
const matches = v.getCountriesByDial(String(dial));
|
|
155
|
+
const iso2 = matches[0]?.value;
|
|
156
|
+
if (!iso2) return '';
|
|
157
|
+
const res = v.validate({ country: { iso2 }, phone: phone.value });
|
|
158
|
+
return res.full_phone ?? '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const initialValue = composeE164();
|
|
162
|
+
|
|
163
|
+
const field = useField<string>(name, options.rules, {
|
|
164
|
+
initialValue,
|
|
165
|
+
// Don't run rules on every keystroke — let validateOn drive when validation fires.
|
|
166
|
+
validateOnValueUpdate: false,
|
|
167
|
+
...options.fieldOptions,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Keep VeeValidate's value in sync with the two v-models.
|
|
171
|
+
watch(
|
|
172
|
+
[phone, country],
|
|
173
|
+
() => {
|
|
174
|
+
field.value.value = composeE164();
|
|
175
|
+
},
|
|
176
|
+
{ flush: 'post' }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const validating = computed(() => !!field.meta.pending);
|
|
180
|
+
|
|
181
|
+
const fieldProps = computed(() => ({
|
|
182
|
+
name: toValue(name),
|
|
183
|
+
error: (field.errorMessage.value ?? null) as string | null,
|
|
184
|
+
validateOn: options.validateOn ?? ('blur' as ATelInputValidateOn),
|
|
185
|
+
validating: validating.value,
|
|
186
|
+
defaultCountry: options.defaultCountry,
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
phone,
|
|
191
|
+
country,
|
|
192
|
+
e164: computed(() => field.value.value ?? ''),
|
|
193
|
+
error: field.errorMessage,
|
|
194
|
+
validating,
|
|
195
|
+
meta: field.meta,
|
|
196
|
+
handleBlur: field.handleBlur,
|
|
197
|
+
validate: field.validate,
|
|
198
|
+
setErrors: field.setErrors,
|
|
199
|
+
resetField: field.resetField,
|
|
200
|
+
fieldProps,
|
|
201
|
+
};
|
|
202
|
+
}
|
package/src/zod/index.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod adapter for `@alikhalilll/a-tel-input`.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the same `usePhoneValidation()` engine the component uses, so a Zod schema
|
|
5
|
+
* never disagrees with the visual validation state in the field.
|
|
6
|
+
*
|
|
7
|
+
* Three usage shapes, all backed by libphonenumber-js:
|
|
8
|
+
*
|
|
9
|
+
* 1. **E.164 string** (default) — validate a full international number:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { zPhone } from '@alikhalilll/a-tel-input/zod';
|
|
13
|
+
* const schema = z.object({ phone: zPhone() });
|
|
14
|
+
* schema.parse({ phone: '+201234567890' });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* 2. **National number for a fixed country** — when you already know the country (e.g.,
|
|
18
|
+
* a Saudi-only form) and only need to validate the digits the user typed:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const schema = z.object({ phone: zPhone({ country: 'SA' }) });
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* 3. **Combined object** — matches ATelInput's two v-models out of the box:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { zPhoneObject } from '@alikhalilll/a-tel-input/zod';
|
|
28
|
+
* const schema = z.object({
|
|
29
|
+
* contact: zPhoneObject(), // -> { phone: string, country: number | null }
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* All three honour `allowedDialCodes` and produce a `PhoneValidationReason` localised
|
|
34
|
+
* through the same `DEFAULT_ERROR_MESSAGES` map as the component, so error wording is
|
|
35
|
+
* consistent across UI + schema.
|
|
36
|
+
*
|
|
37
|
+
* `zod` is an **optional peer dependency** — install it yourself in your app.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { z } from 'zod';
|
|
41
|
+
import { parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
42
|
+
import {
|
|
43
|
+
usePhoneValidation,
|
|
44
|
+
type PhoneValidationReason,
|
|
45
|
+
type PhoneValidationResult,
|
|
46
|
+
} from '../composables/usePhoneValidation';
|
|
47
|
+
import { DEFAULT_ERROR_MESSAGES } from '../types';
|
|
48
|
+
|
|
49
|
+
export interface ZPhoneOptions {
|
|
50
|
+
/**
|
|
51
|
+
* ISO 3166-1 alpha-2 country code (`'EG'`, `'SA'`, …). When set, the input is treated
|
|
52
|
+
* as a national number for that country. Leave undefined to validate a full E.164
|
|
53
|
+
* string (the country is inferred from the leading `+` dial code).
|
|
54
|
+
*/
|
|
55
|
+
country?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Whitelist of allowed dial-digit codes (no `+`), e.g. `['20', '966']`. Numbers from
|
|
58
|
+
* countries outside this list fail with `country_not_supported`. Matches the
|
|
59
|
+
* `allowedDialCodes` prop on `ATelInput`.
|
|
60
|
+
*/
|
|
61
|
+
allowedDialCodes?: string[];
|
|
62
|
+
/**
|
|
63
|
+
* BCP-47 locale, forwarded to the validator. Doesn't change the validity outcome —
|
|
64
|
+
* only affects locale-dependent fields on the underlying validation result.
|
|
65
|
+
*/
|
|
66
|
+
locale?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Override the error messages used when the Zod issue is raised. Keyed by validation
|
|
69
|
+
* reason. Falls back to the same English defaults the component uses.
|
|
70
|
+
*/
|
|
71
|
+
messages?: Partial<Record<PhoneValidationReason, string>>;
|
|
72
|
+
/**
|
|
73
|
+
* Custom message for the "empty input" case. By default the schema accepts an empty
|
|
74
|
+
* string — wrap with `z.string().min(1)` upstream if you want "required" semantics,
|
|
75
|
+
* or pass a non-empty `requiredMessage` to enforce it here.
|
|
76
|
+
*/
|
|
77
|
+
requiredMessage?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function messageFor(reason: PhoneValidationReason, overrides?: ZPhoneOptions['messages']) {
|
|
81
|
+
return overrides?.[reason] ?? DEFAULT_ERROR_MESSAGES[reason];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function failsAllowList(dialDigits: string, allowed?: string[]) {
|
|
85
|
+
if (!allowed || allowed.length === 0) return false;
|
|
86
|
+
return !allowed.includes(dialDigits);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Run the same validate() the component uses, but resolve country from an E.164 string. */
|
|
90
|
+
function validateE164(
|
|
91
|
+
value: string,
|
|
92
|
+
v: ReturnType<typeof usePhoneValidation>,
|
|
93
|
+
locale?: string
|
|
94
|
+
): PhoneValidationResult {
|
|
95
|
+
const trimmed = String(value ?? '').trim();
|
|
96
|
+
if (!trimmed) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: 'invalid_phone',
|
|
100
|
+
country: null,
|
|
101
|
+
phone: { raw: value, digits: '' },
|
|
102
|
+
full_phone: null,
|
|
103
|
+
required: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// libphonenumber wants a leading `+` to skip the country-hint requirement.
|
|
107
|
+
const e164 = trimmed.startsWith('+') ? trimmed : `+${trimmed}`;
|
|
108
|
+
const parsed = parsePhoneNumberFromString(e164);
|
|
109
|
+
if (!parsed || !parsed.country) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
reason: 'parse_failed',
|
|
113
|
+
country: null,
|
|
114
|
+
phone: { raw: value, digits: '' },
|
|
115
|
+
full_phone: null,
|
|
116
|
+
required: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return v.validate({
|
|
120
|
+
country: { iso2: parsed.country },
|
|
121
|
+
phone: parsed.nationalNumber,
|
|
122
|
+
locale,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Zod schema for a phone string.
|
|
128
|
+
*
|
|
129
|
+
* @see {@link ZPhoneOptions} for behaviour modes.
|
|
130
|
+
*/
|
|
131
|
+
export function zPhone(options: ZPhoneOptions = {}): z.ZodType<string, z.ZodTypeDef, string> {
|
|
132
|
+
// Lazy: only construct the validator on first parse, so apps that bundle this don't
|
|
133
|
+
// pay the libphonenumber metadata load until they actually validate.
|
|
134
|
+
let _v: ReturnType<typeof usePhoneValidation> | null = null;
|
|
135
|
+
const getV = () => {
|
|
136
|
+
if (!_v) {
|
|
137
|
+
_v = usePhoneValidation();
|
|
138
|
+
void _v.getCountries();
|
|
139
|
+
}
|
|
140
|
+
return _v;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return z.string().superRefine((value, ctx) => {
|
|
144
|
+
const v = getV();
|
|
145
|
+
const isEmpty = !value || !String(value).trim();
|
|
146
|
+
|
|
147
|
+
if (isEmpty) {
|
|
148
|
+
if (options.requiredMessage) {
|
|
149
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: options.requiredMessage });
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result: PhoneValidationResult = options.country
|
|
155
|
+
? v.validate({ country: { iso2: options.country }, phone: value, locale: options.locale })
|
|
156
|
+
: validateE164(value, v, options.locale);
|
|
157
|
+
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
const reason = result.reason ?? 'invalid_phone';
|
|
160
|
+
ctx.addIssue({
|
|
161
|
+
code: z.ZodIssueCode.custom,
|
|
162
|
+
message: messageFor(reason, options.messages),
|
|
163
|
+
params: { reason, validation: result },
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const dialDigits = result.country?.dial_code?.replace(/^\+/, '') ?? '';
|
|
169
|
+
if (failsAllowList(dialDigits, options.allowedDialCodes)) {
|
|
170
|
+
ctx.addIssue({
|
|
171
|
+
code: z.ZodIssueCode.custom,
|
|
172
|
+
message: messageFor('country_not_supported', options.messages),
|
|
173
|
+
params: { reason: 'country_not_supported' as PhoneValidationReason, validation: result },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Zod schema for the `{ phone, country }` object shape matching `ATelInput`'s two
|
|
181
|
+
* v-models. `phone` is the digits-only national number; `country` is the dial-digit
|
|
182
|
+
* **number** (e.g. `20` for Egypt). NANP (`+1`) maps to `'US'` for validation purposes,
|
|
183
|
+
* which is correct for `isValidPhoneNumber` (all NANP countries share the same rule set).
|
|
184
|
+
*/
|
|
185
|
+
export function zPhoneObject(options: ZPhoneOptions = {}) {
|
|
186
|
+
let _v: ReturnType<typeof usePhoneValidation> | null = null;
|
|
187
|
+
const getV = () => {
|
|
188
|
+
if (!_v) {
|
|
189
|
+
_v = usePhoneValidation();
|
|
190
|
+
void _v.getCountries();
|
|
191
|
+
}
|
|
192
|
+
return _v;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
return z
|
|
196
|
+
.object({
|
|
197
|
+
phone: z.string(),
|
|
198
|
+
country: z.number().nullable(),
|
|
199
|
+
})
|
|
200
|
+
.superRefine((input, ctx) => {
|
|
201
|
+
const v = getV();
|
|
202
|
+
const phone = (input.phone ?? '').trim();
|
|
203
|
+
const country = input.country;
|
|
204
|
+
|
|
205
|
+
if (!phone) {
|
|
206
|
+
if (options.requiredMessage) {
|
|
207
|
+
ctx.addIssue({
|
|
208
|
+
code: z.ZodIssueCode.custom,
|
|
209
|
+
path: ['phone'],
|
|
210
|
+
message: options.requiredMessage,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (country == null) {
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
code: z.ZodIssueCode.custom,
|
|
219
|
+
path: ['country'],
|
|
220
|
+
message: messageFor('missing_country', options.messages),
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const matches = v.getCountriesByDial(String(country));
|
|
226
|
+
const iso2 = matches[0]?.value ?? options.country;
|
|
227
|
+
if (!iso2) {
|
|
228
|
+
ctx.addIssue({
|
|
229
|
+
code: z.ZodIssueCode.custom,
|
|
230
|
+
path: ['country'],
|
|
231
|
+
message: messageFor('country_not_supported', options.messages),
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = v.validate({ country: { iso2 }, phone, locale: options.locale });
|
|
237
|
+
if (!result.ok) {
|
|
238
|
+
const reason = result.reason ?? 'invalid_phone';
|
|
239
|
+
ctx.addIssue({
|
|
240
|
+
code: z.ZodIssueCode.custom,
|
|
241
|
+
path: ['phone'],
|
|
242
|
+
message: messageFor(reason, options.messages),
|
|
243
|
+
params: { reason, validation: result },
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (failsAllowList(String(country), options.allowedDialCodes)) {
|
|
249
|
+
ctx.addIssue({
|
|
250
|
+
code: z.ZodIssueCode.custom,
|
|
251
|
+
path: ['country'],
|
|
252
|
+
message: messageFor('country_not_supported', options.messages),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export { DEFAULT_ERROR_MESSAGES };
|
|
259
|
+
export type { PhoneValidationReason, PhoneValidationResult };
|
package/web-types.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/web-types",
|
|
3
3
|
"name": "@alikhalilll/a-tel-input",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.1",
|
|
5
5
|
"js-types-syntax": "typescript",
|
|
6
6
|
"description-markup": "markdown",
|
|
7
7
|
"framework": "vue",
|
|
@@ -97,6 +97,12 @@
|
|
|
97
97
|
"type": "string",
|
|
98
98
|
"required": false
|
|
99
99
|
},
|
|
100
|
+
{
|
|
101
|
+
"name": "error",
|
|
102
|
+
"type": "string | null",
|
|
103
|
+
"required": false,
|
|
104
|
+
"description": "Externally controlled error message. When set to a non-empty string the component\nis forced into the error visual state and renders this message via the `#error`\nslot — overriding internal libphonenumber validation. Wire this from VeeValidate,\nZod, an async server check (\"this phone is already registered\"), or any custom\nvalidation layer.\n\nPass `null` / `undefined` / `''` to defer to internal validation."
|
|
105
|
+
},
|
|
100
106
|
{
|
|
101
107
|
"name": "errorClass",
|
|
102
108
|
"type": "string",
|
|
@@ -161,6 +167,18 @@
|
|
|
161
167
|
"required": false,
|
|
162
168
|
"description": "Localized UI strings. A single bag covering the picker, validation errors, and a11y\nlabels. Individual props (`searchPlaceholder`, `emptyText`, `loadingText`,\n`errorMessages`) take precedence over the matching `messages` key when both are set."
|
|
163
169
|
},
|
|
170
|
+
{
|
|
171
|
+
"name": "modelValue",
|
|
172
|
+
"type": "string",
|
|
173
|
+
"required": false,
|
|
174
|
+
"description": "Default `v-model` — the canonical **E.164** string (e.g. `'+201066105963'`).\n\nReads + writes the full international number as a single value. Designed to drop\nstraight into VeeValidate's `<Field v-slot=\"{ field }\">` pattern (use\n`v-bind=\"field\"`), native `<form>` submission, or any `v-model=\"phoneE164\"` consumer.\n\nStays in sync with the split `v-model:phone` + `v-model:country` contract — use\neither, or both."
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"name": "name",
|
|
178
|
+
"type": "string",
|
|
179
|
+
"required": false,
|
|
180
|
+
"description": "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."
|
|
181
|
+
},
|
|
164
182
|
{
|
|
165
183
|
"name": "placeholder",
|
|
166
184
|
"type": "string",
|
|
@@ -206,6 +224,18 @@
|
|
|
206
224
|
"name": "size",
|
|
207
225
|
"type": "Size",
|
|
208
226
|
"required": false
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
"name": "validateOn",
|
|
230
|
+
"type": "ATelInputValidateOn",
|
|
231
|
+
"required": false,
|
|
232
|
+
"description": "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."
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
"name": "validating",
|
|
236
|
+
"type": "boolean",
|
|
237
|
+
"required": false,
|
|
238
|
+
"description": "`true` while an async validation is in flight (e.g. a server-side uniqueness\ncheck). Renders a small spinner inside the field and sets `aria-busy=\"true\"` on\nthe input. Does **not** disable the field — use `loading` for that. Replace the\nspinner via the `#validating` slot.\n\nDesigned to be bound to the `validating` ref returned by `useTelField()`."
|
|
209
239
|
}
|
|
210
240
|
],
|
|
211
241
|
"slots": [
|
|
@@ -266,13 +296,26 @@
|
|
|
266
296
|
{
|
|
267
297
|
"name": "valid-icon",
|
|
268
298
|
"description": "Replace the green check shown when the number validates."
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
"name": "validating",
|
|
302
|
+
"description": "Replace the spinner shown inside the field while async validation is in flight."
|
|
269
303
|
}
|
|
270
304
|
],
|
|
271
305
|
"js": {
|
|
272
306
|
"events": [
|
|
307
|
+
{
|
|
308
|
+
"name": "blur"
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "focus"
|
|
312
|
+
},
|
|
273
313
|
{
|
|
274
314
|
"name": "update:country"
|
|
275
315
|
},
|
|
316
|
+
{
|
|
317
|
+
"name": "update:modelValue"
|
|
318
|
+
},
|
|
276
319
|
{
|
|
277
320
|
"name": "update:phone"
|
|
278
321
|
}
|