@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.
Files changed (42) hide show
  1. package/.media/README.md +3 -0
  2. package/.media/hero.png +0 -0
  3. package/README.md +597 -72
  4. package/dist/_chunks/types.d.ts +661 -0
  5. package/dist/_chunks/types.js +52 -0
  6. package/dist/_chunks/types.js.map +1 -0
  7. package/dist/_chunks/usePhoneValidation.js +539 -0
  8. package/dist/_chunks/usePhoneValidation.js.map +1 -0
  9. package/dist/index.cjs +471 -695
  10. package/dist/index.cjs.map +1 -1
  11. package/dist/index.d.cts +122 -587
  12. package/dist/index.d.ts +122 -587
  13. package/dist/index.js +454 -658
  14. package/dist/index.js.map +1 -1
  15. package/dist/styles.css +20 -5
  16. package/dist/vee-validate/index.cjs +113 -0
  17. package/dist/vee-validate/index.cjs.map +1 -0
  18. package/dist/vee-validate/index.d.cts +86 -0
  19. package/dist/vee-validate/index.d.ts +86 -0
  20. package/dist/vee-validate/index.js +112 -0
  21. package/dist/vee-validate/index.js.map +1 -0
  22. package/dist/zod/index.cjs +211 -0
  23. package/dist/zod/index.cjs.map +1 -0
  24. package/dist/zod/index.d.cts +65 -0
  25. package/dist/zod/index.d.ts +65 -0
  26. package/dist/zod/index.js +208 -0
  27. package/dist/zod/index.js.map +1 -0
  28. package/package.json +34 -3
  29. package/src/components/ACountrySelect.vue +17 -3
  30. package/src/components/ATelInput.vue +206 -66
  31. package/src/composables/useCountryDetection.ts +28 -11
  32. package/src/composables/useCountryMatching.ts +160 -20
  33. package/src/composables/useCountrySelection.ts +71 -0
  34. package/src/composables/usePhoneValidation.ts +81 -18
  35. package/src/composables/useSyncedModel.ts +80 -0
  36. package/src/composables/useTelInputValidation.ts +50 -11
  37. package/src/index.ts +2 -0
  38. package/src/types.ts +80 -0
  39. package/src/vee-validate/index.ts +2 -0
  40. package/src/vee-validate/useTelField.ts +202 -0
  41. package/src/zod/index.ts +259 -0
  42. package/web-types.json +44 -1
package/README.md CHANGED
@@ -1,41 +1,166 @@
1
- # @alikhalilll/a-tel-input
1
+ # `@alikhalilll/a-tel-input`
2
2
 
3
- Headless, [shadcn-vue](https://www.shadcn-vue.com) style Vue 3 **international telephone input**.
4
- Detects the country from what the user types (debounced, NANP-aware via
5
- [`libphonenumber-js`](https://github.com/catamphetamine/libphonenumber-js)), validates + formats the
6
- number, and offers a country picker that's a **popover on desktop / drawer on mobile**. RTL, i18n,
7
- and alternative-numeral (Arabic-Indic, Persian, Devanagari, Bengali) input all work out of the box.
3
+ <p align="center">
4
+ <img
5
+ src="https://raw.githubusercontent.com/alikhalilll/ali-nuxt-toolkit/master/packages/ui-components/ATelInput/.media/hero.png"
6
+ alt="ATelInput input on the left with the country picker open next to it, showing flags, country names, and dial codes"
7
+ width="820"
8
+ />
9
+ </p>
8
10
 
9
- Components: **`ATelInput`** · **`ACountrySelect`** · **`ACountryFlag`**.
11
+ <p align="center">
12
+ <sub>Headless picker — popover on desktop, bottom-sheet on mobile.</sub>
13
+ </p>
10
14
 
11
- ## Install
15
+ > A headless, shadcn-vue style **international telephone input** for Vue 3 / Nuxt 3+.
16
+ > Country auto-detect · libphonenumber-js validation · responsive picker (popover ⇆ drawer) ·
17
+ > RTL & i18n ready · first-class VeeValidate + Zod integration · server-side validation hook.
18
+
19
+ [![npm version](https://img.shields.io/npm/v/%40alikhalilll%2Fa-tel-input.svg?style=for-the-badge&label=npm&labelColor=0a0a0a&color=635bff)](https://www.npmjs.com/package/@alikhalilll/a-tel-input)
20
+ [![license](https://img.shields.io/npm/l/%40alikhalilll%2Fa-tel-input.svg?style=for-the-badge&labelColor=0a0a0a&color=635bff)](./LICENSE)
21
+ [![types](https://img.shields.io/npm/types/%40alikhalilll%2Fa-tel-input.svg?style=for-the-badge&labelColor=0a0a0a&color=635bff)](https://www.npmjs.com/package/@alikhalilll/a-tel-input)
12
22
 
13
23
  ```bash
24
+ # pnpm
14
25
  pnpm add @alikhalilll/a-tel-input
26
+
27
+ # npm
28
+ npm install @alikhalilll/a-tel-input
29
+
30
+ # yarn
31
+ yarn add @alikhalilll/a-tel-input
32
+
33
+ # bun
34
+ bun add @alikhalilll/a-tel-input
15
35
  ```
16
36
 
17
- (Pulls in `@alikhalilll/a-responsive-popover` → `a-popover` + `a-drawer` automatically. Design
18
- tokens are bundled into each component's `styles.css`, so there's no separate base package to install.)
37
+ ```vue
38
+ <!-- Single E.164 string what VeeValidate's <Field v-slot="{ field }"> + native forms expect -->
39
+ <ATelInput v-model="phone" default-country="SA" show-validation />
40
+
41
+ <!-- Or split phone + country into two v-models -->
42
+ <ATelInput v-model:phone="phone" v-model:country="country" default-country="SA" show-validation />
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Why this component
48
+
49
+ - **Universal country detection** — debounced parse against the **full libphonenumber
50
+ metadata (~250 countries)**. Works with international format (`+201066105963`) AND
51
+ local format (`01066105963`), with NANP disambiguation and a hint-priority chain
52
+ (env → current → recents → popular → all). No "only the popular countries" caveats.
53
+ - **Validates and formats** — error reasons, format hint, E.164 output, every keystroke.
54
+ - **Responsive surface** — popover on desktop, bottom-sheet drawer on mobile, sticky-safe
55
+ scroll lock on **both**. The page underneath never scrolls; the inner picker list does.
56
+ - **Headless slots for every region** — trigger, chevron, flag, item, search, hint,
57
+ error. Restyle the field down to the pixel without forking the logic.
58
+ - **First-class form-library integration** — controlled `error` prop, `@blur` event,
59
+ `useTelField()` composable for VeeValidate, `zPhone()` factory for Zod schemas, and a
60
+ `validating` spinner for async server-side checks ("is this number already registered?").
61
+ - **Two binding contracts, your pick** — single default `v-model` (E.164 string, drops
62
+ into VeeValidate's `<Field v-slot="{ field }">` via `v-bind="field"`), or split
63
+ `v-model:phone` + `v-model:country`. Both stay in sync.
64
+ - **i18n + RTL out of the box** — country names localised via `Intl.DisplayNames`,
65
+ alternative numerals (Arabic-Indic, Persian, Devanagari, Bengali) folded to ASCII on
66
+ input, RTL inherited from the page or forced via `dir`.
67
+ - **Efficient by default** — REST Countries fetch + IP geolocation request deduped to
68
+ one network call per page across every `<ATelInput>` / `<ACountrySelect>` /
69
+ `useTelField()` / `zPhone()` instance. LRU-cached matcher. `FALLBACK_COUNTRIES`
70
+ pre-seeded into the lookup indexes so detection works synchronously from first paint.
71
+ - **SSR-safe** — country detection runs only after mount, hydration is clean.
72
+ - **TypeScript-first** — every prop, slot, and event fully typed; web-types ship for
73
+ JetBrains IDEs.
74
+
75
+ ---
76
+
77
+ ## Table of contents
78
+
79
+ - [Install](#install)
80
+ - [Quick start](#quick-start)
81
+ - [Form integration](#form-integration)
82
+ - [VeeValidate + Zod](#veevalidate--zod)
83
+ - [Server-side validation](#server-side-validation-is-this-phone-already-registered)
84
+ - [Native HTML forms](#native-html-forms)
85
+ - [API reference](#api-reference)
86
+ - [Props](#props)
87
+ - [Events](#events)
88
+ - [Slots](#slots)
89
+ - [Exposed methods](#exposed-methods)
90
+ - [Composables](#composables)
91
+ - [Theming](#theming)
92
+ - [Accessibility](#accessibility)
93
+ - [Auto-import](#auto-import)
94
+ - [SSR](#ssr)
95
+ - [TypeScript](#typescript)
96
+ - [Browser support](#browser-support)
97
+ - [Troubleshooting](#troubleshooting)
98
+ - [License](#license)
99
+
100
+ ---
101
+
102
+ ## Install
103
+
104
+ Pick your package manager:
105
+
106
+ ```bash
107
+ # pnpm
108
+ pnpm add @alikhalilll/a-tel-input
19
109
 
20
- ## Styles
110
+ # npm
111
+ npm install @alikhalilll/a-tel-input
21
112
 
22
- The picker renders the popover/drawer, so import their stylesheets too:
113
+ # yarn
114
+ yarn add @alikhalilll/a-tel-input
115
+
116
+ # bun
117
+ bun add @alikhalilll/a-tel-input
118
+ ```
119
+
120
+ **One CSS import — everything in.** The popover, drawer, and design tokens are all
121
+ bundled into the same stylesheet:
23
122
 
24
123
  ```ts
25
- import '@alikhalilll/a-popover/styles.css';
26
- import '@alikhalilll/a-drawer/styles.css';
27
124
  import '@alikhalilll/a-tel-input/styles.css';
28
125
  ```
29
126
 
30
- ## Usage
127
+ No separate `a-popover` / `a-drawer` / `a-ui-base` imports needed.
128
+
129
+ ---
130
+
131
+ ## Quick start
132
+
133
+ The component supports **two binding contracts** — pick whichever fits your form code:
134
+
135
+ ### Single v-model (E.164 string)
136
+
137
+ The friendliest with VeeValidate's `<Field v-slot="{ field }">`, native HTML `<form>`
138
+ submission, and anything else that expects one canonical value:
31
139
 
32
140
  ```vue
33
141
  <script setup lang="ts">
34
142
  import { ref } from 'vue';
35
143
  import { ATelInput } from '@alikhalilll/a-tel-input';
36
144
 
37
- const phone = ref('');
38
- const country = ref<number | null>(null);
145
+ const phone = ref(''); // → '+201066105963'
146
+ </script>
147
+
148
+ <template>
149
+ <ATelInput v-model="phone" default-country="SA" show-validation />
150
+ </template>
151
+ ```
152
+
153
+ ### Split `v-model:phone` + `v-model:country`
154
+
155
+ When you want the raw national digits and the dial code as separate values:
156
+
157
+ ```vue
158
+ <script setup lang="ts">
159
+ import { ref } from 'vue';
160
+ import { ATelInput } from '@alikhalilll/a-tel-input';
161
+
162
+ const phone = ref(''); // → '1066105963'
163
+ const country = ref<number | null>(null); // → 20 (the dial code as a number)
39
164
  </script>
40
165
 
41
166
  <template>
@@ -43,82 +168,482 @@ const country = ref<number | null>(null);
43
168
  </template>
44
169
  ```
45
170
 
46
- By default the picker is hidden and reveals once a leading dial code is detected from typing. Pass
47
- `default-country` (ISO2 like `"EG"` or a dial code like `"20"`) to show it pre-selected, or
48
- `:detect-from-input="false"` for the legacy always-visible picker.
49
-
50
- ## API
51
-
52
- ### Key props
53
-
54
- | Prop | Type | Default | Description |
55
- | ------------------------------------- | ------------------------------ | -------- | -------------------------------------------------------- |
56
- | `phone` | `string` | — | `v-model:phone` — the raw national number. |
57
- | `country` | `number \| null` | — | `v-model:country` — selected dial code. |
58
- | `defaultCountry` | `string` | — | Initial country: ISO2 (`"EG"`) or dial code (`"20"`). |
59
- | `detectCountry` | `DetectionStrategy` | `auto` | Silent IP → timezone → `navigator.language` hint. |
60
- | `detectFromInput` | `boolean` | `true` | Reveal the picker on first dial-code match while typing. |
61
- | `detectDebounceMs` | `number` | — | Debounce for input-based detection. |
62
- | `allowedDialCodes` | `string[]` | — | Whitelist of dial codes; others render disabled. |
63
- | `size` | `Size` (`xs`–`xl`) | `md` | Control size. |
64
- | `dir` | `'ltr' \| 'rtl' \| 'auto'` | `auto` | Text direction (inherits from the page by default). |
65
- | `locale` | `string` | — | BCP-47 locale for country names + numerals. |
66
- | `messages` | `TelInputMessagesInput` | — | Localize every UI string in one bag. |
67
- | `showValidation` | `boolean` | `false` | Colour the field border/ring by validity. |
68
- | `showValidationIcon` | `boolean` | `false` | Show the valid/error icon at the field end. |
69
- | `disabled` / `loading` | `boolean` | `false` | Field state. |
70
- | `flagUrl` | `(iso2, width) => string` | — | Override the flag image source. |
71
- | `countries` / `searcher` / `detector` | — | — | Override the data sources. |
72
- | `ipEndpoint` | `string` | — | Override the IP-geolocation endpoint. |
73
- | `scrollLock` | `'events' \| 'body' \| 'none'` | `events` | Desktop scroll-lock behaviour of the picker. |
74
-
75
- Class hooks: `class`, `fieldClass`, `inputClass`, `contentClass`, `popoverClass`, `drawerClass`,
76
- `hintClass`, `errorClass`. Full prop/type reference:
77
- [docs](https://alikhalilll.github.io/ali-nuxt-toolkit/ui/tell-input).
171
+ | Binding | Type | Carries |
172
+ | ----------------- | ---------------- | --------------------------------------------------------------------- |
173
+ | `v-model` | `string` | Full E.164 string (`'+201066105963'`). Empty when invalid / blank. |
174
+ | `v-model:phone` | `string` | Digits-only national number (no `+`, no spaces). |
175
+ | `v-model:country` | `number \| null` | Dial-digit number (e.g. `20` for Egypt, `1` for NANP). `null` ≈ none. |
176
+
177
+ > The two contracts stay in sync — you can mix them, but most apps pick one and stick with it.
178
+
179
+ The picker is **hidden by default** until a leading dial code is detected from typing —
180
+ pass `default-country` to show it pre-selected, or `:detect-from-input="false"` for the
181
+ legacy always-visible picker.
182
+
183
+ ---
184
+
185
+ ## Form integration
186
+
187
+ `@alikhalilll/a-tel-input` ships two thin subpath entries so the same validation engine
188
+ that powers the in-field UI is also available to your form layer:
189
+
190
+ - **`@alikhalilll/a-tel-input/vee-validate`** `useTelField()` composable.
191
+ - **`@alikhalilll/a-tel-input/zod`** — `zPhone()` / `zPhoneObject()` schema factories.
192
+
193
+ Both `vee-validate` and `zod` are **optional peer dependencies** install them yourself.
194
+
195
+ ### Drop-in `<Field v-slot="{ field, errors }">` pattern
196
+
197
+ If you're already using VeeValidate's slot-style fields, **`v-bind="field"` just works**.
198
+ ATelInput's default `v-model` is the E.164 string, and Vue auto-spreads
199
+ `field.modelValue` + `field['onUpdate:modelValue']` + `field.name` + `field.onBlur`
200
+ from the slot prop directly onto the component:
201
+
202
+ ```vue
203
+ <script setup lang="ts">
204
+ import { useForm, Field as VeeField } from 'vee-validate';
205
+ import { toTypedSchema } from '@vee-validate/zod';
206
+ import { z } from 'zod';
207
+ import { ATelInput } from '@alikhalilll/a-tel-input';
208
+ import { zPhone } from '@alikhalilll/a-tel-input/zod';
209
+
210
+ const { handleSubmit } = useForm({
211
+ validationSchema: toTypedSchema(z.object({ phone: zPhone() })),
212
+ });
213
+ </script>
214
+
215
+ <template>
216
+ <form @submit="handleSubmit(onSubmit)">
217
+ <VeeField v-slot="{ field, errors }" name="phone">
218
+ <label for="phone">Phone</label>
219
+ <ATelInput
220
+ id="phone"
221
+ v-bind="field"
222
+ :error="errors[0]"
223
+ :aria-invalid="!!errors.length"
224
+ default-country="SA"
225
+ show-validation
226
+ />
227
+ </VeeField>
228
+ <button type="submit">Submit</button>
229
+ </form>
230
+ </template>
231
+ ```
232
+
233
+ That's it — no `useTelField()`, no manual wiring, no `handleBlur` to forward. `field`
234
+ provides everything; `:error="errors[0]"` surfaces the first error message in the
235
+ existing error region.
236
+
237
+ > Prefer `useTelField()` (below) when you also need async / server-side validation in
238
+ > flight, or when you want the helper to manage `defaultCountry` for you.
239
+
240
+ ### VeeValidate + Zod (with useTelField)
241
+
242
+ ```bash
243
+ # pnpm
244
+ pnpm add vee-validate @vee-validate/zod zod
245
+
246
+ # npm
247
+ npm install vee-validate @vee-validate/zod zod
248
+
249
+ # yarn
250
+ yarn add vee-validate @vee-validate/zod zod
251
+
252
+ # bun
253
+ bun add vee-validate @vee-validate/zod zod
254
+ ```
255
+
256
+ ```ts
257
+ import { useForm } from 'vee-validate';
258
+ import { toTypedSchema } from '@vee-validate/zod';
259
+ import { z } from 'zod';
260
+ import { useTelField } from '@alikhalilll/a-tel-input/vee-validate';
261
+ import { zPhone } from '@alikhalilll/a-tel-input/zod';
262
+
263
+ const { handleSubmit } = useForm({
264
+ validationSchema: toTypedSchema(z.object({ phone: zPhone() })),
265
+ });
266
+
267
+ const { phone, country, error, handleBlur, fieldProps } = useTelField('phone', {
268
+ validateOn: 'blur',
269
+ defaultCountry: 'SA',
270
+ });
271
+ ```
272
+
273
+ ```vue
274
+ <form @submit="handleSubmit(onSubmit)">
275
+ <ATelInput
276
+ v-model:phone="phone"
277
+ v-model:country="country"
278
+ v-bind="fieldProps"
279
+ :error="error"
280
+ @blur="handleBlur"
281
+ />
282
+ <button type="submit">Submit</button>
283
+ </form>
284
+ ```
285
+
286
+ `useTelField` composes the digits-only `phone` + the dial-code `country` into an E.164
287
+ string under the hood, and feeds **that** to VeeValidate's schema — so your Zod schema
288
+ validates a single canonical value while the component still binds to two v-models.
289
+
290
+ ### Server-side validation ("is this phone already registered?")
291
+
292
+ > **Important** — VeeValidate **ignores field-level `rules`** when `useForm` is given a
293
+ > `validationSchema`. To run an async server check, chain it onto the schema itself via
294
+ > `z.refine(async)`. `handleSubmit` awaits the schema, and `meta.pending` (which drives
295
+ > `useTelField`'s `validating` ref → the in-field spinner) follows the schema's async work.
296
+
297
+ ```ts
298
+ import { useForm } from 'vee-validate';
299
+ import { toTypedSchema } from '@vee-validate/zod';
300
+ import { z } from 'zod';
301
+ import { useTelField } from '@alikhalilll/a-tel-input/vee-validate';
302
+ import { zPhone } from '@alikhalilll/a-tel-input/zod';
303
+
304
+ // Build the schema: sync zPhone() first (cheap — runs locally via libphonenumber-js),
305
+ // then an async refine that hits your server. Refines run AFTER the parent passes, so
306
+ // the server is only contacted when the value is syntactically valid.
307
+ const phoneSchema = zPhone().refine(
308
+ async (value) => {
309
+ if (!value) return true;
310
+ const { exists } = await $fetch('/api/phone/exists', { query: { phone: value } });
311
+ return !exists;
312
+ },
313
+ { message: 'This phone number is already registered.' }
314
+ );
315
+
316
+ const { handleSubmit } = useForm({
317
+ validationSchema: toTypedSchema(z.object({ phone: phoneSchema })),
318
+ });
319
+
320
+ const { phone, country, error, handleBlur, fieldProps, validating } = useTelField('phone', {
321
+ validateOn: 'blur',
322
+ });
323
+ ```
324
+
325
+ ```vue
326
+ <ATelInput
327
+ v-model:phone="phone"
328
+ v-model:country="country"
329
+ v-bind="fieldProps"
330
+ :error="error"
331
+ :validating="validating"
332
+ show-validation
333
+ @blur="handleBlur"
334
+ />
335
+ ```
336
+
337
+ - `error` displays the server message in the existing error region.
338
+ - `validating` is `true` while the request is in flight — renders a small spinner inside
339
+ the field and sets `aria-busy="true"`. It does **not** disable the input.
340
+ - `handleSubmit` awaits the async refine before invoking your callback, so a failing
341
+ server check blocks submission automatically.
342
+
343
+ ### Native HTML forms
344
+
345
+ ```vue
346
+ <form>
347
+ <ATelInput v-model:phone="phone" v-model:country="country" name="phone" />
348
+ </form>
349
+ ```
350
+
351
+ `name` is forwarded to the inner `<input>` so `FormData` picks the value up. The submitted
352
+ value is the digits-only national number — compose the E.164 with `usePhoneValidation()`
353
+ in your submit handler if you want the international form.
354
+
355
+ ---
356
+
357
+ ## API reference
358
+
359
+ ### Props
360
+
361
+ | Prop | Type | Default | Description |
362
+ | ---------------------- | -------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
363
+ | `modelValue` | `string` | `''` | Default `v-model` — full **E.164** string (`'+201066105963'`). Drops directly into VeeValidate's `<Field v-slot="{ field }">` via `v-bind="field"`. |
364
+ | `phone` | `string` | `''` | `v-model:phone` — digits-only national number. |
365
+ | `country` | `number \| null` | `null` | `v-model:country` — selected dial-digit number (e.g. `20`). |
366
+ | `name` | `string` | — | Forwarded to the inner `<input name="">` for native form submission / form libraries. |
367
+ | `error` | `string \| null` | — | Externally controlled error message. When non-empty, overrides internal validation. |
368
+ | `validating` | `boolean` | `false` | `true` while an async validation is in flight. Renders a spinner inside the field. |
369
+ | `validateOn` | `'change' \| 'blur' \| 'eager'` | `'change'` | When to surface validation in the UI. |
370
+ | `defaultCountry` | `string` | — | Initial country — ISO2 (`'EG'`) or dial code (`'20'` / `'+20'`). |
371
+ | `detectCountry` | `DetectionStrategy` | `'auto'` | Silent country hint chain: IP → timezone → `navigator.language`. |
372
+ | `detectFromInput` | `boolean` | `true` | Reveal the picker on first dial-code match while typing. |
373
+ | `detectDebounceMs` | `number` | `800` | Debounce window for `detectFromInput`. |
374
+ | `allowedDialCodes` | `string[]` | — | Whitelist of dial codes; others render disabled. |
375
+ | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Control size — mirrors the shared `Size` scale. |
376
+ | `dir` | `'ltr' \| 'rtl' \| 'auto'` | `'auto'` | Text direction (inherits from the page by default). |
377
+ | `locale` | `string` | — | BCP-47 locale — localises country names + numerals in hints. |
378
+ | `messages` | `TelInputMessagesInput` | — | Bag of every UI string; merged onto English defaults. |
379
+ | `showValidation` | `boolean` | `false` | Colour the field border + error line by validity. |
380
+ | `showValidationIcon` | `boolean` | `false` | Show the valid / error icon at the field end. |
381
+ | `disabled` / `loading` | `boolean` | `false` | Field state. |
382
+ | `placeholder` | `string` | derived | Falls back to the country's `format_hint` when empty. |
383
+ | `flagUrl` | `(iso2, w) => string` | flagcdn | Override the flag image source. |
384
+ | `countries` | `CountryOption[]` | REST API | Provide your own country list (bypasses the REST Countries fetch). |
385
+ | `searcher` | `(q, c) => boolean` | substring | Custom search predicate. |
386
+ | `detector` | `async (opts) => string \| null` | built-in | Fully custom country detection. |
387
+ | `ipEndpoint` | `string` | `ipapi.co` | Override the IP geolocation endpoint. |
388
+ | `scrollLock` | `'events' \| 'body' \| 'none'` | `'events'` | How page-scroll is blocked while the picker is open. Applies on both desktop and mobile. |
389
+ | Class hooks | `string` | — | `class`, `fieldClass`, `inputClass`, `contentClass`, `popoverClass`, `drawerClass`, `hintClass`, `errorClass`. |
390
+ | Localised strings | `string` | — | `searchPlaceholder`, `emptyText`, `loadingText`, `errorMessages`. |
391
+
392
+ > The full prop / type reference (with every default and every JSDoc note) lives in
393
+ > [`src/types.ts`](./src/types.ts) and is published as part of the package types.
78
394
 
79
395
  ### Events
80
396
 
81
- | Event | Payload |
82
- | ---------------- | ---------------- |
83
- | `update:phone` | `string` |
84
- | `update:country` | `number \| null` |
397
+ | Event | Payload | Fires when |
398
+ | ------------------- | ---------------- | ---------------------------------------------------------- |
399
+ | `update:modelValue` | `string` | Composed E.164 string changed (the default `v-model`). |
400
+ | `update:phone` | `string` | Digits-only national number changed. |
401
+ | `update:country` | `number \| null` | Dial-code number changed. |
402
+ | `blur` | `FocusEvent` | Inner input lost focus (also flips internal `hasBlurred`). |
403
+ | `focus` | `FocusEvent` | Inner input gained focus. |
85
404
 
86
405
  ### Slots
87
406
 
88
- Every visual region is a slot, so you can recompose the field entirely:
89
- `prefix`, `suffix`, `trigger`, `chevron`, `flag`, `search`, `loading`, `empty`, `detecting`,
90
- `group-header`, `item`, `valid-icon`, `error-icon`, `hint`, `error`. Slots receive typed context —
91
- e.g. `error` gets `{ message, reason, validation }`, `flag` gets `{ country, context }`.
407
+ Every visual region is a slot the component is fully recomposable.
408
+
409
+ | Slot | Props |
410
+ | -------------- | ------------------------------------------- |
411
+ | `prefix` | — |
412
+ | `suffix` | `{ validationState, validation }` |
413
+ | `trigger` | `{ selectedCountry, open, sizeClasses }` |
414
+ | `chevron` | `{ open }` |
415
+ | `flag` | `{ country, context: 'trigger' \| 'item' }` |
416
+ | `item` | `{ country, selected, disabled, select }` |
417
+ | `group-header` | `{ label, group: 'suggested' \| 'all' }` |
418
+ | `search` | `{ value, setValue, isSearching }` |
419
+ | `loading` | — |
420
+ | `empty` | `{ query }` |
421
+ | `detecting` | — (during country detection) |
422
+ | `validating` | — (during async form validation) |
423
+ | `valid-icon` | — |
424
+ | `error-icon` | `{ reason }` |
425
+ | `hint` | `{ country, formatHint, example }` |
426
+ | `error` | `{ message, reason, validation }` |
427
+
428
+ ### Exposed methods
429
+
430
+ Reach these via `<ATelInput ref="tel" />` → `tel.value?.<method>()`:
431
+
432
+ | Member | Type | Notes |
433
+ | ------------------------ | ------------------------------------------- | ----------------------------------------------- |
434
+ | `validation` | `ComputedRef<PhoneValidationResult>` | Full validation result. |
435
+ | `required` | `ComputedRef<PhoneRequiredInfo \| null>` | Country format hint + example E.164. |
436
+ | `selectedDialCode` | `ComputedRef<string \| null>` | `+`-prefixed dial code of the selected country. |
437
+ | `validationState` | `ComputedRef<'idle' \| 'valid' \| 'error'>` | Raw state (no typing-pause gating). |
438
+ | `visibleValidationState` | `ComputedRef<'idle' \| 'valid' \| 'error'>` | UI-surfacing state (gated by `validateOn`). |
439
+ | `isDetecting` | `Readonly<Ref<boolean>>` | `true` during the first debounce window. |
440
+ | `hasFinishedTyping` | `Readonly<Ref<boolean>>` | Flips after the debounce settles. |
441
+ | `detectionAttempted` | `Readonly<Ref<boolean>>` | `true` after at least one detection pass. |
442
+ | `focus(options?)` | `() => void` | Focus the inner `<input>`. |
443
+ | `blur()` | `() => void` | Blur the inner `<input>`. |
444
+ | `select()` | `() => void` | Select the inner `<input>`'s text. |
445
+
446
+ ### Composables
447
+
448
+ Re-exported from the main entry — compose your own field from the same primitives the
449
+ component uses.
450
+
451
+ | Symbol | Source path | Purpose |
452
+ | ------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
453
+ | `usePhoneValidation` | `@alikhalilll/a-tel-input` | The libphonenumber-js wrapper — `validate`, `getRequiredInfo`, `searchCountries`, `getCountryByValue`, `getCountriesByDial`. |
454
+ | `useCountryMatching` | `@alikhalilll/a-tel-input` | Longest-prefix dial-code matching with tier-3 NANP tie-break. |
455
+ | `detectCountry` | `@alikhalilll/a-tel-input` | The IP → timezone → locale → default chain (callable standalone). |
456
+ | `useTypingPhase` | `@alikhalilll/a-tel-input` | Debounced typing-pause state machine. |
457
+ | `useTelInputValidation` | `@alikhalilll/a-tel-input` | View-layer facade (visible state, error message, show flags). |
458
+ | `useCountrySelection` | `@alikhalilll/a-tel-input` | Picker selection state machine (`iso2` + `source` enum + `detectionLocked`). The single state machine the component uses internally — useful when composing your own field. |
459
+ | `useSyncedModel` | `@alikhalilll/a-tel-input` | Generic bidirectional sync between a `defineModel` ref and internal state, with the echo-loop guard built in. Reusable in any v-model bridge. |
460
+ | `useTelField` | `@alikhalilll/a-tel-input/vee-validate` | VeeValidate adapter — see [Form integration](#form-integration). |
461
+ | `zPhone` / `zPhoneObject` | `@alikhalilll/a-tel-input/zod` | Zod schema factories — see [Form integration](#form-integration). |
462
+ | `normalizeDigits` | `@alikhalilll/a-tel-input` | Fold Arabic-Indic / Persian / Devanagari / Bengali numerals → ASCII. |
463
+ | `defaultFlagUrl` | `@alikhalilll/a-tel-input` | Default flagcdn URL builder. |
464
+
465
+ ---
466
+
467
+ ## Theming
468
+
469
+ The component renders with neutral defaults and reads global design tokens — restyle from
470
+ your app's stylesheet without touching the component itself.
471
+
472
+ ### CSS custom properties
473
+
474
+ Set these on `:root` (or any ancestor) to retint the component:
475
+
476
+ | Token | Used for |
477
+ | -------------------------- | ------------------------------------- |
478
+ | `--ak-ui-background` | Field + popover/drawer background. |
479
+ | `--ak-ui-foreground` | Field + popover/drawer text. |
480
+ | `--ak-ui-input` | Field border. |
481
+ | `--ak-ui-ring` | Focus ring colour. |
482
+ | `--ak-ui-muted-foreground` | Dial prefix, hint text, placeholder. |
483
+ | `--ak-ui-destructive` | Error border / ring / icon / message. |
484
+ | `--ak-ui-radius` | Field corner radius. |
485
+
486
+ Valid / error tints (green / red) read literal values — override via the class hooks below.
487
+
488
+ ### Class hooks
489
+
490
+ Each visual region accepts a class prop you can target with utility classes (Tailwind,
491
+ your own utility framework, or plain CSS):
92
492
 
93
- ### Composables & helpers (also exported)
493
+ | Prop | Targets |
494
+ | -------------- | -------------------------------------------------------------------------- |
495
+ | `class` | The root wrapper (`.a-tel-input`). |
496
+ | `fieldClass` | The field row that contains input + dial + picker (`.a-tel-input__field`). |
497
+ | `inputClass` | The actual `<input>` element. |
498
+ | `hintClass` | The hint paragraph below the field. |
499
+ | `errorClass` | The error paragraph below the field. |
500
+ | `popoverClass` | The desktop popover surface. |
501
+ | `drawerClass` | The mobile drawer surface. |
502
+ | `contentClass` | Both branches (applied alongside `popoverClass` / `drawerClass`). |
94
503
 
95
- `usePhoneValidation`, `useCountryDetection`, `useCountryMatching`, `useTypingPhase`,
96
- `useTelInputValidation`, `normalizeDigits`, `LOCALE_DIGIT_RANGES`, `defaultFlagUrl`,
97
- `aTelInputVariants`, `DEFAULT_MESSAGES`, `resolveMessages` compose your own field from the same
98
- primitives `ATelInput` uses (see `ACountrySelect` / `AInput`).
504
+ ### Stateful selectors
505
+
506
+ - `[data-state="idle" | "valid" | "error"]` on the root and on `.a-tel-input__field`.
507
+ - `[data-size="xs" | "sm" | "md" | "lg" | "xl"]` on the root.
508
+ - `[data-show-validation]` on the root when `showValidation` is on.
509
+ - `[aria-invalid="true"]` on the input when the surfaced state is error.
510
+ - `[aria-busy="true"]` on the input when `validating` is true.
511
+
512
+ ### Dark mode
513
+
514
+ Light / dark is driven entirely by the `--ak-ui-*` tokens — set them to dark values under
515
+ `[data-theme="dark"]` (or however your app gates dark mode) and everything follows.
516
+
517
+ ---
518
+
519
+ ## Accessibility
520
+
521
+ - `aria-label` on the inner `<input>` (overrideable via `messages.phoneInputLabel`).
522
+ - `aria-invalid="true"` mirrors the surfaced error state.
523
+ - `aria-describedby` points at the hint / error line whenever it has content; the line is
524
+ an `aria-live="polite"` region so screen readers announce new errors.
525
+ - `aria-errormessage` points at the same line when the field is in error.
526
+ - `aria-busy="true"` on the input while `validating` is on.
527
+ - Country picker is keyboard-navigable — arrow keys, `/` to focus search, Enter to pick,
528
+ Esc to close.
529
+ - Focus management is handled by the underlying popover/drawer — focus returns to the
530
+ trigger on close.
531
+
532
+ ---
99
533
 
100
534
  ## Auto-import
101
535
 
102
- **Nuxt:**
536
+ ### Nuxt
103
537
 
104
538
  ```ts
105
539
  export default defineNuxtConfig({
106
540
  modules: ['@alikhalilll/a-tel-input/nuxt'],
107
- css: [
108
- '@alikhalilll/a-popover/styles.css',
109
- '@alikhalilll/a-drawer/styles.css',
110
- '@alikhalilll/a-tel-input/styles.css',
111
- ],
541
+ // The single tel-input stylesheet already bundles popover + drawer styles + design tokens.
542
+ css: ['@alikhalilll/a-tel-input/styles.css'],
112
543
  });
113
544
  ```
114
545
 
115
- **Vite:** register `@alikhalilll/a-tel-input/resolver` in `unplugin-vue-components`.
546
+ ### Vite + `unplugin-vue-components`
547
+
548
+ Register `@alikhalilll/a-tel-input/resolver`:
549
+
550
+ ```ts
551
+ import Components from 'unplugin-vue-components/vite';
552
+ import { ATelInputResolver } from '@alikhalilll/a-tel-input/resolver';
553
+
554
+ export default { plugins: [Components({ resolvers: [ATelInputResolver()] })] };
555
+ ```
556
+
557
+ ---
116
558
 
117
559
  ## SSR
118
560
 
119
- Country detection runs only in `onMounted` — the field renders immediately with `defaultCountry`
120
- (or empty), and IP/timezone/locale resolution patches in on hydration. No SSR network calls.
561
+ Country detection runs **only inside `onMounted`** — the field renders immediately with
562
+ `defaultCountry` (or empty) on the server, and the IP / timezone / locale chain patches
563
+ in on hydration. There are no SSR network calls, and `useEventScrollLock` short-circuits
564
+ when `document` is unavailable.
565
+
566
+ If `default-country` is set, the picker is visible at first paint and hydration is a
567
+ no-op visually. If you rely on `detect-from-input`, the picker stays hidden until the
568
+ client-side parser sees a leading dial code — also hydration-safe.
569
+
570
+ ---
571
+
572
+ ## TypeScript
573
+
574
+ Import the public types from the main entry:
575
+
576
+ ```ts
577
+ import type {
578
+ ATelInputProps,
579
+ ATelInputEmits,
580
+ ATelInputSlots,
581
+ ATelInputSize,
582
+ ATelInputDir,
583
+ ATelInputValidateOn,
584
+ TelInputMessages,
585
+ TelInputMessagesInput,
586
+ PhoneValidationResult,
587
+ PhoneValidationReason,
588
+ PhoneRequiredInfo,
589
+ CountryOption,
590
+ } from '@alikhalilll/a-tel-input';
591
+ ```
592
+
593
+ Slot props are inferable in templates:
594
+
595
+ ```vue
596
+ <ATelInput #suffix="{ validationState, validation }"> … </ATelInput>
597
+ ```
598
+
599
+ Or in script:
600
+
601
+ ```ts
602
+ type SuffixProps = Parameters<NonNullable<ATelInputSlots['suffix']>>[0];
603
+ ```
604
+
605
+ ---
606
+
607
+ ## Browser support
608
+
609
+ Modern evergreen browsers — last two versions of Chrome, Edge, Firefox, Safari, and the
610
+ matching mobile WebViews. Uses `Intl.DisplayNames` for localized country names (universal
611
+ since 2020). No polyfills required.
612
+
613
+ ---
614
+
615
+ ## Troubleshooting
616
+
617
+ **Why is the picker hidden until I type?**
618
+ `detectFromInput` is on by default — the field starts as a single clean input and the
619
+ picker reveals once a leading dial code is recognised. Pass `default-country="EG"` (or
620
+ any ISO2 / dial-code string) to show it pre-selected, or `:detect-from-input="false"`
621
+ for the legacy always-visible picker.
622
+
623
+ **How do I show validation only after blur?**
624
+ `validateOn="blur"`. Or use `useTelField()` — its `fieldProps` already includes that.
625
+
626
+ **I want a single E.164 value out of the field.**
627
+ Two options. From a form: `useTelField()` tracks the E.164 string as VeeValidate's value
628
+ (see [Form integration](#form-integration)). Without a form: `tellRef.value?.validation.full_phone`.
629
+
630
+ **My Zod schema rejects a valid number.**
631
+ Check `allowedDialCodes` and `country` — `zPhone({ country: 'EG' })` expects the **national**
632
+ digits (`'1066105963'`), while `zPhone()` (no `country`) expects the **E.164** form
633
+ (`'+201066105963'`). Use `zPhoneObject()` if you want to pass `{ phone, country }` directly.
634
+
635
+ **The page underneath the drawer scrolls.**
636
+ That was a bug in versions < 1.1.0 — the event scroll-lock was desktop-only. Upgrade.
637
+ If you need the legacy `body { overflow: hidden }` style instead, set `scroll-lock="body"`.
638
+
639
+ **Country auto-detect didn't work.**
640
+ The default `ipEndpoint` is `https://ipapi.co/json/` — it's free-tier rate-limited.
641
+ Either provide your own endpoint (`ip-endpoint="/api/geo"` returning `{ country_code }`),
642
+ swap the entire chain via the `detector` prop, or disable IP and fall through to timezone:
643
+ `detect-country="locale"`.
644
+
645
+ ---
121
646
 
122
647
  ## License
123
648
 
124
- MIT
649
+ [MIT](./LICENSE) © alikhalilll