@alikhalilll/a-tel-input 1.0.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/index.cjs +5846 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +791 -0
- package/dist/index.d.ts +791 -0
- package/dist/index.js +5804 -0
- package/dist/index.js.map +1 -0
- package/dist/nuxt/index.cjs +30 -0
- package/dist/nuxt/index.cjs.map +1 -0
- package/dist/nuxt/index.d.cts +15 -0
- package/dist/nuxt/index.d.ts +15 -0
- package/dist/nuxt/index.js +30 -0
- package/dist/nuxt/index.js.map +1 -0
- package/dist/resolver/index.cjs +25 -0
- package/dist/resolver/index.cjs.map +1 -0
- package/dist/resolver/index.d.cts +14 -0
- package/dist/resolver/index.d.ts +14 -0
- package/dist/resolver/index.js +25 -0
- package/dist/resolver/index.js.map +1 -0
- package/dist/styles.css +520 -0
- package/package.json +123 -0
- package/src/components/ACountryFlag.vue +78 -0
- package/src/components/ACountrySelect.vue +674 -0
- package/src/components/ATelInput.vue +742 -0
- package/src/composables/useCountryDetection.ts +247 -0
- package/src/composables/useCountryMatching.ts +213 -0
- package/src/composables/usePhoneValidation.ts +573 -0
- package/src/composables/useTelInputValidation.ts +136 -0
- package/src/composables/useTypingPhase.ts +88 -0
- package/src/icons/AlertCircleIcon.vue +17 -0
- package/src/icons/CheckCircleIcon.vue +16 -0
- package/src/icons/CheckIcon.vue +15 -0
- package/src/icons/ChevronDownIcon.vue +15 -0
- package/src/icons/SearchIcon.vue +16 -0
- package/src/icons/SpinnerIcon.vue +28 -0
- package/src/icons/index.ts +6 -0
- package/src/index.ts +36 -0
- package/src/nuxt/index.ts +37 -0
- package/src/resolver/index.ts +29 -0
- package/src/types.ts +389 -0
- package/src/utils/digits.ts +42 -0
- package/src/utils/flag-url.ts +10 -0
- package/web-types.json +526 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, useId, watch } from 'vue';
|
|
3
|
+
import { cn } from '@alikhalilll/a-ui-base';
|
|
4
|
+
import { usePhoneValidation } from '../composables/usePhoneValidation';
|
|
5
|
+
import { detectCountry, type DetectCountryOptions } from '../composables/useCountryDetection';
|
|
6
|
+
import { useCountryMatching } from '../composables/useCountryMatching';
|
|
7
|
+
import { useTypingPhase } from '../composables/useTypingPhase';
|
|
8
|
+
import { useTelInputValidation } from '../composables/useTelInputValidation';
|
|
9
|
+
import { DEFAULT_SIZE } from '@alikhalilll/a-ui-base';
|
|
10
|
+
import { resolveMessages, type ATelInputProps, type ATelInputSlots } from '../types';
|
|
11
|
+
import { normalizeDigits } from '../utils/digits';
|
|
12
|
+
import ACountrySelect from './ACountrySelect.vue';
|
|
13
|
+
import { CheckCircleIcon, AlertCircleIcon, SpinnerIcon } from '../icons';
|
|
14
|
+
|
|
15
|
+
const props = withDefaults(defineProps<ATelInputProps>(), {
|
|
16
|
+
placeholder: 'Phone number',
|
|
17
|
+
size: DEFAULT_SIZE,
|
|
18
|
+
detectCountry: 'auto',
|
|
19
|
+
defaultCountry: '',
|
|
20
|
+
ipEndpoint: 'https://ipapi.co/json/',
|
|
21
|
+
detectFromInput: true,
|
|
22
|
+
detectDebounceMs: 800,
|
|
23
|
+
showValidationIcon: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
defineSlots<ATelInputSlots>();
|
|
27
|
+
|
|
28
|
+
const phone = defineModel<string>('phone', { default: '' });
|
|
29
|
+
/** Public `v-model:country` — the **dial number** (e.g. `20` for Egypt, `44` for the UK,
|
|
30
|
+
* `1` for the NANP block). `null` means no country selected. Internally the component
|
|
31
|
+
* tracks a richer ISO2 code (`selectedIso2`) because dial codes alone can't disambiguate
|
|
32
|
+
* NANP (`+1` covers 25+ countries) — the picker still needs an exact country. */
|
|
33
|
+
const country = defineModel<number | null>('country', { default: null });
|
|
34
|
+
|
|
35
|
+
/** Internal source of truth — the ISO2 alpha-2 code of the picker selection. Synced with
|
|
36
|
+
* `country` (dial number) via watchers below. */
|
|
37
|
+
const selectedIso2 = ref<string>('');
|
|
38
|
+
|
|
39
|
+
const { getCountries, validate, getRequiredInfo, getCountryByValue, getCountriesByDial } =
|
|
40
|
+
usePhoneValidation();
|
|
41
|
+
// Pass the loaded lookups in — useCountryMatching can't call usePhoneValidation() itself
|
|
42
|
+
// because each invocation creates a fresh, empty country index.
|
|
43
|
+
const { resolveCountryIdentifier, dialNumberFor, matchLeadingDialCode } = useCountryMatching({
|
|
44
|
+
getCountryByValue,
|
|
45
|
+
getCountriesByDial,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
void getCountries();
|
|
49
|
+
|
|
50
|
+
const userPickedCountry = ref(false);
|
|
51
|
+
const autoSettingCountry = ref(false);
|
|
52
|
+
|
|
53
|
+
/** Silently resolved via IP/timezone/locale when `detectFromInput` is on — used as a hint
|
|
54
|
+
* so local-format numbers (e.g. Egyptian `01066105963`) can be parsed without a `+` prefix.
|
|
55
|
+
* Seeded from `defaultCountry` so it has a usable value before async detection resolves. */
|
|
56
|
+
const inferredCountry = ref<string>(resolveCountryIdentifier(props.defaultCountry));
|
|
57
|
+
|
|
58
|
+
/** Closure passed everywhere the matcher needs typing context (hint country + current
|
|
59
|
+
* selection for tier-3 tie-breaks). Avoids re-reading `selectedIso2`/`inferredCountry`
|
|
60
|
+
* at every call site. */
|
|
61
|
+
function tryMatchPhone(digits: string) {
|
|
62
|
+
return matchLeadingDialCode(digits, {
|
|
63
|
+
hintCountry: inferredCountry.value,
|
|
64
|
+
currentIso2: selectedIso2.value,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---------------------------------------------------------------
|
|
69
|
+
* Typing-phase state machine — owns `isDetecting`, `hasFinishedTyping`,
|
|
70
|
+
* `detectionAttempted` and the debounce timer. The `onSettle` callback
|
|
71
|
+
* runs at the end of every debounce window: it short-circuits when
|
|
72
|
+
* detection is disabled / the user already picked / input is empty,
|
|
73
|
+
* otherwise marks a detection attempt and applies any match.
|
|
74
|
+
* ------------------------------------------------------------- */
|
|
75
|
+
const typing = useTypingPhase({
|
|
76
|
+
debounceMs: computed(() => Math.max(0, props.detectDebounceMs)),
|
|
77
|
+
onSettle: () => {
|
|
78
|
+
if (!props.detectFromInput) return;
|
|
79
|
+
if (userPickedCountry.value || selectedIso2.value) return;
|
|
80
|
+
const current = phone.value;
|
|
81
|
+
if (!current) return;
|
|
82
|
+
|
|
83
|
+
typing.markDetectionAttempt();
|
|
84
|
+
|
|
85
|
+
const match = tryMatchPhone(current);
|
|
86
|
+
if (!match) return;
|
|
87
|
+
autoSettingCountry.value = true;
|
|
88
|
+
selectedIso2.value = match.country.value;
|
|
89
|
+
phone.value = match.nationalNumber;
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const { isDetecting, hasFinishedTyping, detectionAttempted } = typing;
|
|
93
|
+
|
|
94
|
+
onMounted(async () => {
|
|
95
|
+
if (selectedIso2.value) return; // v-model has an initial value — respect it.
|
|
96
|
+
|
|
97
|
+
// Explicit `defaultCountry` is treated as the initial picker value (the picker shows
|
|
98
|
+
// immediately) — this is how callers opt out of the hidden-until-detected default. Accepts
|
|
99
|
+
// either an ISO2 code (`'EG'`) or a dial-digit string (`'20'`, `'+20'`).
|
|
100
|
+
if (props.defaultCountry) {
|
|
101
|
+
const seed = resolveCountryIdentifier(props.defaultCountry);
|
|
102
|
+
if (seed) {
|
|
103
|
+
inferredCountry.value = seed;
|
|
104
|
+
autoSettingCountry.value = true;
|
|
105
|
+
selectedIso2.value = seed;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// No defaultCountry → run the environment chain. Used as a parsing hint in detect mode,
|
|
111
|
+
// or as the auto-fill source in legacy (`detectFromInput=false`) mode.
|
|
112
|
+
const detectOpts: DetectCountryOptions = {
|
|
113
|
+
strategy: props.detectCountry,
|
|
114
|
+
ipEndpoint: props.ipEndpoint,
|
|
115
|
+
defaultCountry: '',
|
|
116
|
+
};
|
|
117
|
+
let detected: string | null | undefined;
|
|
118
|
+
if (props.detector) {
|
|
119
|
+
try {
|
|
120
|
+
detected = await props.detector(detectOpts);
|
|
121
|
+
} catch {
|
|
122
|
+
detected = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!detected) {
|
|
126
|
+
detected = await detectCountry(detectOpts);
|
|
127
|
+
}
|
|
128
|
+
const iso2 = detected ? detected.toUpperCase() : '';
|
|
129
|
+
|
|
130
|
+
if (props.detectFromInput) {
|
|
131
|
+
inferredCountry.value = iso2;
|
|
132
|
+
// If the user has already typed something while detection was resolving, re-attempt
|
|
133
|
+
// matching now that we have a hint country for the libphonenumber national-format pass.
|
|
134
|
+
if (phone.value && !userPickedCountry.value && !selectedIso2.value) {
|
|
135
|
+
const match = tryMatchPhone(phone.value);
|
|
136
|
+
if (match) {
|
|
137
|
+
autoSettingCountry.value = true;
|
|
138
|
+
selectedIso2.value = match.country.value;
|
|
139
|
+
phone.value = match.nationalNumber;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!selectedIso2.value && iso2) {
|
|
145
|
+
autoSettingCountry.value = true;
|
|
146
|
+
selectedIso2.value = iso2;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** External → internal: when the caller mutates `v-model:country` (dial number), resolve
|
|
151
|
+
* it to an ISO2. If the current ISO2 already maps to this dial (e.g. user has Canada
|
|
152
|
+
* selected and the caller writes back `1`), keep the existing selection — don't churn it. */
|
|
153
|
+
watch(
|
|
154
|
+
country,
|
|
155
|
+
(next) => {
|
|
156
|
+
if (next == null) {
|
|
157
|
+
if (selectedIso2.value) selectedIso2.value = '';
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (dialNumberFor(selectedIso2.value) === next) return; // already in sync
|
|
161
|
+
const iso2 = resolveCountryIdentifier(String(next));
|
|
162
|
+
if (iso2) selectedIso2.value = iso2;
|
|
163
|
+
},
|
|
164
|
+
{ immediate: true }
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
/** Internal → external: keep `country` (dial number) in lockstep with `selectedIso2`, and
|
|
168
|
+
* flag "user manually picked from picker" when the change isn't one we initiated.
|
|
169
|
+
* `flush: 'sync'` so the `autoSettingCountry` guard is reliable. */
|
|
170
|
+
watch(
|
|
171
|
+
selectedIso2,
|
|
172
|
+
(iso2, prev) => {
|
|
173
|
+
const wasAutoSet = autoSettingCountry.value;
|
|
174
|
+
autoSettingCountry.value = false;
|
|
175
|
+
|
|
176
|
+
const nextDial = dialNumberFor(iso2);
|
|
177
|
+
if (country.value !== nextDial) country.value = nextDial;
|
|
178
|
+
|
|
179
|
+
if (!wasAutoSet && props.detectFromInput && iso2 && prev !== iso2) {
|
|
180
|
+
userPickedCountry.value = true;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{ flush: 'sync' }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/** The string shown in the `<input>`. Deliberately decoupled from `phone` (the digits-only
|
|
187
|
+
* model) so the visible field is NOT rewritten mid-edit — non-digits / alternative numerals
|
|
188
|
+
* are normalized into `phone` immediately, but the displayed value is only cleaned up once
|
|
189
|
+
* the user finishes typing (on blur / change). */
|
|
190
|
+
const displayValue = ref<string>(String(phone.value ?? ''));
|
|
191
|
+
|
|
192
|
+
/** Set when the in-flight `phone` change came from the user typing — tells the `phone`
|
|
193
|
+
* watcher to leave `displayValue` alone (the user is still editing it). */
|
|
194
|
+
let phoneEditedByInput = false;
|
|
195
|
+
|
|
196
|
+
function commitPhone(value: string) {
|
|
197
|
+
phoneEditedByInput = true;
|
|
198
|
+
phone.value = value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function handlePhoneInput(e: Event) {
|
|
202
|
+
const target = e.target as HTMLInputElement;
|
|
203
|
+
// Keep the visible value exactly as typed — don't rewrite it mid-edit. The model still
|
|
204
|
+
// receives a normalized, digits-only value so validation + detection stay correct.
|
|
205
|
+
displayValue.value = target.value;
|
|
206
|
+
// Fold alternative numerals (Arabic-Indic, Persian, …) to ASCII, then strip non-digits.
|
|
207
|
+
const cleaned = normalizeDigits(target.value).replace(/\D/g, '');
|
|
208
|
+
|
|
209
|
+
if (!cleaned) {
|
|
210
|
+
// Always reset on clear — even after a manual pick. Instant (not debounced) so the
|
|
211
|
+
// picker + spinner hide the moment the input goes empty.
|
|
212
|
+
typing.reset();
|
|
213
|
+
if (props.detectFromInput) {
|
|
214
|
+
autoSettingCountry.value = true;
|
|
215
|
+
selectedIso2.value = '';
|
|
216
|
+
userPickedCountry.value = false;
|
|
217
|
+
}
|
|
218
|
+
commitPhone('');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
typing.markTyping();
|
|
223
|
+
commitPhone(cleaned);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Fires when the user finishes editing (blur). Now it's safe to normalize the visible
|
|
227
|
+
* value — fold alternative numerals to ASCII and drop any stray non-digits. */
|
|
228
|
+
function handlePhoneChange(e: Event) {
|
|
229
|
+
const target = e.target as HTMLInputElement;
|
|
230
|
+
displayValue.value = normalizeDigits(target.value).replace(/\D/g, '');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
watch(
|
|
234
|
+
() => phone.value,
|
|
235
|
+
(next) => {
|
|
236
|
+
const cleaned = normalizeDigits(String(next ?? '')).replace(/\D/g, '');
|
|
237
|
+
// Normalize a programmatic value that arrived non-clean.
|
|
238
|
+
if (cleaned !== next) {
|
|
239
|
+
phone.value = cleaned;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// The user typing manages `displayValue` itself — don't fight their edit.
|
|
243
|
+
if (phoneEditedByInput) {
|
|
244
|
+
phoneEditedByInput = false;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
// External or detection-driven change → reflect it in the visible input.
|
|
248
|
+
displayValue.value = cleaned;
|
|
249
|
+
},
|
|
250
|
+
{ flush: 'post' }
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
/** Resolved UI strings — `messages` prop merged onto English defaults. The individual
|
|
254
|
+
* string props still win when both are set (see `errorMessage` / template bindings). */
|
|
255
|
+
const messages = computed(() => resolveMessages(props.messages));
|
|
256
|
+
|
|
257
|
+
/** `dir` of the outer wrapper — drives the hint/error text alignment and the country
|
|
258
|
+
* picker popover. Explicit `'ltr'`/`'rtl'` is applied; `'auto'` or an omitted prop yields
|
|
259
|
+
* `undefined` so it inherits from the page. The field row itself is always LTR so the
|
|
260
|
+
* dial prefix / digits / flag trigger keep a consistent order. */
|
|
261
|
+
const dirAttr = computed<'ltr' | 'rtl' | undefined>(() =>
|
|
262
|
+
props.dir === 'ltr' || props.dir === 'rtl' ? props.dir : undefined
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
/* ---------------------------------------------------------------
|
|
266
|
+
* Validation facade — wraps the raw `usePhoneValidation` calls and
|
|
267
|
+
* produces the view-layer surface (visible state gated by the typing
|
|
268
|
+
* pause, localised error message, conditional show flags, etc.).
|
|
269
|
+
* ------------------------------------------------------------- */
|
|
270
|
+
const {
|
|
271
|
+
validation,
|
|
272
|
+
required,
|
|
273
|
+
validationState,
|
|
274
|
+
visibleValidationState,
|
|
275
|
+
errorMessage,
|
|
276
|
+
showError,
|
|
277
|
+
showHint,
|
|
278
|
+
selectedDialCode,
|
|
279
|
+
} = useTelInputValidation(
|
|
280
|
+
{ validate, getRequiredInfo, getCountryByValue },
|
|
281
|
+
{ phone, selectedIso2, hasFinishedTyping, messages },
|
|
282
|
+
{
|
|
283
|
+
locale: () => props.locale,
|
|
284
|
+
showValidation: () => props.showValidation,
|
|
285
|
+
errorMessages: () => props.errorMessages,
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const effectivePlaceholder = computed(
|
|
290
|
+
() => props.placeholder || required.value?.format_hint || messages.value.phoneInputLabel
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
/* ---------------------------------------------------------------
|
|
294
|
+
* Accessibility — the helper line (hint or error) lives in a single
|
|
295
|
+
* `aria-live` region; the input's `aria-describedby` points at it
|
|
296
|
+
* whenever it has content.
|
|
297
|
+
* ------------------------------------------------------------- */
|
|
298
|
+
const helperId = useId();
|
|
299
|
+
const describedBy = computed(() => (showError.value || showHint.value ? helperId : undefined));
|
|
300
|
+
|
|
301
|
+
defineExpose({
|
|
302
|
+
validation,
|
|
303
|
+
required,
|
|
304
|
+
selectedDialCode,
|
|
305
|
+
validationState,
|
|
306
|
+
visibleValidationState,
|
|
307
|
+
isDetecting,
|
|
308
|
+
hasFinishedTyping,
|
|
309
|
+
detectionAttempted,
|
|
310
|
+
});
|
|
311
|
+
</script>
|
|
312
|
+
|
|
313
|
+
<template>
|
|
314
|
+
<div
|
|
315
|
+
:class="cn('a-tel-input', $attrs.class as string)"
|
|
316
|
+
:data-size="props.size"
|
|
317
|
+
:data-state="visibleValidationState"
|
|
318
|
+
:data-show-validation="props.showValidation ? '' : undefined"
|
|
319
|
+
data-slot="tel-input"
|
|
320
|
+
:dir="dirAttr"
|
|
321
|
+
>
|
|
322
|
+
<!-- The field row is forced LTR so its pieces (dial prefix, digits, flag trigger) keep
|
|
323
|
+
the same order regardless of page direction — phone numbers read left-to-right. -->
|
|
324
|
+
<div class="a-tel-input__row" dir="ltr">
|
|
325
|
+
<div
|
|
326
|
+
:class="cn('a-tel-input__field', props.class, props.fieldClass)"
|
|
327
|
+
:data-state="visibleValidationState"
|
|
328
|
+
>
|
|
329
|
+
<slot name="prefix" />
|
|
330
|
+
|
|
331
|
+
<span
|
|
332
|
+
v-if="selectedDialCode"
|
|
333
|
+
data-slot="tel-input-dial"
|
|
334
|
+
dir="ltr"
|
|
335
|
+
aria-hidden="true"
|
|
336
|
+
class="a-tel-input__dial"
|
|
337
|
+
>
|
|
338
|
+
{{ selectedDialCode }}
|
|
339
|
+
</span>
|
|
340
|
+
|
|
341
|
+
<input
|
|
342
|
+
:value="displayValue"
|
|
343
|
+
type="tel"
|
|
344
|
+
inputmode="numeric"
|
|
345
|
+
autocomplete="tel"
|
|
346
|
+
dir="ltr"
|
|
347
|
+
data-slot="tel-input-field"
|
|
348
|
+
:disabled="props.disabled || props.loading"
|
|
349
|
+
:placeholder="effectivePlaceholder"
|
|
350
|
+
:aria-label="messages.phoneInputLabel"
|
|
351
|
+
:aria-invalid="visibleValidationState === 'error' || undefined"
|
|
352
|
+
:aria-describedby="describedBy"
|
|
353
|
+
:class="cn('a-tel-input__input', props.inputClass)"
|
|
354
|
+
:data-has-dial="selectedDialCode ? '' : undefined"
|
|
355
|
+
@input="handlePhoneInput"
|
|
356
|
+
@change="handlePhoneChange"
|
|
357
|
+
/>
|
|
358
|
+
|
|
359
|
+
<!-- Detection-in-flight spinner — shown only during the first debounce window,
|
|
360
|
+
before the picker has appeared. Once the picker is visible (success OR a failed
|
|
361
|
+
attempt that revealed the empty picker) we stop re-flashing on every keystroke. -->
|
|
362
|
+
<Transition name="a-tell-detect">
|
|
363
|
+
<div
|
|
364
|
+
v-if="isDetecting && !selectedIso2 && !detectionAttempted"
|
|
365
|
+
class="a-tel-input__detecting"
|
|
366
|
+
aria-hidden="true"
|
|
367
|
+
data-slot="tel-input-detecting"
|
|
368
|
+
>
|
|
369
|
+
<slot name="detecting">
|
|
370
|
+
<SpinnerIcon class="a-tel-input__detecting-icon" />
|
|
371
|
+
</slot>
|
|
372
|
+
</div>
|
|
373
|
+
</Transition>
|
|
374
|
+
|
|
375
|
+
<Transition name="a-tell-country">
|
|
376
|
+
<!-- Wrapper div gives the <Transition> a single element root to animate.
|
|
377
|
+
ACountrySelect's root is the AResponsivePopover fragment (Popover/Drawer
|
|
378
|
+
swap), which a Transition can't animate directly — without this wrapper
|
|
379
|
+
Vue logs "Component inside <Transition> renders non-element root node". -->
|
|
380
|
+
<div
|
|
381
|
+
v-if="!props.detectFromInput || selectedIso2 || detectionAttempted"
|
|
382
|
+
class="a-tel-input__country-wrapper"
|
|
383
|
+
data-slot="tel-input-country-wrapper"
|
|
384
|
+
>
|
|
385
|
+
<ACountrySelect
|
|
386
|
+
v-model:selected="selectedIso2"
|
|
387
|
+
:allowed-dial-codes="props.allowedDialCodes"
|
|
388
|
+
:disabled="props.disabled || props.loading"
|
|
389
|
+
:size="props.size"
|
|
390
|
+
:locale="props.locale"
|
|
391
|
+
:search-placeholder="props.searchPlaceholder ?? messages.searchPlaceholder"
|
|
392
|
+
:empty-text="props.emptyText ?? messages.emptyText"
|
|
393
|
+
:loading-text="props.loadingText ?? messages.loadingText"
|
|
394
|
+
:suggested-label="messages.suggestedLabel"
|
|
395
|
+
:all-countries-label="messages.allCountriesLabel"
|
|
396
|
+
:country-label="messages.countryLabel"
|
|
397
|
+
:select-country-label="messages.selectCountryLabel"
|
|
398
|
+
:flag-url="props.flagUrl"
|
|
399
|
+
:searcher="props.searcher"
|
|
400
|
+
:countries="props.countries"
|
|
401
|
+
:content-class="props.contentClass"
|
|
402
|
+
:popover-class="props.popoverClass"
|
|
403
|
+
:drawer-class="props.drawerClass"
|
|
404
|
+
:scroll-lock="props.scrollLock"
|
|
405
|
+
>
|
|
406
|
+
<template v-if="$slots.trigger" #trigger="slotProps">
|
|
407
|
+
<slot name="trigger" v-bind="slotProps" />
|
|
408
|
+
</template>
|
|
409
|
+
<template v-if="$slots.chevron" #chevron="slotProps">
|
|
410
|
+
<slot name="chevron" v-bind="slotProps" />
|
|
411
|
+
</template>
|
|
412
|
+
<template v-if="$slots.flag" #flag="slotProps">
|
|
413
|
+
<slot name="flag" v-bind="slotProps" />
|
|
414
|
+
</template>
|
|
415
|
+
<template v-if="$slots.item" #item="slotProps">
|
|
416
|
+
<slot name="item" v-bind="slotProps" />
|
|
417
|
+
</template>
|
|
418
|
+
<template v-if="$slots['group-header']" #group-header="slotProps">
|
|
419
|
+
<slot name="group-header" v-bind="slotProps" />
|
|
420
|
+
</template>
|
|
421
|
+
<template v-if="$slots.search" #search="slotProps">
|
|
422
|
+
<slot name="search" v-bind="slotProps" />
|
|
423
|
+
</template>
|
|
424
|
+
<template v-if="$slots.loading" #loading>
|
|
425
|
+
<slot name="loading" />
|
|
426
|
+
</template>
|
|
427
|
+
<template v-if="$slots.empty" #empty="slotProps">
|
|
428
|
+
<slot name="empty" v-bind="slotProps" />
|
|
429
|
+
</template>
|
|
430
|
+
</ACountrySelect>
|
|
431
|
+
</div>
|
|
432
|
+
</Transition>
|
|
433
|
+
|
|
434
|
+
<slot name="suffix" :validation-state="validationState" :validation="validation" />
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<Transition v-if="props.showValidationIcon" name="a-tell-icon">
|
|
438
|
+
<slot v-if="visibleValidationState === 'valid'" name="valid-icon">
|
|
439
|
+
<CheckCircleIcon class="a-tel-input__icon a-tel-input__icon--valid" />
|
|
440
|
+
</slot>
|
|
441
|
+
<slot
|
|
442
|
+
v-else-if="visibleValidationState === 'error'"
|
|
443
|
+
name="error-icon"
|
|
444
|
+
:reason="validation.reason ?? ''"
|
|
445
|
+
>
|
|
446
|
+
<AlertCircleIcon class="a-tel-input__icon a-tel-input__icon--error" />
|
|
447
|
+
</slot>
|
|
448
|
+
</Transition>
|
|
449
|
+
</div>
|
|
450
|
+
|
|
451
|
+
<div :id="helperId" aria-live="polite">
|
|
452
|
+
<slot
|
|
453
|
+
v-if="showError"
|
|
454
|
+
name="error"
|
|
455
|
+
:message="errorMessage!"
|
|
456
|
+
:reason="validation.reason ?? ''"
|
|
457
|
+
:validation="validation"
|
|
458
|
+
>
|
|
459
|
+
<p
|
|
460
|
+
data-slot="tel-input-error"
|
|
461
|
+
:class="cn('a-tel-input__error', props.errorClass)"
|
|
462
|
+
role="alert"
|
|
463
|
+
>
|
|
464
|
+
{{ errorMessage }}
|
|
465
|
+
</p>
|
|
466
|
+
</slot>
|
|
467
|
+
<slot
|
|
468
|
+
v-else-if="showHint"
|
|
469
|
+
name="hint"
|
|
470
|
+
:country="selectedIso2"
|
|
471
|
+
:format-hint="required!.format_hint"
|
|
472
|
+
:example="required!.example_e164"
|
|
473
|
+
>
|
|
474
|
+
<p data-slot="tel-input-hint" :class="cn('a-tel-input__hint', props.hintClass)">
|
|
475
|
+
{{ required!.format_hint }}
|
|
476
|
+
</p>
|
|
477
|
+
</slot>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</template>
|
|
481
|
+
|
|
482
|
+
<style scoped>
|
|
483
|
+
/* ------------------------------------------------------------
|
|
484
|
+
* ATelInput — scoped CSS. All colors map to the global
|
|
485
|
+
* --ak-ui-* design tokens (defined in assets/styles.src.css) so
|
|
486
|
+
* dark mode + consumer theme overrides keep working.
|
|
487
|
+
* ---------------------------------------------------------- */
|
|
488
|
+
.a-tel-input {
|
|
489
|
+
display: flex;
|
|
490
|
+
width: 100%;
|
|
491
|
+
flex-direction: column;
|
|
492
|
+
gap: 0.375rem;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
.a-tel-input__row {
|
|
496
|
+
display: flex;
|
|
497
|
+
align-items: center;
|
|
498
|
+
gap: 0.5rem;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.a-tel-input__field {
|
|
502
|
+
display: flex;
|
|
503
|
+
width: 100%;
|
|
504
|
+
align-items: center;
|
|
505
|
+
overflow: hidden;
|
|
506
|
+
border: 1px solid hsl(var(--ak-ui-input));
|
|
507
|
+
background: hsl(var(--ak-ui-background));
|
|
508
|
+
border-radius: calc(var(--ak-ui-radius) - 2px);
|
|
509
|
+
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
510
|
+
transition:
|
|
511
|
+
border-color 150ms,
|
|
512
|
+
box-shadow 150ms;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.a-tel-input__field:focus-within {
|
|
516
|
+
outline: none;
|
|
517
|
+
box-shadow: 0 0 0 2px hsl(var(--ak-ui-ring) / 0.4);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.a-tel-input__field:has(input:disabled) {
|
|
521
|
+
cursor: not-allowed;
|
|
522
|
+
opacity: 0.5;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* Validation field colors — opt-in via `data-show-validation` on the root. */
|
|
526
|
+
.a-tel-input[data-show-validation] .a-tel-input__field[data-state='valid'] {
|
|
527
|
+
border-color: rgb(16 185 129 / 0.6);
|
|
528
|
+
box-shadow: 0 0 0 1px rgb(16 185 129 / 0.2);
|
|
529
|
+
}
|
|
530
|
+
.a-tel-input[data-show-validation] .a-tel-input__field[data-state='valid']:focus-within {
|
|
531
|
+
box-shadow: 0 0 0 2px rgb(16 185 129 / 0.4);
|
|
532
|
+
}
|
|
533
|
+
.a-tel-input[data-show-validation] .a-tel-input__field[data-state='error'] {
|
|
534
|
+
border-color: hsl(var(--ak-ui-destructive) / 0.8);
|
|
535
|
+
box-shadow: 0 0 0 1px hsl(var(--ak-ui-destructive) / 0.2);
|
|
536
|
+
}
|
|
537
|
+
.a-tel-input[data-show-validation] .a-tel-input__field[data-state='error']:focus-within {
|
|
538
|
+
box-shadow: 0 0 0 2px hsl(var(--ak-ui-destructive) / 0.4);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* Size variants — values mirror the shared Size scale (see utils/sizes.ts). */
|
|
542
|
+
.a-tel-input[data-size='xs'] .a-tel-input__field {
|
|
543
|
+
height: 1.75rem;
|
|
544
|
+
font-size: 0.75rem;
|
|
545
|
+
line-height: 1rem;
|
|
546
|
+
}
|
|
547
|
+
.a-tel-input[data-size='sm'] .a-tel-input__field {
|
|
548
|
+
height: 2.25rem;
|
|
549
|
+
font-size: 0.875rem;
|
|
550
|
+
line-height: 1.25rem;
|
|
551
|
+
}
|
|
552
|
+
.a-tel-input[data-size='md'] .a-tel-input__field {
|
|
553
|
+
height: 43px;
|
|
554
|
+
font-size: 0.875rem;
|
|
555
|
+
line-height: 1.25rem;
|
|
556
|
+
}
|
|
557
|
+
.a-tel-input[data-size='lg'] .a-tel-input__field {
|
|
558
|
+
height: 52px;
|
|
559
|
+
font-size: 1rem;
|
|
560
|
+
line-height: 1.5rem;
|
|
561
|
+
}
|
|
562
|
+
.a-tel-input[data-size='xl'] .a-tel-input__field {
|
|
563
|
+
height: 60px;
|
|
564
|
+
font-size: 1rem;
|
|
565
|
+
line-height: 1.5rem;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.a-tel-input__dial {
|
|
569
|
+
flex-shrink: 0;
|
|
570
|
+
color: hsl(var(--ak-ui-muted-foreground));
|
|
571
|
+
font-variant-numeric: tabular-nums;
|
|
572
|
+
user-select: none;
|
|
573
|
+
padding: 0 0.5rem;
|
|
574
|
+
}
|
|
575
|
+
.a-tel-input[data-size='xs'] .a-tel-input__dial {
|
|
576
|
+
font-size: 0.75rem;
|
|
577
|
+
}
|
|
578
|
+
.a-tel-input[data-size='sm'] .a-tel-input__dial,
|
|
579
|
+
.a-tel-input[data-size='md'] .a-tel-input__dial {
|
|
580
|
+
font-size: 0.875rem;
|
|
581
|
+
}
|
|
582
|
+
.a-tel-input[data-size='lg'] .a-tel-input__dial,
|
|
583
|
+
.a-tel-input[data-size='xl'] .a-tel-input__dial {
|
|
584
|
+
font-size: 1rem;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.a-tel-input__input {
|
|
588
|
+
height: 100%;
|
|
589
|
+
width: 100%;
|
|
590
|
+
min-width: 0;
|
|
591
|
+
flex: 1;
|
|
592
|
+
background: transparent;
|
|
593
|
+
font-variant-numeric: tabular-nums;
|
|
594
|
+
outline: none;
|
|
595
|
+
border: 0;
|
|
596
|
+
color: inherit;
|
|
597
|
+
font: inherit;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.a-tel-input__input::placeholder {
|
|
601
|
+
color: hsl(var(--ak-ui-muted-foreground));
|
|
602
|
+
}
|
|
603
|
+
.a-tel-input__input:disabled {
|
|
604
|
+
cursor: not-allowed;
|
|
605
|
+
}
|
|
606
|
+
.a-tel-input__input[data-has-dial] {
|
|
607
|
+
padding-inline-start: 0.25rem;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/* Per-size horizontal padding for the input itself. */
|
|
611
|
+
.a-tel-input[data-size='xs'] .a-tel-input__input {
|
|
612
|
+
padding-inline: 0.5rem;
|
|
613
|
+
}
|
|
614
|
+
.a-tel-input[data-size='sm'] .a-tel-input__input {
|
|
615
|
+
padding-inline: 0.625rem;
|
|
616
|
+
}
|
|
617
|
+
.a-tel-input[data-size='md'] .a-tel-input__input {
|
|
618
|
+
padding-inline: 0.75rem;
|
|
619
|
+
}
|
|
620
|
+
.a-tel-input[data-size='lg'] .a-tel-input__input {
|
|
621
|
+
padding-inline: 0.875rem;
|
|
622
|
+
}
|
|
623
|
+
.a-tel-input[data-size='xl'] .a-tel-input__input {
|
|
624
|
+
padding-inline: 1rem;
|
|
625
|
+
}
|
|
626
|
+
/* When the dial prefix is present, the input already inherits ps-1 via [data-has-dial]; collapse start padding. */
|
|
627
|
+
.a-tel-input__input[data-has-dial] {
|
|
628
|
+
padding-inline-start: 0.25rem;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
.a-tel-input__detecting {
|
|
632
|
+
display: inline-flex;
|
|
633
|
+
height: 100%;
|
|
634
|
+
flex-shrink: 0;
|
|
635
|
+
align-items: center;
|
|
636
|
+
padding: 0 0.5rem;
|
|
637
|
+
color: hsl(var(--ak-ui-muted-foreground));
|
|
638
|
+
}
|
|
639
|
+
.a-tel-input__detecting-icon {
|
|
640
|
+
width: 1rem;
|
|
641
|
+
height: 1rem;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
.a-tel-input__country-wrapper {
|
|
645
|
+
display: inline-flex;
|
|
646
|
+
height: 100%;
|
|
647
|
+
flex-shrink: 0;
|
|
648
|
+
align-items: center;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.a-tel-input__icon {
|
|
652
|
+
width: 1.25rem;
|
|
653
|
+
height: 1.25rem;
|
|
654
|
+
flex-shrink: 0;
|
|
655
|
+
}
|
|
656
|
+
.a-tel-input__icon--valid {
|
|
657
|
+
color: rgb(16 185 129);
|
|
658
|
+
}
|
|
659
|
+
.a-tel-input__icon--error {
|
|
660
|
+
color: hsl(var(--ak-ui-destructive));
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.a-tel-input__error {
|
|
664
|
+
color: hsl(var(--ak-ui-destructive));
|
|
665
|
+
font-size: 0.75rem;
|
|
666
|
+
line-height: 1rem;
|
|
667
|
+
margin: 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
.a-tel-input__hint {
|
|
671
|
+
color: hsl(var(--ak-ui-muted-foreground));
|
|
672
|
+
font-size: 0.75rem;
|
|
673
|
+
line-height: 1rem;
|
|
674
|
+
font-variant-numeric: tabular-nums;
|
|
675
|
+
margin: 0;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* Detecting spinner transition (collapsible width + fade). */
|
|
679
|
+
.a-tell-detect-enter-active {
|
|
680
|
+
transition:
|
|
681
|
+
opacity 200ms ease-out,
|
|
682
|
+
max-width 200ms ease-out;
|
|
683
|
+
overflow: hidden;
|
|
684
|
+
}
|
|
685
|
+
.a-tell-detect-leave-active {
|
|
686
|
+
transition:
|
|
687
|
+
opacity 150ms ease-in,
|
|
688
|
+
max-width 150ms ease-in;
|
|
689
|
+
overflow: hidden;
|
|
690
|
+
}
|
|
691
|
+
.a-tell-detect-enter-from,
|
|
692
|
+
.a-tell-detect-leave-to {
|
|
693
|
+
opacity: 0;
|
|
694
|
+
max-width: 0;
|
|
695
|
+
}
|
|
696
|
+
.a-tell-detect-enter-to,
|
|
697
|
+
.a-tell-detect-leave-from {
|
|
698
|
+
opacity: 1;
|
|
699
|
+
max-width: 2.5rem;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/* Country picker reveal/hide transition. */
|
|
703
|
+
.a-tell-country-enter-active {
|
|
704
|
+
transition:
|
|
705
|
+
opacity 200ms ease-out,
|
|
706
|
+
max-width 200ms ease-out;
|
|
707
|
+
overflow: hidden;
|
|
708
|
+
}
|
|
709
|
+
.a-tell-country-leave-active {
|
|
710
|
+
transition:
|
|
711
|
+
opacity 150ms ease-in,
|
|
712
|
+
max-width 150ms ease-in;
|
|
713
|
+
overflow: hidden;
|
|
714
|
+
}
|
|
715
|
+
.a-tell-country-enter-from,
|
|
716
|
+
.a-tell-country-leave-to {
|
|
717
|
+
opacity: 0;
|
|
718
|
+
max-width: 0;
|
|
719
|
+
}
|
|
720
|
+
.a-tell-country-enter-to,
|
|
721
|
+
.a-tell-country-leave-from {
|
|
722
|
+
opacity: 1;
|
|
723
|
+
max-width: 12rem;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/* Validation icon swap. */
|
|
727
|
+
.a-tell-icon-enter-active {
|
|
728
|
+
transition:
|
|
729
|
+
opacity 150ms ease-out,
|
|
730
|
+
transform 150ms ease-out;
|
|
731
|
+
}
|
|
732
|
+
.a-tell-icon-leave-active {
|
|
733
|
+
transition:
|
|
734
|
+
opacity 100ms ease-in,
|
|
735
|
+
transform 100ms ease-in;
|
|
736
|
+
}
|
|
737
|
+
.a-tell-icon-enter-from,
|
|
738
|
+
.a-tell-icon-leave-to {
|
|
739
|
+
opacity: 0;
|
|
740
|
+
transform: scale(0.9);
|
|
741
|
+
}
|
|
742
|
+
</style>
|