@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,247 @@
1
+ /**
2
+ * Best-effort country detection chain: IP geolocation → timezone → navigator.language → fallback.
3
+ * Every step is independent and degrades gracefully; the final fallback is always returned.
4
+ *
5
+ * The composable returns reactive state so consumers can swap in the detected ISO2 the moment it
6
+ * resolves, while still rendering an input immediately on mount.
7
+ */
8
+
9
+ import { onMounted, ref, type Ref } from 'vue';
10
+
11
+ export type DetectionStrategy = 'auto' | 'locale' | 'none';
12
+
13
+ export interface DetectCountryOptions {
14
+ /**
15
+ * - `'auto'` — try IP geolocation first, then timezone, then `navigator.language`, then default.
16
+ * - `'locale'` — skip the network call, use timezone + locale only.
17
+ * - `'none'` — return `defaultCountry` immediately.
18
+ */
19
+ strategy?: DetectionStrategy;
20
+ /** Endpoint returning a JSON body with a `country_code` (or `country`) field. */
21
+ ipEndpoint?: string;
22
+ /** Fallback ISO2 used when every step fails. */
23
+ defaultCountry?: string;
24
+ /** Abort the IP request after this many ms. */
25
+ timeoutMs?: number;
26
+ /** Persist the resolved country in sessionStorage so re-mounts within the session skip detection. */
27
+ cache?: boolean;
28
+ }
29
+
30
+ const SESSION_CACHE_KEY = 'ali_ui_country_detected';
31
+
32
+ const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
33
+
34
+ /** Hand-rolled IANA timezone → ISO2 map. Covers the most-populated zones; falls through on miss. */
35
+ const TIMEZONE_TO_ISO2: Record<string, string> = {
36
+ // Africa
37
+ 'Africa/Cairo': 'EG',
38
+ 'Africa/Johannesburg': 'ZA',
39
+ 'Africa/Lagos': 'NG',
40
+ 'Africa/Casablanca': 'MA',
41
+ 'Africa/Algiers': 'DZ',
42
+ 'Africa/Nairobi': 'KE',
43
+ 'Africa/Accra': 'GH',
44
+ 'Africa/Tunis': 'TN',
45
+ // Americas
46
+ 'America/Argentina/Buenos_Aires': 'AR',
47
+ 'America/Bogota': 'CO',
48
+ 'America/Caracas': 'VE',
49
+ 'America/Chicago': 'US',
50
+ 'America/Denver': 'US',
51
+ 'America/Halifax': 'CA',
52
+ 'America/Lima': 'PE',
53
+ 'America/Los_Angeles': 'US',
54
+ 'America/Mexico_City': 'MX',
55
+ 'America/New_York': 'US',
56
+ 'America/Phoenix': 'US',
57
+ 'America/Sao_Paulo': 'BR',
58
+ 'America/Santiago': 'CL',
59
+ 'America/Toronto': 'CA',
60
+ 'America/Vancouver': 'CA',
61
+ // Asia
62
+ 'Asia/Baghdad': 'IQ',
63
+ 'Asia/Bahrain': 'BH',
64
+ 'Asia/Bangkok': 'TH',
65
+ 'Asia/Beirut': 'LB',
66
+ 'Asia/Damascus': 'SY',
67
+ 'Asia/Dhaka': 'BD',
68
+ 'Asia/Dubai': 'AE',
69
+ 'Asia/Hong_Kong': 'HK',
70
+ 'Asia/Jakarta': 'ID',
71
+ 'Asia/Jerusalem': 'IL',
72
+ 'Asia/Karachi': 'PK',
73
+ 'Asia/Kolkata': 'IN',
74
+ 'Asia/Kuala_Lumpur': 'MY',
75
+ 'Asia/Kuwait': 'KW',
76
+ 'Asia/Manila': 'PH',
77
+ 'Asia/Muscat': 'OM',
78
+ 'Asia/Qatar': 'QA',
79
+ 'Asia/Riyadh': 'SA',
80
+ 'Asia/Seoul': 'KR',
81
+ 'Asia/Shanghai': 'CN',
82
+ 'Asia/Singapore': 'SG',
83
+ 'Asia/Taipei': 'TW',
84
+ 'Asia/Tehran': 'IR',
85
+ 'Asia/Tokyo': 'JP',
86
+ 'Asia/Yangon': 'MM',
87
+ // Europe
88
+ 'Europe/Amsterdam': 'NL',
89
+ 'Europe/Athens': 'GR',
90
+ 'Europe/Belgrade': 'RS',
91
+ 'Europe/Berlin': 'DE',
92
+ 'Europe/Brussels': 'BE',
93
+ 'Europe/Bucharest': 'RO',
94
+ 'Europe/Budapest': 'HU',
95
+ 'Europe/Copenhagen': 'DK',
96
+ 'Europe/Dublin': 'IE',
97
+ 'Europe/Helsinki': 'FI',
98
+ 'Europe/Istanbul': 'TR',
99
+ 'Europe/Kyiv': 'UA',
100
+ 'Europe/Lisbon': 'PT',
101
+ 'Europe/London': 'GB',
102
+ 'Europe/Madrid': 'ES',
103
+ 'Europe/Moscow': 'RU',
104
+ 'Europe/Oslo': 'NO',
105
+ 'Europe/Paris': 'FR',
106
+ 'Europe/Prague': 'CZ',
107
+ 'Europe/Rome': 'IT',
108
+ 'Europe/Sofia': 'BG',
109
+ 'Europe/Stockholm': 'SE',
110
+ 'Europe/Vienna': 'AT',
111
+ 'Europe/Warsaw': 'PL',
112
+ 'Europe/Zurich': 'CH',
113
+ // Oceania
114
+ 'Australia/Brisbane': 'AU',
115
+ 'Australia/Melbourne': 'AU',
116
+ 'Australia/Perth': 'AU',
117
+ 'Australia/Sydney': 'AU',
118
+ 'Pacific/Auckland': 'NZ',
119
+ 'Pacific/Honolulu': 'US',
120
+ };
121
+
122
+ function tryTimezone(): string | null {
123
+ if (!isBrowser()) return null;
124
+ try {
125
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
126
+ return TIMEZONE_TO_ISO2[tz] ?? null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function tryLocale(): string | null {
133
+ if (!isBrowser()) return null;
134
+ try {
135
+ const lang = navigator.language ?? '';
136
+ const m = lang.match(/^[a-z]{2,3}-([A-Z]{2})/);
137
+ return m ? m[1] : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ async function tryIp(endpoint: string, timeoutMs: number): Promise<string | null> {
144
+ if (!isBrowser() || typeof fetch !== 'function') return null;
145
+ const controller = new AbortController();
146
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
147
+ try {
148
+ const res = await fetch(endpoint, { signal: controller.signal, credentials: 'omit' });
149
+ if (!res.ok) return null;
150
+ const data = (await res.json()) as { country_code?: string; country?: string };
151
+ const code = (data.country_code ?? data.country ?? '').toString().toUpperCase();
152
+ return /^[A-Z]{2}$/.test(code) ? code : null;
153
+ } catch {
154
+ return null;
155
+ } finally {
156
+ clearTimeout(timer);
157
+ }
158
+ }
159
+
160
+ function readCache(): string | null {
161
+ if (!isBrowser()) return null;
162
+ try {
163
+ const v = sessionStorage.getItem(SESSION_CACHE_KEY);
164
+ return v && /^[A-Z]{2}$/.test(v) ? v : null;
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ function writeCache(iso2: string) {
171
+ if (!isBrowser()) return;
172
+ try {
173
+ sessionStorage.setItem(SESSION_CACHE_KEY, iso2);
174
+ } catch {
175
+ /* quota or storage disabled — silently ignore */
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Imperative API. Use this when you want to call detection from outside Vue (e.g. inside a
181
+ * non-component composable, server middleware, or unit test).
182
+ */
183
+ export async function detectCountry(opts: DetectCountryOptions = {}): Promise<string> {
184
+ const {
185
+ strategy = 'auto',
186
+ ipEndpoint = 'https://ipapi.co/json/',
187
+ defaultCountry = 'US',
188
+ timeoutMs = 2000,
189
+ cache = true,
190
+ } = opts;
191
+
192
+ if (cache) {
193
+ const cached = readCache();
194
+ if (cached) return cached;
195
+ }
196
+
197
+ if (strategy === 'none') {
198
+ return defaultCountry.toUpperCase();
199
+ }
200
+
201
+ if (strategy === 'auto') {
202
+ const ipResult = await tryIp(ipEndpoint, timeoutMs);
203
+ if (ipResult) {
204
+ if (cache) writeCache(ipResult);
205
+ return ipResult;
206
+ }
207
+ }
208
+
209
+ const localResult = tryTimezone() ?? tryLocale();
210
+ const final = (localResult ?? defaultCountry).toUpperCase();
211
+ if (cache) writeCache(final);
212
+ return final;
213
+ }
214
+
215
+ export interface UseCountryDetectionReturn {
216
+ /** Resolved ISO2 code. Initially `null` until detection completes. */
217
+ country: Ref<string | null>;
218
+ /** True while detection is in flight. */
219
+ isLoading: Ref<boolean>;
220
+ /** Manually re-run detection (e.g. after the user changes their VPN). */
221
+ refresh: () => Promise<string>;
222
+ }
223
+
224
+ /**
225
+ * Reactive wrapper. Kicks off detection in `onMounted` so SSR renders an empty value and the
226
+ * client patches in the real country once resolved.
227
+ */
228
+ export function useCountryDetection(opts: DetectCountryOptions = {}): UseCountryDetectionReturn {
229
+ const country = ref<string | null>(null);
230
+ const isLoading = ref(false);
231
+
232
+ async function run() {
233
+ isLoading.value = true;
234
+ try {
235
+ country.value = await detectCountry(opts);
236
+ } finally {
237
+ isLoading.value = false;
238
+ }
239
+ return country.value!;
240
+ }
241
+
242
+ onMounted(() => {
243
+ void run();
244
+ });
245
+
246
+ return { country, isLoading, refresh: run };
247
+ }
@@ -0,0 +1,213 @@
1
+ import { parsePhoneNumberFromString, type CountryCode } from 'libphonenumber-js';
2
+ import type { CountryOption } from './usePhoneValidation';
3
+
4
+ /** Synchronous dial-digit → ISO2 fallback for common countries, used when the async
5
+ * REST Countries fetch hasn't populated `getCountriesByDial`'s index yet at setup. */
6
+ export const DIAL_TO_ISO2_FALLBACK: Record<string, string> = {
7
+ '1': 'US',
8
+ '7': 'RU',
9
+ '20': 'EG',
10
+ '27': 'ZA',
11
+ '30': 'GR',
12
+ '31': 'NL',
13
+ '32': 'BE',
14
+ '33': 'FR',
15
+ '34': 'ES',
16
+ '39': 'IT',
17
+ '44': 'GB',
18
+ '46': 'SE',
19
+ '47': 'NO',
20
+ '48': 'PL',
21
+ '49': 'DE',
22
+ '52': 'MX',
23
+ '54': 'AR',
24
+ '55': 'BR',
25
+ '60': 'MY',
26
+ '61': 'AU',
27
+ '62': 'ID',
28
+ '63': 'PH',
29
+ '64': 'NZ',
30
+ '65': 'SG',
31
+ '66': 'TH',
32
+ '81': 'JP',
33
+ '82': 'KR',
34
+ '84': 'VN',
35
+ '86': 'CN',
36
+ '90': 'TR',
37
+ '91': 'IN',
38
+ '92': 'PK',
39
+ '95': 'MM',
40
+ '212': 'MA',
41
+ '213': 'DZ',
42
+ '216': 'TN',
43
+ '218': 'LY',
44
+ '234': 'NG',
45
+ '254': 'KE',
46
+ '352': 'LU',
47
+ '353': 'IE',
48
+ '358': 'FI',
49
+ '359': 'BG',
50
+ '380': 'UA',
51
+ '420': 'CZ',
52
+ '421': 'SK',
53
+ '961': 'LB',
54
+ '962': 'JO',
55
+ '963': 'SY',
56
+ '964': 'IQ',
57
+ '965': 'KW',
58
+ '966': 'SA',
59
+ '967': 'YE',
60
+ '968': 'OM',
61
+ '970': 'PS',
62
+ '971': 'AE',
63
+ '972': 'IL',
64
+ '973': 'BH',
65
+ '974': 'QA',
66
+ };
67
+
68
+ /** localStorage key for the user's most recently picked countries. Used as a
69
+ * tie-breaker when multiple countries share a dial code (e.g. all NANP). */
70
+ export const COUNTRY_RECENTS_KEY = 'ali_ui_country_recents_v1';
71
+
72
+ export interface DialMatch {
73
+ country: CountryOption;
74
+ /** The national significant number — what the phone input should hold, with both the
75
+ * dial code and the national prefix (e.g. Egyptian leading `0`) stripped. */
76
+ nationalNumber: string;
77
+ }
78
+
79
+ export interface MatchLeadingDialCodeOptions {
80
+ /** ISO2 hint for libphonenumber's national-format parse (tier 2). Typically the
81
+ * IP/timezone/locale-resolved country. */
82
+ hintCountry?: string;
83
+ /** Currently selected ISO2 — preferred when a shared dial code yields multiple
84
+ * countries (tier 3 tie-break). */
85
+ currentIso2?: string;
86
+ }
87
+
88
+ function readRecents(): string[] {
89
+ if (typeof window === 'undefined') return [];
90
+ try {
91
+ const raw = localStorage.getItem(COUNTRY_RECENTS_KEY);
92
+ if (!raw) return [];
93
+ const parsed = JSON.parse(raw);
94
+ return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ export interface CountryMatchingDeps {
101
+ /** Country lookup by ISO2 code — typically `usePhoneValidation().getCountryByValue`. */
102
+ getCountryByValue: (value: string) => CountryOption | null;
103
+ /** Country lookup by dial digits — typically `usePhoneValidation().getCountriesByDial`. */
104
+ getCountriesByDial: (dial: string) => CountryOption[];
105
+ }
106
+
107
+ /**
108
+ * Country-matching helpers used by the tel input — pure functions on top of the
109
+ * `usePhoneValidation` country index. Split out of `ATellInput.vue` so the component
110
+ * script stays focused on UI state and the matching logic is independently testable.
111
+ *
112
+ * **Important**: takes the validation lookups as dependencies rather than calling
113
+ * `usePhoneValidation()` itself. `usePhoneValidation` creates a fresh state on every
114
+ * call, so calling it here would produce a *second* empty country index that never gets
115
+ * populated by the caller's `getCountries()` — the matcher would see no countries and
116
+ * all tier-3 prefix lookups would fall through to the (much smaller) fallback table.
117
+ */
118
+ export function useCountryMatching(deps: CountryMatchingDeps) {
119
+ const { getCountryByValue, getCountriesByDial } = deps;
120
+
121
+ /** Accept either an ISO2 code (`'EG'`) or a dial-digit string (`'20'`, `'+20'`).
122
+ * Returns the canonical ISO2 for downstream consumers, or `''` if it can't resolve. */
123
+ function resolveCountryIdentifier(raw: string | undefined | null): string {
124
+ const v = String(raw ?? '').trim();
125
+ if (!v) return '';
126
+ if (/^[A-Za-z]{2}$/.test(v)) return v.toUpperCase();
127
+ const dial = v.replace(/^\+/, '');
128
+ if (!/^\d+$/.test(dial)) return '';
129
+ // Prefer the loaded country index (right answer when multiple share a dial); fall
130
+ // back to the synchronous table when the async list hasn't arrived yet.
131
+ const match = getCountriesByDial(dial)[0];
132
+ if (match) return match.value;
133
+ return DIAL_TO_ISO2_FALLBACK[dial] ?? '';
134
+ }
135
+
136
+ /** Compute the dial digits (as a number) for an ISO2 code. Falls back to the
137
+ * synchronous dial table if the async country list hasn't populated yet. */
138
+ function dialNumberFor(iso2: string): number | null {
139
+ if (!iso2) return null;
140
+ const fromIndex = getCountryByValue(iso2)?.raw_data?.dial_digits;
141
+ const digits =
142
+ fromIndex ?? Object.entries(DIAL_TO_ISO2_FALLBACK).find(([, v]) => v === iso2)?.[0];
143
+ if (!digits) return null;
144
+ const n = Number(digits);
145
+ return Number.isFinite(n) ? n : null;
146
+ }
147
+
148
+ /** Three-tier match of the leading digits to a country:
149
+ * 1. libphonenumber international parse (handles NANP disambiguation).
150
+ * 2. libphonenumber national-format parse using `hintCountry` (handles local
151
+ * formats like Egyptian `01066105963` with no dial-code prefix).
152
+ * 3. Longest-prefix match against the dial-digits index, with the current
153
+ * selection / recents as tie-breakers when multiple countries share a code. */
154
+ function matchLeadingDialCode(
155
+ digits: string,
156
+ options: MatchLeadingDialCodeOptions = {}
157
+ ): DialMatch | null {
158
+ if (!digits) return null;
159
+ const { hintCountry, currentIso2 } = options;
160
+
161
+ // Tier 1: international parse with leading `+`.
162
+ try {
163
+ const parsed = parsePhoneNumberFromString(`+${digits}`);
164
+ if (parsed?.country && parsed.countryCallingCode) {
165
+ const parsedCountry = getCountryByValue(parsed.country);
166
+ if (parsedCountry) {
167
+ return { country: parsedCountry, nationalNumber: String(parsed.nationalNumber ?? '') };
168
+ }
169
+ }
170
+ } catch {
171
+ /* libphonenumber throws on partial input — fall through */
172
+ }
173
+
174
+ // Tier 2: national-format parse using the silently-inferred country as a hint.
175
+ if (hintCountry && digits.length >= 4) {
176
+ try {
177
+ const parsed = parsePhoneNumberFromString(digits, hintCountry as CountryCode);
178
+ if (parsed?.isValid()) {
179
+ const matched = getCountryByValue(parsed.country || hintCountry);
180
+ if (matched) {
181
+ return { country: matched, nationalNumber: String(parsed.nationalNumber ?? '') };
182
+ }
183
+ }
184
+ } catch {
185
+ /* fall through */
186
+ }
187
+ }
188
+
189
+ // Tier 3: longest-prefix match over the dial-digits index.
190
+ for (let len = Math.min(3, digits.length); len >= 1; len--) {
191
+ const prefix = digits.slice(0, len);
192
+ const group = getCountriesByDial(prefix);
193
+ if (!group.length) continue;
194
+ const nationalNumber = digits.slice(prefix.length);
195
+ if (group.length === 1) return { country: group[0], nationalNumber };
196
+ const current = currentIso2 ? group.find((c) => c.value === currentIso2.toUpperCase()) : null;
197
+ if (current) return { country: current, nationalNumber };
198
+ const recents = readRecents();
199
+ const recentHit = recents
200
+ .map((iso2) => group.find((c) => c.value === iso2))
201
+ .find((c): c is CountryOption => Boolean(c));
202
+ if (recentHit) return { country: recentHit, nationalNumber };
203
+ return { country: group[0], nationalNumber };
204
+ }
205
+ return null;
206
+ }
207
+
208
+ return {
209
+ resolveCountryIdentifier,
210
+ dialNumberFor,
211
+ matchLeadingDialCode,
212
+ };
213
+ }