@alikhalilll/a-tel-input 1.0.1 → 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 (40) 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 +13859 -1240
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +120 -585
  10. package/dist/index.d.ts +120 -585
  11. package/dist/index.js +13752 -1113
  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 +41 -6
  27. package/src/components/ACountrySelect.vue +79 -1
  28. package/src/components/ATelInput.vue +206 -66
  29. package/src/composables/useCountryDetection.ts +28 -11
  30. package/src/composables/useCountryMatching.ts +160 -20
  31. package/src/composables/useCountrySelection.ts +71 -0
  32. package/src/composables/usePhoneValidation.ts +81 -18
  33. package/src/composables/useSyncedModel.ts +80 -0
  34. package/src/composables/useTelInputValidation.ts +50 -11
  35. package/src/index.ts +2 -0
  36. package/src/types.ts +80 -0
  37. package/src/vee-validate/index.ts +2 -0
  38. package/src/vee-validate/useTelField.ts +202 -0
  39. package/src/zod/index.ts +259 -0
  40. package/web-types.json +44 -1
@@ -15,13 +15,15 @@ import type { TelInputMessages } from '../types';
15
15
  * component needs:
16
16
  *
17
17
  * - `validation` / `validationState` — the raw + simplified state of the current input.
18
- * - `visibleValidationState` — `validationState` gated by the `hasFinishedTyping` flag
19
- * from {@link useTypingPhase}, so error tints / icons / messages only appear once the
20
- * user has paused. This is the value the template should bind to.
18
+ * - `visibleValidationState` — `validationState` gated by `validateOn` + the
19
+ * `hasFinishedTyping` flag from {@link useTypingPhase}, so error tints / icons /
20
+ * messages only appear at the right moment (after typing pause, after blur, or eagerly).
21
+ * This is the value the template should bind to.
21
22
  * - `errorMessage` — localised error string for the current `validation.reason`, or
22
- * `null` when the input is empty or valid.
23
+ * `null` when the input is empty / valid. When an external `error` is supplied
24
+ * (e.g. from VeeValidate), it wins.
23
25
  * - `showError` / `showHint` — boolean computed properties for conditional rendering
24
- * in the template; both already respect `showValidation` and the typing-pause gate.
26
+ * in the template; both already respect `showValidation` and the visible-state gate.
25
27
  * - `selectedDialCode` — the human-readable dial prefix (`+20`, `+1`, …) for the
26
28
  * selected country, used as an in-input prefix.
27
29
  *
@@ -43,6 +45,17 @@ export interface UseTelInputValidationDeps {
43
45
  getCountryByValue: UsePhoneValidationReturn['getCountryByValue'];
44
46
  }
45
47
 
48
+ /** When to surface validation in the UI.
49
+ * - `'change'` (default) — visible state mirrors the typing-paused state. Errors light up
50
+ * a beat after the user stops typing. Best for inline forms.
51
+ * - `'blur'` — visible state stays `'idle'` until the input has been blurred at least
52
+ * once, then mirrors typing-paused state. Best for VeeValidate / form-library flows
53
+ * that validate on blur.
54
+ * - `'eager'` — visible state mirrors raw `validationState` immediately on every keystroke,
55
+ * no typing pause. Use sparingly; can feel aggressive.
56
+ */
57
+ export type ATelInputValidateOn = 'change' | 'blur' | 'eager';
58
+
46
59
  export interface UseTelInputValidationInputs {
47
60
  /** Digits-only national number model. */
48
61
  phone: Ref<string>;
@@ -50,6 +63,8 @@ export interface UseTelInputValidationInputs {
50
63
  selectedIso2: Ref<string>;
51
64
  /** From {@link useTypingPhase} — gates visible state during the debounce window. */
52
65
  hasFinishedTyping: Readonly<Ref<boolean>>;
66
+ /** Whether the input has been blurred at least once. Drives `validateOn: 'blur'`. */
67
+ hasBlurred: Readonly<Ref<boolean>>;
53
68
  /** Resolved i18n messages (merged defaults + consumer overrides). */
54
69
  messages: ComputedRef<TelInputMessages>;
55
70
  }
@@ -61,6 +76,15 @@ export interface UseTelInputValidationConfig {
61
76
  showValidation: () => boolean | undefined;
62
77
  /** Per-reason error string overrides. From props. */
63
78
  errorMessages: () => Partial<Record<PhoneValidationReason, string>> | undefined;
79
+ /** When to surface validation in the UI. Defaults to `'change'`. */
80
+ validateOn: () => ATelInputValidateOn | undefined;
81
+ /**
82
+ * Externally controlled error (from VeeValidate / Zod / a custom form layer). When set
83
+ * to a non-empty string, the component is forced into `'error'` state and surfaces this
84
+ * message regardless of internal validation. `null` / `undefined` / `''` defers to the
85
+ * internal validator.
86
+ */
87
+ externalError: () => string | null | undefined;
64
88
  }
65
89
 
66
90
  export interface UseTelInputValidationReturn {
@@ -93,25 +117,40 @@ export function useTelInputValidation(
93
117
  })
94
118
  );
95
119
 
120
+ const externalErrorActive = computed<boolean>(() => {
121
+ const e = config.externalError();
122
+ return typeof e === 'string' && e.length > 0;
123
+ });
124
+
96
125
  const validationState = computed<'idle' | 'valid' | 'error'>(() => {
126
+ if (externalErrorActive.value) return 'error';
97
127
  if (!inputs.phone.value) return 'idle';
98
128
  return validation.value.ok ? 'valid' : 'error';
99
129
  });
100
130
 
101
- const visibleValidationState = computed<'idle' | 'valid' | 'error'>(() =>
102
- inputs.hasFinishedTyping.value ? validationState.value : 'idle'
103
- );
131
+ const visibleValidationState = computed<'idle' | 'valid' | 'error'>(() => {
132
+ if (externalErrorActive.value) return 'error';
133
+ const mode = config.validateOn() ?? 'change';
134
+ if (mode === 'eager') return validationState.value;
135
+ if (mode === 'blur' && !inputs.hasBlurred.value) return 'idle';
136
+ return inputs.hasFinishedTyping.value ? validationState.value : 'idle';
137
+ });
104
138
 
105
139
  const errorMessage = computed<string | null>(() => {
140
+ const ext = config.externalError();
141
+ if (typeof ext === 'string' && ext.length > 0) return ext;
106
142
  const v = validation.value;
107
143
  if (v.ok || !v.reason) return null;
108
144
  if (!inputs.phone.value) return null;
109
145
  return config.errorMessages()?.[v.reason] ?? inputs.messages.value.errorMessages[v.reason];
110
146
  });
111
147
 
112
- const showError = computed<boolean>(() =>
113
- Boolean(config.showValidation() && inputs.hasFinishedTyping.value && errorMessage.value)
114
- );
148
+ const showError = computed<boolean>(() => {
149
+ if (!errorMessage.value) return false;
150
+ if (externalErrorActive.value) return true;
151
+ if (!config.showValidation()) return false;
152
+ return visibleValidationState.value === 'error';
153
+ });
115
154
 
116
155
  const showHint = computed<boolean>(
117
156
  () => !showError.value && !inputs.phone.value && !!required.value?.format_hint
package/src/index.ts CHANGED
@@ -34,3 +34,5 @@ export * from './composables/useCountryDetection';
34
34
  export * from './composables/useCountryMatching';
35
35
  export * from './composables/useTypingPhase';
36
36
  export * from './composables/useTelInputValidation';
37
+ export * from './composables/useCountrySelection';
38
+ export * from './composables/useSyncedModel';
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,2 @@
1
+ export { useTelField } from './useTelField';
2
+ export type { UseTelFieldOptions, UseTelFieldReturn } from './useTelField';
@@ -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
+ }