@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.
- 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,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 <ACountrySelectItem />. */
|
|
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>
|