@alikhalilll/ui 1.2.2 → 1.2.4

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 (47) hide show
  1. package/dist/entries/drawer/components/ADrawer.vue.d.ts +1 -5
  2. package/dist/entries/drawer/components/ADrawerContent.vue.d.ts +1 -5
  3. package/dist/entries/drawer/components/ADrawerTrigger.vue.d.ts +1 -5
  4. package/dist/entries/input/components/AInput.vue.d.ts +1 -5
  5. package/dist/entries/popover/components/APopover.vue.d.ts +1 -5
  6. package/dist/entries/popover/components/APopoverContent.vue.d.ts +1 -5
  7. package/dist/entries/popover/components/APopoverTrigger.vue.d.ts +1 -5
  8. package/dist/entries/responsive-popover/components/AResponsivePopover.vue.d.ts +1 -5
  9. package/dist/entries/responsive-popover/components/AResponsivePopoverContent.vue.d.ts +1 -5
  10. package/dist/entries/responsive-popover/components/AResponsivePopoverTrigger.vue.d.ts +1 -5
  11. package/dist/entries/tell-input/components/ACountryFlag.vue.d.ts +1 -5
  12. package/dist/entries/tell-input/components/ACountrySelect.vue.d.ts +1 -5
  13. package/dist/entries/tell-input/components/ATellInput.vue.d.ts +1 -5
  14. package/entries/drawer/components/ADrawer.vue +16 -0
  15. package/entries/drawer/components/ADrawerContent.vue +35 -0
  16. package/entries/drawer/components/ADrawerOverlay.vue +25 -0
  17. package/entries/drawer/components/ADrawerTrigger.vue +13 -0
  18. package/entries/drawer/index.ts +4 -0
  19. package/entries/input/components/AInput.vue +111 -0
  20. package/entries/input/index.ts +1 -0
  21. package/entries/popover/components/APopover.vue +19 -0
  22. package/entries/popover/components/APopoverContent.vue +65 -0
  23. package/entries/popover/components/APopoverOverlay.vue +69 -0
  24. package/entries/popover/components/APopoverTrigger.vue +13 -0
  25. package/entries/popover/composables/useEventScrollLock.ts +193 -0
  26. package/entries/popover/index.ts +8 -0
  27. package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
  28. package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
  29. package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
  30. package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
  31. package/entries/responsive-popover/index.ts +3 -0
  32. package/entries/tell-input/components/ACountryFlag.vue +68 -0
  33. package/entries/tell-input/components/ACountrySelect.vue +522 -0
  34. package/entries/tell-input/components/ATellInput.vue +616 -0
  35. package/entries/tell-input/composables/useCountryDetection.ts +247 -0
  36. package/entries/tell-input/composables/useCountryMatching.ts +213 -0
  37. package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
  38. package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
  39. package/entries/tell-input/composables/useTypingPhase.ts +88 -0
  40. package/entries/tell-input/index.ts +29 -0
  41. package/entries/tell-input/utils/digits.ts +42 -0
  42. package/entries/tell-input/utils/flag-url.ts +10 -0
  43. package/entries/tell-input/utils/types.ts +169 -0
  44. package/package.json +4 -1
  45. package/utils/cn.ts +6 -0
  46. package/utils/index.ts +10 -0
  47. package/utils/sizes.ts +48 -0
@@ -0,0 +1,616 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, useId, watch } from 'vue';
3
+ import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-vue-next';
4
+ import { cn } from '@/utils';
5
+ import {
6
+ usePhoneValidation,
7
+ type CountryOption,
8
+ type PhoneValidationResult,
9
+ } from '../composables/usePhoneValidation';
10
+ import { detectCountry, type DetectCountryOptions } from '../composables/useCountryDetection';
11
+ import { useCountryMatching } from '../composables/useCountryMatching';
12
+ import { useTypingPhase } from '../composables/useTypingPhase';
13
+ import { useTellInputValidation } from '../composables/useTellInputValidation';
14
+ import { controlPaddingX, controlTextSize, DEFAULT_SIZE } from '@/utils';
15
+ import { aTellInputVariants, resolveMessages, type ATellInputProps } from '../utils/types';
16
+ import { normalizeDigits } from '../utils/digits';
17
+ import ACountrySelect from './ACountrySelect.vue';
18
+
19
+ interface ExtendedProps extends ATellInputProps {
20
+ /** Override the flag URL builder, forwarded to ACountrySelect. */
21
+ flagUrl?: (iso2: string, width: number) => string;
22
+ /** Custom search predicate, forwarded to ACountrySelect. */
23
+ searcher?: (query: string, country: CountryOption) => boolean;
24
+ /** Provide your own country list, forwarded to ACountrySelect. */
25
+ countries?: CountryOption[];
26
+ /**
27
+ * Fully custom country detection. When provided, this function runs in place of the
28
+ * built-in chain — `detectCountry`-style options are still honored but the function
29
+ * receives them and is free to ignore them.
30
+ */
31
+ detector?: (options: DetectCountryOptions) => Promise<string | null | undefined>;
32
+ /** Forwarded to ACountrySelect: classes for the popover content surface. */
33
+ contentClass?: string;
34
+ popoverClass?: string;
35
+ drawerClass?: string;
36
+ /** Classes for the inner phone field input element. */
37
+ inputClass?: string;
38
+ /** Classes for the outer wrapper that holds country select + input. */
39
+ fieldClass?: string;
40
+ /** Classes for the helper hint line. */
41
+ hintClass?: string;
42
+ /** Classes for the error message line. */
43
+ errorClass?: string;
44
+ /**
45
+ * How page scroll is blocked while the country popover is open. Defaults to `'events'`
46
+ * (sticky-safe document-level lock). Pass `'body'` for the legacy
47
+ * `body { overflow: hidden }` lock, or `'none'` to leave page scrolling alone.
48
+ */
49
+ scrollLock?: 'events' | 'body' | 'none';
50
+ }
51
+
52
+ const props = withDefaults(defineProps<ExtendedProps>(), {
53
+ placeholder: 'Phone number',
54
+ size: DEFAULT_SIZE,
55
+ detectCountry: 'auto',
56
+ defaultCountry: '',
57
+ ipEndpoint: 'https://ipapi.co/json/',
58
+ detectFromInput: true,
59
+ detectDebounceMs: 800,
60
+ showValidationIcon: false,
61
+ });
62
+
63
+ defineSlots<{
64
+ /** Content before the country select trigger (e.g. an icon). */
65
+ prefix?: () => unknown;
66
+ /** Content between the input and the validation icons. */
67
+ suffix?: (props: {
68
+ validationState: 'idle' | 'valid' | 'error';
69
+ validation: PhoneValidationResult;
70
+ }) => unknown;
71
+ /** Replace the green check shown when the number validates. */
72
+ 'valid-icon'?: () => unknown;
73
+ /** Replace the warning icon shown when the number fails validation. */
74
+ 'error-icon'?: (props: { reason: string }) => unknown;
75
+ /** Replace the dim helper line shown below the input when empty. */
76
+ hint?: (props: { country: string; formatHint: string; example: string | null }) => unknown;
77
+ /** Replace the error message rendered when invalid. */
78
+ error?: (props: {
79
+ message: string;
80
+ reason: string;
81
+ validation: PhoneValidationResult;
82
+ }) => unknown;
83
+ /** Forwarded to ACountrySelect — replace the trigger button. */
84
+ trigger?: (props: {
85
+ selectedCountry: CountryOption | null;
86
+ open: boolean;
87
+ sizeClasses: string;
88
+ }) => unknown;
89
+ /** Forwarded to ACountrySelect — replace the chevron. */
90
+ chevron?: (props: { open: boolean }) => unknown;
91
+ /** Forwarded — replace any flag rendering. */
92
+ flag?: (props: { country: CountryOption; context: 'trigger' | 'item' }) => unknown;
93
+ /** Forwarded — replace each country list row. */
94
+ item?: (props: {
95
+ country: CountryOption;
96
+ selected: boolean;
97
+ disabled: boolean;
98
+ select: () => void;
99
+ }) => unknown;
100
+ /** Forwarded — section header. */
101
+ 'group-header'?: (props: { label: string; group: 'suggested' | 'all' }) => unknown;
102
+ /** Forwarded — search bar. */
103
+ search?: (props: {
104
+ value: string;
105
+ setValue: (v: string) => void;
106
+ isSearching: boolean;
107
+ }) => unknown;
108
+ loading?: () => unknown;
109
+ empty?: (props: { query: string }) => unknown;
110
+ /** Replace the spinner shown in the picker slot during the debounce window. */
111
+ detecting?: () => unknown;
112
+ }>();
113
+
114
+ const phone = defineModel<string>('phone', { default: '' });
115
+ /** Public `v-model:country` — the **dial number** (e.g. `20` for Egypt, `44` for the UK,
116
+ * `1` for the NANP block). `null` means no country selected. Internally the component
117
+ * tracks a richer ISO2 code (`selectedIso2`) because dial codes alone can't disambiguate
118
+ * NANP (`+1` covers 25+ countries) — the picker still needs an exact country. */
119
+ const country = defineModel<number | null>('country', { default: null });
120
+
121
+ /** Internal source of truth — the ISO2 alpha-2 code of the picker selection. Synced with
122
+ * `country` (dial number) via watchers below. */
123
+ const selectedIso2 = ref<string>('');
124
+
125
+ const { getCountries, validate, getRequiredInfo, getCountryByValue, getCountriesByDial } =
126
+ usePhoneValidation();
127
+ // Pass the loaded lookups in — useCountryMatching can't call usePhoneValidation() itself
128
+ // because each invocation creates a fresh, empty country index.
129
+ const { resolveCountryIdentifier, dialNumberFor, matchLeadingDialCode } = useCountryMatching({
130
+ getCountryByValue,
131
+ getCountriesByDial,
132
+ });
133
+
134
+ void getCountries();
135
+
136
+ const userPickedCountry = ref(false);
137
+ const autoSettingCountry = ref(false);
138
+
139
+ /** Silently resolved via IP/timezone/locale when `detectFromInput` is on — used as a hint
140
+ * so local-format numbers (e.g. Egyptian `01066105963`) can be parsed without a `+` prefix.
141
+ * Seeded from `defaultCountry` so it has a usable value before async detection resolves. */
142
+ const inferredCountry = ref<string>(resolveCountryIdentifier(props.defaultCountry));
143
+
144
+ /** Closure passed everywhere the matcher needs typing context (hint country + current
145
+ * selection for tier-3 tie-breaks). Avoids re-reading `selectedIso2`/`inferredCountry`
146
+ * at every call site. */
147
+ function tryMatchPhone(digits: string) {
148
+ return matchLeadingDialCode(digits, {
149
+ hintCountry: inferredCountry.value,
150
+ currentIso2: selectedIso2.value,
151
+ });
152
+ }
153
+
154
+ /* ---------------------------------------------------------------
155
+ * Typing-phase state machine — owns `isDetecting`, `hasFinishedTyping`,
156
+ * `detectionAttempted` and the debounce timer. The `onSettle` callback
157
+ * runs at the end of every debounce window: it short-circuits when
158
+ * detection is disabled / the user already picked / input is empty,
159
+ * otherwise marks a detection attempt and applies any match.
160
+ * ------------------------------------------------------------- */
161
+ const typing = useTypingPhase({
162
+ debounceMs: computed(() => Math.max(0, props.detectDebounceMs)),
163
+ onSettle: () => {
164
+ if (!props.detectFromInput) return;
165
+ if (userPickedCountry.value || selectedIso2.value) return;
166
+ const current = phone.value;
167
+ if (!current) return;
168
+
169
+ typing.markDetectionAttempt();
170
+
171
+ const match = tryMatchPhone(current);
172
+ if (!match) return;
173
+ autoSettingCountry.value = true;
174
+ selectedIso2.value = match.country.value;
175
+ phone.value = match.nationalNumber;
176
+ },
177
+ });
178
+ const { isDetecting, hasFinishedTyping, detectionAttempted } = typing;
179
+
180
+ onMounted(async () => {
181
+ if (selectedIso2.value) return; // v-model has an initial value — respect it.
182
+
183
+ // Explicit `defaultCountry` is treated as the initial picker value (the picker shows
184
+ // immediately) — this is how callers opt out of the hidden-until-detected default. Accepts
185
+ // either an ISO2 code (`'EG'`) or a dial-digit string (`'20'`, `'+20'`).
186
+ if (props.defaultCountry) {
187
+ const seed = resolveCountryIdentifier(props.defaultCountry);
188
+ if (seed) {
189
+ inferredCountry.value = seed;
190
+ autoSettingCountry.value = true;
191
+ selectedIso2.value = seed;
192
+ return;
193
+ }
194
+ }
195
+
196
+ // No defaultCountry → run the environment chain. Used as a parsing hint in detect mode,
197
+ // or as the auto-fill source in legacy (`detectFromInput=false`) mode.
198
+ const detectOpts: DetectCountryOptions = {
199
+ strategy: props.detectCountry,
200
+ ipEndpoint: props.ipEndpoint,
201
+ defaultCountry: '',
202
+ };
203
+ let detected: string | null | undefined;
204
+ if (props.detector) {
205
+ try {
206
+ detected = await props.detector(detectOpts);
207
+ } catch {
208
+ detected = null;
209
+ }
210
+ }
211
+ if (!detected) {
212
+ detected = await detectCountry(detectOpts);
213
+ }
214
+ const iso2 = detected ? detected.toUpperCase() : '';
215
+
216
+ if (props.detectFromInput) {
217
+ inferredCountry.value = iso2;
218
+ // If the user has already typed something while detection was resolving, re-attempt
219
+ // matching now that we have a hint country for the libphonenumber national-format pass.
220
+ if (phone.value && !userPickedCountry.value && !selectedIso2.value) {
221
+ const match = tryMatchPhone(phone.value);
222
+ if (match) {
223
+ autoSettingCountry.value = true;
224
+ selectedIso2.value = match.country.value;
225
+ phone.value = match.nationalNumber;
226
+ }
227
+ }
228
+ return;
229
+ }
230
+ if (!selectedIso2.value && iso2) {
231
+ autoSettingCountry.value = true;
232
+ selectedIso2.value = iso2;
233
+ }
234
+ });
235
+
236
+ /** External → internal: when the caller mutates `v-model:country` (dial number), resolve
237
+ * it to an ISO2. If the current ISO2 already maps to this dial (e.g. user has Canada
238
+ * selected and the caller writes back `1`), keep the existing selection — don't churn it. */
239
+ watch(
240
+ country,
241
+ (next) => {
242
+ if (next == null) {
243
+ if (selectedIso2.value) selectedIso2.value = '';
244
+ return;
245
+ }
246
+ if (dialNumberFor(selectedIso2.value) === next) return; // already in sync
247
+ const iso2 = resolveCountryIdentifier(String(next));
248
+ if (iso2) selectedIso2.value = iso2;
249
+ },
250
+ { immediate: true }
251
+ );
252
+
253
+ /** Internal → external: keep `country` (dial number) in lockstep with `selectedIso2`, and
254
+ * flag "user manually picked from picker" when the change isn't one we initiated.
255
+ * `flush: 'sync'` so the `autoSettingCountry` guard is reliable. */
256
+ watch(
257
+ selectedIso2,
258
+ (iso2, prev) => {
259
+ const wasAutoSet = autoSettingCountry.value;
260
+ autoSettingCountry.value = false;
261
+
262
+ const nextDial = dialNumberFor(iso2);
263
+ if (country.value !== nextDial) country.value = nextDial;
264
+
265
+ if (!wasAutoSet && props.detectFromInput && iso2 && prev !== iso2) {
266
+ userPickedCountry.value = true;
267
+ }
268
+ },
269
+ { flush: 'sync' }
270
+ );
271
+
272
+ /** The string shown in the `<input>`. Deliberately decoupled from `phone` (the digits-only
273
+ * model) so the visible field is NOT rewritten mid-edit — non-digits / alternative numerals
274
+ * are normalized into `phone` immediately, but the displayed value is only cleaned up once
275
+ * the user finishes typing (on blur / change). */
276
+ const displayValue = ref<string>(String(phone.value ?? ''));
277
+
278
+ /** Set when the in-flight `phone` change came from the user typing — tells the `phone`
279
+ * watcher to leave `displayValue` alone (the user is still editing it). */
280
+ let phoneEditedByInput = false;
281
+
282
+ function commitPhone(value: string) {
283
+ phoneEditedByInput = true;
284
+ phone.value = value;
285
+ }
286
+
287
+ function handlePhoneInput(e: Event) {
288
+ const target = e.target as HTMLInputElement;
289
+ // Keep the visible value exactly as typed — don't rewrite it mid-edit. The model still
290
+ // receives a normalized, digits-only value so validation + detection stay correct.
291
+ displayValue.value = target.value;
292
+ // Fold alternative numerals (Arabic-Indic, Persian, …) to ASCII, then strip non-digits.
293
+ const cleaned = normalizeDigits(target.value).replace(/\D/g, '');
294
+
295
+ if (!cleaned) {
296
+ // Always reset on clear — even after a manual pick. Instant (not debounced) so the
297
+ // picker + spinner hide the moment the input goes empty.
298
+ typing.reset();
299
+ if (props.detectFromInput) {
300
+ autoSettingCountry.value = true;
301
+ selectedIso2.value = '';
302
+ userPickedCountry.value = false;
303
+ }
304
+ commitPhone('');
305
+ return;
306
+ }
307
+
308
+ typing.markTyping();
309
+ commitPhone(cleaned);
310
+ }
311
+
312
+ /** Fires when the user finishes editing (blur). Now it's safe to normalize the visible
313
+ * value — fold alternative numerals to ASCII and drop any stray non-digits. */
314
+ function handlePhoneChange(e: Event) {
315
+ const target = e.target as HTMLInputElement;
316
+ displayValue.value = normalizeDigits(target.value).replace(/\D/g, '');
317
+ }
318
+
319
+ watch(
320
+ () => phone.value,
321
+ (next) => {
322
+ const cleaned = normalizeDigits(String(next ?? '')).replace(/\D/g, '');
323
+ // Normalize a programmatic value that arrived non-clean.
324
+ if (cleaned !== next) {
325
+ phone.value = cleaned;
326
+ return;
327
+ }
328
+ // The user typing manages `displayValue` itself — don't fight their edit.
329
+ if (phoneEditedByInput) {
330
+ phoneEditedByInput = false;
331
+ return;
332
+ }
333
+ // External or detection-driven change → reflect it in the visible input.
334
+ displayValue.value = cleaned;
335
+ },
336
+ { flush: 'post' }
337
+ );
338
+
339
+ /** Resolved UI strings — `messages` prop merged onto English defaults. The individual
340
+ * string props still win when both are set (see `errorMessage` / template bindings). */
341
+ const messages = computed(() => resolveMessages(props.messages));
342
+
343
+ /** `dir` of the outer wrapper — drives the hint/error text alignment and the country
344
+ * picker popover. Explicit `'ltr'`/`'rtl'` is applied; `'auto'` or an omitted prop yields
345
+ * `undefined` so it inherits from the page. The field row itself is always LTR so the
346
+ * dial prefix / digits / flag trigger keep a consistent order. */
347
+ const dirAttr = computed<'ltr' | 'rtl' | undefined>(() =>
348
+ props.dir === 'ltr' || props.dir === 'rtl' ? props.dir : undefined
349
+ );
350
+
351
+ /* ---------------------------------------------------------------
352
+ * Validation facade — wraps the raw `usePhoneValidation` calls and
353
+ * produces the view-layer surface (visible state gated by the typing
354
+ * pause, localised error message, conditional show flags, etc.).
355
+ * ------------------------------------------------------------- */
356
+ const {
357
+ validation,
358
+ required,
359
+ validationState,
360
+ visibleValidationState,
361
+ errorMessage,
362
+ showError,
363
+ showHint,
364
+ selectedDialCode,
365
+ } = useTellInputValidation(
366
+ { validate, getRequiredInfo, getCountryByValue },
367
+ { phone, selectedIso2, hasFinishedTyping, messages },
368
+ {
369
+ locale: () => props.locale,
370
+ showValidation: () => props.showValidation,
371
+ errorMessages: () => props.errorMessages,
372
+ }
373
+ );
374
+
375
+ const effectivePlaceholder = computed(
376
+ () => props.placeholder || required.value?.format_hint || messages.value.phoneInputLabel
377
+ );
378
+
379
+ const inputSizeClasses = computed(
380
+ () => `${controlPaddingX[props.size]} ${controlTextSize[props.size]}`
381
+ );
382
+
383
+ /** Classes for the inline dial-code prefix — a tight `px-2` so it hugs the input digits. */
384
+ const dialPrefixClasses = computed(() => `px-2 ${controlTextSize[props.size]}`);
385
+
386
+ /* ---------------------------------------------------------------
387
+ * Accessibility — the helper line (hint or error) lives in a single
388
+ * `aria-live` region; the input's `aria-describedby` points at it
389
+ * whenever it has content.
390
+ * ------------------------------------------------------------- */
391
+ const helperId = useId();
392
+ const describedBy = computed(() => (showError.value || showHint.value ? helperId : undefined));
393
+
394
+ defineExpose({
395
+ validation,
396
+ required,
397
+ selectedDialCode,
398
+ validationState,
399
+ visibleValidationState,
400
+ isDetecting,
401
+ hasFinishedTyping,
402
+ detectionAttempted,
403
+ });
404
+ </script>
405
+
406
+ <template>
407
+ <div
408
+ :class="cn('flex w-full flex-col gap-1.5', $attrs.class as string)"
409
+ data-slot="tell-input"
410
+ :dir="dirAttr"
411
+ >
412
+ <!-- The field row is forced LTR so its pieces (dial prefix, digits, flag trigger) keep
413
+ the same order regardless of page direction — phone numbers read left-to-right. -->
414
+ <div class="flex items-center gap-2" dir="ltr">
415
+ <div
416
+ :class="
417
+ cn(
418
+ aTellInputVariants({ size: props.size }),
419
+ 'focus-within:ring-2 focus-within:ring-offset-0',
420
+ // Validation field colors are an opt-in via `showValidation` — by default the
421
+ // field stays neutral and the consumer drives error rendering from `validation`.
422
+ (!props.showValidation || visibleValidationState === 'idle') &&
423
+ 'focus-within:ring-ring/40',
424
+ props.showValidation &&
425
+ visibleValidationState === 'valid' &&
426
+ 'border-emerald-500/60 ring-1 ring-emerald-500/20 focus-within:ring-emerald-500/40',
427
+ props.showValidation &&
428
+ visibleValidationState === 'error' &&
429
+ 'border-destructive/80 ring-1 ring-destructive/20 focus-within:ring-destructive/40',
430
+ props.class,
431
+ props.fieldClass
432
+ )
433
+ "
434
+ :data-state="visibleValidationState"
435
+ >
436
+ <slot name="prefix" />
437
+
438
+ <span
439
+ v-if="selectedDialCode"
440
+ data-slot="tell-input-dial"
441
+ dir="ltr"
442
+ aria-hidden="true"
443
+ :class="cn('text-muted-foreground shrink-0 tabular-nums select-none', dialPrefixClasses)"
444
+ >
445
+ {{ selectedDialCode }}
446
+ </span>
447
+
448
+ <input
449
+ :value="displayValue"
450
+ type="tel"
451
+ inputmode="numeric"
452
+ autocomplete="tel"
453
+ dir="ltr"
454
+ data-slot="tell-input-field"
455
+ :disabled="props.disabled || props.loading"
456
+ :placeholder="effectivePlaceholder"
457
+ :aria-label="messages.phoneInputLabel"
458
+ :aria-invalid="visibleValidationState === 'error' || undefined"
459
+ :aria-describedby="describedBy"
460
+ :class="
461
+ cn(
462
+ 'placeholder:text-muted-foreground h-full w-full min-w-0 flex-1 bg-transparent tabular-nums outline-none disabled:cursor-not-allowed',
463
+ inputSizeClasses,
464
+ selectedDialCode && 'ps-1',
465
+ props.inputClass
466
+ )
467
+ "
468
+ @input="handlePhoneInput"
469
+ @change="handlePhoneChange"
470
+ />
471
+
472
+ <!-- Detection-in-flight spinner — shown only during the first debounce window,
473
+ before the picker has appeared. Once the picker is visible (success OR a failed
474
+ attempt that revealed the empty picker) we stop re-flashing on every keystroke. -->
475
+ <Transition
476
+ enter-active-class="transition-all duration-200 ease-out overflow-hidden"
477
+ leave-active-class="transition-all duration-150 ease-in overflow-hidden"
478
+ enter-from-class="opacity-0 max-w-0"
479
+ leave-to-class="opacity-0 max-w-0"
480
+ enter-to-class="max-w-[2.5rem]"
481
+ leave-from-class="max-w-[2.5rem]"
482
+ >
483
+ <div
484
+ v-if="isDetecting && !selectedIso2 && !detectionAttempted"
485
+ class="text-muted-foreground inline-flex h-full shrink-0 items-center px-2"
486
+ aria-hidden="true"
487
+ data-slot="tell-input-detecting"
488
+ >
489
+ <slot name="detecting">
490
+ <Loader2 class="size-4 animate-spin" />
491
+ </slot>
492
+ </div>
493
+ </Transition>
494
+
495
+ <Transition
496
+ enter-active-class="transition-all duration-200 ease-out overflow-hidden"
497
+ leave-active-class="transition-all duration-150 ease-in overflow-hidden"
498
+ enter-from-class="opacity-0 max-w-0"
499
+ leave-to-class="opacity-0 max-w-0"
500
+ enter-to-class="max-w-[12rem]"
501
+ leave-from-class="max-w-[12rem]"
502
+ >
503
+ <!-- Wrapper div gives the <Transition> a single element root to animate.
504
+ ACountrySelect's root is the AResponsivePopover fragment (Popover/Drawer
505
+ swap), which a Transition can't animate directly — without this wrapper
506
+ Vue logs "Component inside <Transition> renders non-element root node". -->
507
+ <div
508
+ v-if="!props.detectFromInput || selectedIso2 || detectionAttempted"
509
+ class="inline-flex h-full shrink-0 items-center"
510
+ data-slot="tell-input-country-wrapper"
511
+ >
512
+ <ACountrySelect
513
+ v-model:selected="selectedIso2"
514
+ :allowed-dial-codes="props.allowedDialCodes"
515
+ :disabled="props.disabled || props.loading"
516
+ :size="props.size"
517
+ :locale="props.locale"
518
+ :search-placeholder="props.searchPlaceholder ?? messages.searchPlaceholder"
519
+ :empty-text="props.emptyText ?? messages.emptyText"
520
+ :loading-text="props.loadingText ?? messages.loadingText"
521
+ :suggested-label="messages.suggestedLabel"
522
+ :all-countries-label="messages.allCountriesLabel"
523
+ :country-label="messages.countryLabel"
524
+ :select-country-label="messages.selectCountryLabel"
525
+ :flag-url="props.flagUrl"
526
+ :searcher="props.searcher"
527
+ :countries="props.countries"
528
+ :content-class="props.contentClass"
529
+ :popover-class="props.popoverClass"
530
+ :drawer-class="props.drawerClass"
531
+ :scroll-lock="props.scrollLock"
532
+ >
533
+ <template v-if="$slots.trigger" #trigger="slotProps">
534
+ <slot name="trigger" v-bind="slotProps" />
535
+ </template>
536
+ <template v-if="$slots.chevron" #chevron="slotProps">
537
+ <slot name="chevron" v-bind="slotProps" />
538
+ </template>
539
+ <template v-if="$slots.flag" #flag="slotProps">
540
+ <slot name="flag" v-bind="slotProps" />
541
+ </template>
542
+ <template v-if="$slots.item" #item="slotProps">
543
+ <slot name="item" v-bind="slotProps" />
544
+ </template>
545
+ <template v-if="$slots['group-header']" #group-header="slotProps">
546
+ <slot name="group-header" v-bind="slotProps" />
547
+ </template>
548
+ <template v-if="$slots.search" #search="slotProps">
549
+ <slot name="search" v-bind="slotProps" />
550
+ </template>
551
+ <template v-if="$slots.loading" #loading>
552
+ <slot name="loading" />
553
+ </template>
554
+ <template v-if="$slots.empty" #empty="slotProps">
555
+ <slot name="empty" v-bind="slotProps" />
556
+ </template>
557
+ </ACountrySelect>
558
+ </div>
559
+ </Transition>
560
+
561
+ <slot name="suffix" :validation-state="validationState" :validation="validation" />
562
+ </div>
563
+
564
+ <Transition
565
+ v-if="props.showValidationIcon"
566
+ enter-active-class="transition duration-150 ease-out"
567
+ leave-active-class="transition duration-100 ease-in"
568
+ enter-from-class="opacity-0 scale-90"
569
+ leave-to-class="opacity-0 scale-90"
570
+ >
571
+ <slot v-if="visibleValidationState === 'valid'" name="valid-icon">
572
+ <CheckCircle2 class="size-5 shrink-0 text-emerald-500" aria-hidden="true" />
573
+ </slot>
574
+ <slot
575
+ v-else-if="visibleValidationState === 'error'"
576
+ name="error-icon"
577
+ :reason="validation.reason ?? ''"
578
+ >
579
+ <AlertCircle class="text-destructive size-5 shrink-0" aria-hidden="true" />
580
+ </slot>
581
+ </Transition>
582
+ </div>
583
+
584
+ <div :id="helperId" aria-live="polite">
585
+ <slot
586
+ v-if="showError"
587
+ name="error"
588
+ :message="errorMessage!"
589
+ :reason="validation.reason ?? ''"
590
+ :validation="validation"
591
+ >
592
+ <p
593
+ data-slot="tell-input-error"
594
+ :class="cn('text-destructive text-xs', props.errorClass)"
595
+ role="alert"
596
+ >
597
+ {{ errorMessage }}
598
+ </p>
599
+ </slot>
600
+ <slot
601
+ v-else-if="showHint"
602
+ name="hint"
603
+ :country="selectedIso2"
604
+ :format-hint="required!.format_hint"
605
+ :example="required!.example_e164"
606
+ >
607
+ <p
608
+ data-slot="tell-input-hint"
609
+ :class="cn('text-muted-foreground text-xs tabular-nums', props.hintClass)"
610
+ >
611
+ {{ required!.format_hint }}
612
+ </p>
613
+ </slot>
614
+ </div>
615
+ </div>
616
+ </template>