@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.
- package/dist/entries/drawer/components/ADrawer.vue.d.ts +1 -5
- package/dist/entries/drawer/components/ADrawerContent.vue.d.ts +1 -5
- package/dist/entries/drawer/components/ADrawerTrigger.vue.d.ts +1 -5
- package/dist/entries/input/components/AInput.vue.d.ts +1 -5
- package/dist/entries/popover/components/APopover.vue.d.ts +1 -5
- package/dist/entries/popover/components/APopoverContent.vue.d.ts +1 -5
- package/dist/entries/popover/components/APopoverTrigger.vue.d.ts +1 -5
- package/dist/entries/responsive-popover/components/AResponsivePopover.vue.d.ts +1 -5
- package/dist/entries/responsive-popover/components/AResponsivePopoverContent.vue.d.ts +1 -5
- package/dist/entries/responsive-popover/components/AResponsivePopoverTrigger.vue.d.ts +1 -5
- package/dist/entries/tell-input/components/ACountryFlag.vue.d.ts +1 -5
- package/dist/entries/tell-input/components/ACountrySelect.vue.d.ts +1 -5
- package/dist/entries/tell-input/components/ATellInput.vue.d.ts +1 -5
- package/entries/drawer/components/ADrawer.vue +16 -0
- package/entries/drawer/components/ADrawerContent.vue +35 -0
- package/entries/drawer/components/ADrawerOverlay.vue +25 -0
- package/entries/drawer/components/ADrawerTrigger.vue +13 -0
- package/entries/drawer/index.ts +4 -0
- package/entries/input/components/AInput.vue +111 -0
- package/entries/input/index.ts +1 -0
- package/entries/popover/components/APopover.vue +19 -0
- package/entries/popover/components/APopoverContent.vue +65 -0
- package/entries/popover/components/APopoverOverlay.vue +69 -0
- package/entries/popover/components/APopoverTrigger.vue +13 -0
- package/entries/popover/composables/useEventScrollLock.ts +193 -0
- package/entries/popover/index.ts +8 -0
- package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
- package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
- package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
- package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
- package/entries/responsive-popover/index.ts +3 -0
- package/entries/tell-input/components/ACountryFlag.vue +68 -0
- package/entries/tell-input/components/ACountrySelect.vue +522 -0
- package/entries/tell-input/components/ATellInput.vue +616 -0
- package/entries/tell-input/composables/useCountryDetection.ts +247 -0
- package/entries/tell-input/composables/useCountryMatching.ts +213 -0
- package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
- package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
- package/entries/tell-input/composables/useTypingPhase.ts +88 -0
- package/entries/tell-input/index.ts +29 -0
- package/entries/tell-input/utils/digits.ts +42 -0
- package/entries/tell-input/utils/flag-url.ts +10 -0
- package/entries/tell-input/utils/types.ts +169 -0
- package/package.json +4 -1
- package/utils/cn.ts +6 -0
- package/utils/index.ts +10 -0
- 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>
|