@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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/dist/index.cjs +5846 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +791 -0
  6. package/dist/index.d.ts +791 -0
  7. package/dist/index.js +5804 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/nuxt/index.cjs +30 -0
  10. package/dist/nuxt/index.cjs.map +1 -0
  11. package/dist/nuxt/index.d.cts +15 -0
  12. package/dist/nuxt/index.d.ts +15 -0
  13. package/dist/nuxt/index.js +30 -0
  14. package/dist/nuxt/index.js.map +1 -0
  15. package/dist/resolver/index.cjs +25 -0
  16. package/dist/resolver/index.cjs.map +1 -0
  17. package/dist/resolver/index.d.cts +14 -0
  18. package/dist/resolver/index.d.ts +14 -0
  19. package/dist/resolver/index.js +25 -0
  20. package/dist/resolver/index.js.map +1 -0
  21. package/dist/styles.css +520 -0
  22. package/package.json +123 -0
  23. package/src/components/ACountryFlag.vue +78 -0
  24. package/src/components/ACountrySelect.vue +674 -0
  25. package/src/components/ATelInput.vue +742 -0
  26. package/src/composables/useCountryDetection.ts +247 -0
  27. package/src/composables/useCountryMatching.ts +213 -0
  28. package/src/composables/usePhoneValidation.ts +573 -0
  29. package/src/composables/useTelInputValidation.ts +136 -0
  30. package/src/composables/useTypingPhase.ts +88 -0
  31. package/src/icons/AlertCircleIcon.vue +17 -0
  32. package/src/icons/CheckCircleIcon.vue +16 -0
  33. package/src/icons/CheckIcon.vue +15 -0
  34. package/src/icons/ChevronDownIcon.vue +15 -0
  35. package/src/icons/SearchIcon.vue +16 -0
  36. package/src/icons/SpinnerIcon.vue +28 -0
  37. package/src/icons/index.ts +6 -0
  38. package/src/index.ts +36 -0
  39. package/src/nuxt/index.ts +37 -0
  40. package/src/resolver/index.ts +29 -0
  41. package/src/types.ts +389 -0
  42. package/src/utils/digits.ts +42 -0
  43. package/src/utils/flag-url.ts +10 -0
  44. 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>