@alikhalilll/ui 1.2.3 → 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 (34) hide show
  1. package/entries/drawer/components/ADrawer.vue +16 -0
  2. package/entries/drawer/components/ADrawerContent.vue +35 -0
  3. package/entries/drawer/components/ADrawerOverlay.vue +25 -0
  4. package/entries/drawer/components/ADrawerTrigger.vue +13 -0
  5. package/entries/drawer/index.ts +4 -0
  6. package/entries/input/components/AInput.vue +111 -0
  7. package/entries/input/index.ts +1 -0
  8. package/entries/popover/components/APopover.vue +19 -0
  9. package/entries/popover/components/APopoverContent.vue +65 -0
  10. package/entries/popover/components/APopoverOverlay.vue +69 -0
  11. package/entries/popover/components/APopoverTrigger.vue +13 -0
  12. package/entries/popover/composables/useEventScrollLock.ts +193 -0
  13. package/entries/popover/index.ts +8 -0
  14. package/entries/responsive-popover/components/AResponsivePopover.vue +67 -0
  15. package/entries/responsive-popover/components/AResponsivePopoverContent.vue +80 -0
  16. package/entries/responsive-popover/components/AResponsivePopoverTrigger.vue +23 -0
  17. package/entries/responsive-popover/composables/useResponsivePopoverContext.ts +20 -0
  18. package/entries/responsive-popover/index.ts +3 -0
  19. package/entries/tell-input/components/ACountryFlag.vue +68 -0
  20. package/entries/tell-input/components/ACountrySelect.vue +522 -0
  21. package/entries/tell-input/components/ATellInput.vue +616 -0
  22. package/entries/tell-input/composables/useCountryDetection.ts +247 -0
  23. package/entries/tell-input/composables/useCountryMatching.ts +213 -0
  24. package/entries/tell-input/composables/usePhoneValidation.ts +573 -0
  25. package/entries/tell-input/composables/useTellInputValidation.ts +136 -0
  26. package/entries/tell-input/composables/useTypingPhase.ts +88 -0
  27. package/entries/tell-input/index.ts +29 -0
  28. package/entries/tell-input/utils/digits.ts +42 -0
  29. package/entries/tell-input/utils/flag-url.ts +10 -0
  30. package/entries/tell-input/utils/types.ts +169 -0
  31. package/package.json +4 -1
  32. package/utils/cn.ts +6 -0
  33. package/utils/index.ts +10 -0
  34. package/utils/sizes.ts +48 -0
@@ -0,0 +1,522 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { computed, onMounted, ref, watch } from 'vue';
4
+ import { Check, ChevronDown, Search } from 'lucide-vue-next';
5
+ import { cn } from '@/utils';
6
+ import {
7
+ AResponsivePopover,
8
+ AResponsivePopoverContent,
9
+ AResponsivePopoverTrigger,
10
+ } from '@/entries/responsive-popover';
11
+ import {
12
+ usePhoneValidation,
13
+ localizeCountries,
14
+ type CountryOption,
15
+ } from '../composables/usePhoneValidation';
16
+ import { controlPaddingX, controlTextSize, DEFAULT_SIZE, type Size } from '@/utils';
17
+ import ACountryFlag from './ACountryFlag.vue';
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ class?: HTMLAttributes['class'];
22
+ triggerClass?: HTMLAttributes['class'];
23
+ contentClass?: HTMLAttributes['class'];
24
+ popoverClass?: HTMLAttributes['class'];
25
+ drawerClass?: HTMLAttributes['class'];
26
+ searchPlaceholder?: string;
27
+ emptyText?: string;
28
+ loadingText?: string;
29
+ suggestedLabel?: string;
30
+ allCountriesLabel?: string;
31
+ /** ISO2 codes that are selectable. Others are listed but disabled. */
32
+ allowedDialCodes?: string[];
33
+ disabled?: boolean;
34
+ /** Drives the trigger button padding + text size. Matches ATellInput's `size`. */
35
+ size?: Size;
36
+ /** Max items rendered under the "Suggested" header (current + recents, deduped). */
37
+ suggestedLimit?: number;
38
+ /** Cap the number of matching countries shown in search results. */
39
+ maxResults?: number;
40
+ /** Override the flag URL builder, e.g. `(iso, w) => \`/flags/${iso}.svg\``. */
41
+ flagUrl?: (iso2: string, width: number) => string;
42
+ /**
43
+ * Custom search predicate. Default: substring match on the precomputed `search_key`.
44
+ * Return `true` to keep the country in results.
45
+ */
46
+ searcher?: (query: string, country: CountryOption) => boolean;
47
+ /**
48
+ * Provide your own country list (bypasses the REST Countries fetch). Useful when you
49
+ * already have a curated subset, an i18n'd list, or want to avoid the network call.
50
+ */
51
+ countries?: CountryOption[];
52
+ /** Override the right-side kbd hints. Pass `null` to hide. */
53
+ kbdOpen?: string | null;
54
+ kbdClose?: string | null;
55
+ /** BCP-47 locale — country names render localized via `Intl.DisplayNames`. */
56
+ locale?: string;
57
+ /** Prefix of the trigger's `aria-label` when a country is selected, e.g. `"Country"`. */
58
+ countryLabel?: string;
59
+ /** Trigger's `aria-label` when no country is selected. */
60
+ selectCountryLabel?: string;
61
+ /**
62
+ * How page scroll is blocked while the popover is open. Defaults to `'events'` — an
63
+ * event-based lock that keeps the page scrollbar visible and `position: sticky` working.
64
+ * Pass `'body'` for the legacy `body { overflow: hidden }` lock, or `'none'` to allow
65
+ * the page to scroll freely.
66
+ */
67
+ scrollLock?: 'events' | 'body' | 'none';
68
+ }>(),
69
+ {
70
+ searchPlaceholder: 'Search country or +code…',
71
+ emptyText: 'No countries found.',
72
+ loadingText: 'Loading countries…',
73
+ suggestedLabel: 'Suggested',
74
+ allCountriesLabel: 'All countries',
75
+ countryLabel: 'Country',
76
+ selectCountryLabel: 'Select country',
77
+ size: DEFAULT_SIZE,
78
+ suggestedLimit: 4,
79
+ maxResults: 80,
80
+ kbdOpen: '⌘K',
81
+ kbdClose: 'Esc',
82
+ }
83
+ );
84
+
85
+ defineSlots<{
86
+ /** Replace the entire country picker trigger button. */
87
+ trigger?: (props: {
88
+ selectedCountry: CountryOption | null;
89
+ open: boolean;
90
+ sizeClasses: string;
91
+ }) => unknown;
92
+ /** Replace the chevron icon. */
93
+ chevron?: (props: { open: boolean }) => unknown;
94
+ /** Replace just the flag rendered in the trigger and items. */
95
+ flag?: (props: { country: CountryOption; context: 'trigger' | 'item' }) => unknown;
96
+ /** Replace the entire search bar (input + icon + kbd). */
97
+ search?: (props: {
98
+ value: string;
99
+ setValue: (v: string) => void;
100
+ isSearching: boolean;
101
+ }) => unknown;
102
+ /** Replace the search-bar leading icon. */
103
+ 'search-icon'?: () => unknown;
104
+ /** Replace the loading state. */
105
+ loading?: () => unknown;
106
+ /** Replace the empty/no-results state. */
107
+ empty?: (props: { query: string }) => unknown;
108
+ /** Replace a section header. */
109
+ 'group-header'?: (props: { label: string; group: 'suggested' | 'all' }) => unknown;
110
+ /** Replace each country list row. Default render still available via &lt;ACountrySelectItem /&gt;. */
111
+ item?: (props: {
112
+ country: CountryOption;
113
+ selected: boolean;
114
+ disabled: boolean;
115
+ select: () => void;
116
+ }) => unknown;
117
+ /** Replace just the right-side check icon for the selected row. */
118
+ 'item-check'?: (props: { country: CountryOption }) => unknown;
119
+ }>();
120
+
121
+ const triggerSizeClasses = computed(
122
+ () => `${controlPaddingX[props.size]} ${controlTextSize[props.size]}`
123
+ );
124
+
125
+ const selected = defineModel<string>('selected', { default: '' });
126
+
127
+ const {
128
+ countries: internalCountries,
129
+ isCountriesLoading,
130
+ getCountries,
131
+ searchCountries: defaultSearch,
132
+ getCountryByValue: lookupInternal,
133
+ } = usePhoneValidation();
134
+
135
+ const open = ref(false);
136
+ const search = ref('');
137
+
138
+ void getCountries();
139
+
140
+ /* ---------------------------------------------------------------
141
+ * Country source — either the user-supplied list (props.countries)
142
+ * or the internal REST Countries + localStorage cache. A `locale`
143
+ * localizes the internal list's display names via `Intl.DisplayNames`;
144
+ * a caller-supplied `countries` list is used verbatim (caller owns names).
145
+ * ------------------------------------------------------------- */
146
+ const effectiveCountries = computed<CountryOption[]>(() =>
147
+ props.countries && props.countries.length
148
+ ? props.countries
149
+ : localizeCountries(internalCountries.value, props.locale)
150
+ );
151
+
152
+ const effectiveByValue = computed<Map<string, CountryOption>>(
153
+ () => new Map(effectiveCountries.value.map((c) => [c.value, c]))
154
+ );
155
+
156
+ function lookup(iso2: string): CountryOption | null {
157
+ if (!iso2) return null;
158
+ return effectiveByValue.value.get(iso2) ?? lookupInternal(iso2);
159
+ }
160
+
161
+ /* ---------------------------------------------------------------
162
+ * Recent picks — persisted so subsequent visits surface the user's
163
+ * actual countries above the long alphabetical list.
164
+ * ------------------------------------------------------------- */
165
+ const RECENTS_KEY = 'ali_ui_country_recents_v1';
166
+ const recents = ref<string[]>([]);
167
+
168
+ function loadRecents() {
169
+ if (typeof window === 'undefined') return;
170
+ try {
171
+ const raw = localStorage.getItem(RECENTS_KEY);
172
+ if (!raw) return;
173
+ const parsed = JSON.parse(raw);
174
+ if (!Array.isArray(parsed)) return;
175
+ recents.value = parsed.filter((v): v is string => typeof v === 'string').slice(0, 8);
176
+ } catch {
177
+ /* ignore corrupt cache */
178
+ }
179
+ }
180
+
181
+ function pushRecent(iso2: string) {
182
+ if (typeof window === 'undefined' || !iso2) return;
183
+ const next = [iso2, ...recents.value.filter((x) => x !== iso2)].slice(0, 8);
184
+ recents.value = next;
185
+ try {
186
+ localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
187
+ } catch {
188
+ /* quota or storage disabled */
189
+ }
190
+ }
191
+
192
+ onMounted(loadRecents);
193
+
194
+ /* ---------------------------------------------------------------
195
+ * Section state
196
+ * ------------------------------------------------------------- */
197
+ const isSearching = computed(() => search.value.trim().length > 0);
198
+
199
+ function defaultSearcher(q: string, c: CountryOption): boolean {
200
+ return c.search_key.includes(q.toLowerCase());
201
+ }
202
+
203
+ const filtered = computed<CountryOption[]>(() => {
204
+ if (!isSearching.value) return [];
205
+ // When the caller didn't override the country source, the internal `searchCountries`
206
+ // is already optimal (uses the precomputed search_key + early break). Fall back to a
207
+ // manual filter when we need to honor a custom `searcher`/`countries` source, or a
208
+ // `locale` (whose localized `search_key` lives only on `effectiveCountries`).
209
+ if (!props.countries && !props.searcher && !props.locale) {
210
+ return defaultSearch(search.value, props.maxResults);
211
+ }
212
+ const q = search.value.trim();
213
+ const matcher = props.searcher ?? defaultSearcher;
214
+ const out: CountryOption[] = [];
215
+ for (const c of effectiveCountries.value) {
216
+ if (matcher(q, c)) {
217
+ out.push(c);
218
+ if (out.length >= props.maxResults) break;
219
+ }
220
+ }
221
+ return out;
222
+ });
223
+
224
+ const suggested = computed<CountryOption[]>(() => {
225
+ if (isSearching.value) return [];
226
+ const seen = new Set<string>();
227
+ const out: CountryOption[] = [];
228
+ const candidate = (iso: string) => {
229
+ if (!iso || seen.has(iso)) return;
230
+ const c = lookup(iso);
231
+ if (!c) return;
232
+ seen.add(iso);
233
+ out.push(c);
234
+ };
235
+ candidate(selected.value);
236
+
237
+ const allowed = props.allowedDialCodes;
238
+ const hasAllowed = Array.isArray(allowed) && allowed.length > 0;
239
+ if (hasAllowed) {
240
+ // Surface every whitelisted country in the Suggested group — they're the only
241
+ // selectable options, so they belong at the top and the recents/limit logic is
242
+ // irrelevant here.
243
+ for (const c of effectiveCountries.value) {
244
+ if (allowed.includes(c.raw_data.dial_digits)) candidate(c.value);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ for (const r of recents.value) {
250
+ candidate(r);
251
+ if (out.length >= props.suggestedLimit) break;
252
+ }
253
+ return out.slice(0, props.suggestedLimit);
254
+ });
255
+
256
+ const allCountries = computed<CountryOption[]>(() => {
257
+ if (isSearching.value) return [];
258
+ return effectiveCountries.value;
259
+ });
260
+
261
+ const selectedCountry = computed<CountryOption | null>(() => lookup(selected.value));
262
+
263
+ function isAllowed(option: CountryOption) {
264
+ const allowed = props.allowedDialCodes;
265
+ if (!allowed || allowed.length === 0) return true;
266
+ return allowed.includes(option.raw_data.dial_digits);
267
+ }
268
+
269
+ function selectCountry(option: CountryOption) {
270
+ if (!isAllowed(option)) return;
271
+ selected.value = option.value;
272
+ pushRecent(option.value);
273
+ open.value = false;
274
+ }
275
+
276
+ watch(open, (isOpen) => {
277
+ if (!isOpen) search.value = '';
278
+ });
279
+
280
+ defineExpose({
281
+ open,
282
+ setOpen: (v: boolean) => (open.value = v),
283
+ search,
284
+ setSearch: (v: string) => (search.value = v),
285
+ selectedCountry,
286
+ selectCountry,
287
+ countries: effectiveCountries,
288
+ recents,
289
+ });
290
+ </script>
291
+
292
+ <template>
293
+ <AResponsivePopover v-model:open="open" :scroll-lock="props.scrollLock">
294
+ <AResponsivePopoverTrigger as-child>
295
+ <slot
296
+ name="trigger"
297
+ :selected-country="selectedCountry"
298
+ :open="open"
299
+ :size-classes="triggerSizeClasses"
300
+ >
301
+ <button
302
+ type="button"
303
+ :disabled="props.disabled"
304
+ data-slot="country-select-trigger"
305
+ :data-state="open ? 'open' : 'closed'"
306
+ :class="
307
+ cn(
308
+ 'bg-transparent hover:bg-muted focus-visible:bg-muted data-[state=open]:bg-muted focus-visible:ring-ring inline-flex h-full shrink-0 items-center gap-1.5 transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
309
+ triggerSizeClasses,
310
+ props.triggerClass
311
+ )
312
+ "
313
+ :aria-label="
314
+ selectedCountry
315
+ ? `${props.countryLabel}: ${selectedCountry.raw_data.name}`
316
+ : props.selectCountryLabel
317
+ "
318
+ >
319
+ <slot v-if="selectedCountry" name="flag" :country="selectedCountry" context="trigger">
320
+ <ACountryFlag
321
+ :iso2="selectedCountry.raw_data.iso2"
322
+ :src="selectedCountry.raw_data.flag"
323
+ :flag-url="props.flagUrl"
324
+ />
325
+ </slot>
326
+ <slot name="chevron" :open="open">
327
+ <ChevronDown
328
+ class="text-muted-foreground size-3.5 shrink-0 transition-transform duration-200"
329
+ :class="open && 'rotate-180'"
330
+ />
331
+ </slot>
332
+ </button>
333
+ </slot>
334
+ </AResponsivePopoverTrigger>
335
+
336
+ <AResponsivePopoverContent
337
+ align="end"
338
+ :side-offset="6"
339
+ :class="cn('flex flex-col overflow-hidden p-0', props.contentClass)"
340
+ :popover-class="
341
+ cn(
342
+ 'w-[min(20rem,calc(100vw-2rem))] max-h-[min(22rem,var(--reka-popover-content-available-height))]',
343
+ props.popoverClass
344
+ )
345
+ "
346
+ :drawer-class="cn('max-h-[80vh] pb-4', props.drawerClass)"
347
+ >
348
+ <!-- Search header -->
349
+ <slot
350
+ name="search"
351
+ :value="search"
352
+ :set-value="(v: string) => (search = v)"
353
+ :is-searching="isSearching"
354
+ >
355
+ <div class="border-border/70 border-b p-1.5">
356
+ <div
357
+ class="bg-muted/40 ring-border/70 focus-within:ring-ring/50 relative flex items-center rounded-md ring-1 transition-shadow"
358
+ >
359
+ <slot name="search-icon">
360
+ <Search
361
+ class="text-muted-foreground absolute top-1/2 start-2.5 size-3.5 -translate-y-1/2"
362
+ />
363
+ </slot>
364
+ <input
365
+ v-model="search"
366
+ type="text"
367
+ data-slot="country-select-search"
368
+ :placeholder="props.searchPlaceholder"
369
+ class="placeholder:text-muted-foreground h-10 w-full bg-transparent pe-14 ps-8 text-sm outline-none"
370
+ />
371
+ <kbd
372
+ v-if="!isSearching && props.kbdOpen"
373
+ class="bg-background text-muted-foreground border-border absolute top-1/2 end-2 hidden -translate-y-1/2 items-center gap-0.5 rounded border px-1.5 py-0.5 font-mono text-[10px] tracking-tight md:inline-flex"
374
+ >
375
+ {{ props.kbdOpen }}
376
+ </kbd>
377
+ <kbd
378
+ v-else-if="isSearching && props.kbdClose"
379
+ class="bg-background text-muted-foreground border-border absolute top-1/2 end-2 hidden -translate-y-1/2 rounded border px-1.5 py-0.5 font-mono text-[10px] tracking-tight md:inline-block"
380
+ >
381
+ {{ props.kbdClose }}
382
+ </kbd>
383
+ </div>
384
+ </div>
385
+ </slot>
386
+
387
+ <!-- List -->
388
+ <div class="flex-1 overflow-y-auto">
389
+ <slot v-if="isCountriesLoading && effectiveCountries.length === 0" name="loading">
390
+ <div class="text-muted-foreground p-4 text-center text-sm">
391
+ {{ props.loadingText }}
392
+ </div>
393
+ </slot>
394
+
395
+ <slot v-else-if="isSearching && filtered.length === 0" name="empty" :query="search">
396
+ <div class="text-muted-foreground p-4 text-center text-sm">
397
+ {{ props.emptyText }}
398
+ </div>
399
+ </slot>
400
+
401
+ <template v-else>
402
+ <!-- Suggested group -->
403
+ <section
404
+ v-if="suggested.length > 0"
405
+ data-slot="country-select-group"
406
+ data-group="suggested"
407
+ >
408
+ <slot name="group-header" :label="props.suggestedLabel" group="suggested">
409
+ <header
410
+ class="text-muted-foreground bg-popover sticky top-0 z-10 px-3 py-1.5 text-[10px] font-medium tracking-wider uppercase"
411
+ >
412
+ {{ props.suggestedLabel }}
413
+ </header>
414
+ </slot>
415
+ <ul role="listbox" :aria-label="props.suggestedLabel" class="pb-1">
416
+ <li
417
+ v-for="option in suggested"
418
+ :key="`s-${option.value}`"
419
+ role="option"
420
+ :aria-selected="option.value === selected"
421
+ :aria-disabled="!isAllowed(option)"
422
+ >
423
+ <slot
424
+ name="item"
425
+ :country="option"
426
+ :selected="option.value === selected"
427
+ :disabled="!isAllowed(option)"
428
+ :select="() => selectCountry(option)"
429
+ >
430
+ <button
431
+ type="button"
432
+ :disabled="!isAllowed(option)"
433
+ data-slot="country-select-item"
434
+ :data-selected="option.value === selected ? '' : undefined"
435
+ class="hover:bg-muted/60 focus-visible:bg-muted/60 data-[selected]:bg-muted flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
436
+ @click="selectCountry(option)"
437
+ >
438
+ <slot name="flag" :country="option" context="item">
439
+ <ACountryFlag
440
+ :iso2="option.raw_data.iso2"
441
+ :src="option.raw_data.flag"
442
+ :flag-url="props.flagUrl"
443
+ />
444
+ </slot>
445
+ <span class="flex-1 truncate">{{ option.raw_data.name }}</span>
446
+ <span class="text-muted-foreground tabular-nums">{{
447
+ option.raw_data.dial_code
448
+ }}</span>
449
+ <slot v-if="option.value === selected" name="item-check" :country="option">
450
+ <Check class="text-foreground size-3.5 shrink-0" />
451
+ </slot>
452
+ </button>
453
+ </slot>
454
+ </li>
455
+ </ul>
456
+ </section>
457
+
458
+ <!-- All countries / search results -->
459
+ <section data-slot="country-select-group" data-group="all">
460
+ <slot
461
+ v-if="!isSearching && allCountries.length > 0"
462
+ name="group-header"
463
+ :label="props.allCountriesLabel"
464
+ group="all"
465
+ >
466
+ <header
467
+ class="text-muted-foreground bg-popover sticky top-0 z-10 px-3 py-1.5 text-[10px] font-medium tracking-wider uppercase"
468
+ >
469
+ {{ props.allCountriesLabel }}
470
+ </header>
471
+ </slot>
472
+ <ul
473
+ role="listbox"
474
+ :aria-label="isSearching ? props.searchPlaceholder : props.allCountriesLabel"
475
+ class="pb-1"
476
+ >
477
+ <li
478
+ v-for="option in isSearching ? filtered : allCountries"
479
+ :key="option.value"
480
+ role="option"
481
+ :aria-selected="option.value === selected"
482
+ :aria-disabled="!isAllowed(option)"
483
+ >
484
+ <slot
485
+ name="item"
486
+ :country="option"
487
+ :selected="option.value === selected"
488
+ :disabled="!isAllowed(option)"
489
+ :select="() => selectCountry(option)"
490
+ >
491
+ <button
492
+ type="button"
493
+ :disabled="!isAllowed(option)"
494
+ data-slot="country-select-item"
495
+ :data-selected="option.value === selected ? '' : undefined"
496
+ class="hover:bg-muted/60 focus-visible:bg-muted/60 data-[selected]:bg-muted flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
497
+ @click="selectCountry(option)"
498
+ >
499
+ <slot name="flag" :country="option" context="item">
500
+ <ACountryFlag
501
+ :iso2="option.raw_data.iso2"
502
+ :src="option.raw_data.flag"
503
+ :flag-url="props.flagUrl"
504
+ />
505
+ </slot>
506
+ <span class="flex-1 truncate">{{ option.raw_data.name }}</span>
507
+ <span class="text-muted-foreground tabular-nums">{{
508
+ option.raw_data.dial_code
509
+ }}</span>
510
+ <slot v-if="option.value === selected" name="item-check" :country="option">
511
+ <Check class="text-foreground size-3.5 shrink-0" />
512
+ </slot>
513
+ </button>
514
+ </slot>
515
+ </li>
516
+ </ul>
517
+ </section>
518
+ </template>
519
+ </div>
520
+ </AResponsivePopoverContent>
521
+ </AResponsivePopover>
522
+ </template>