@getmicdrop/svelte-components 5.17.4 → 5.18.0
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/recipes/inputs/PhoneInput.svelte +258 -0
- package/dist/recipes/inputs/PhoneInput.svelte.d.ts +42 -0
- package/dist/recipes/inputs/PhoneInput.svelte.d.ts.map +1 -0
- package/dist/recipes/inputs/index.d.ts +1 -0
- package/dist/recipes/inputs/index.js +1 -0
- package/dist/recipes/inputs/phoneInput/CountrySelector.svelte +297 -0
- package/dist/recipes/inputs/phoneInput/CountrySelector.svelte.d.ts +17 -0
- package/dist/recipes/inputs/phoneInput/CountrySelector.svelte.d.ts.map +1 -0
- package/dist/recipes/inputs/phoneInput/countryData.d.ts +20 -0
- package/dist/recipes/inputs/phoneInput/countryData.d.ts.map +1 -0
- package/dist/recipes/inputs/phoneInput/countryData.js +211 -0
- package/dist/utils/phoneUtils.d.ts +35 -0
- package/dist/utils/phoneUtils.d.ts.map +1 -0
- package/dist/utils/phoneUtils.js +104 -0
- package/package.json +6 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from "svelte";
|
|
3
|
+
import { safeSlide } from "../../utils/transitions.js";
|
|
4
|
+
import { cubicOut } from "svelte/easing";
|
|
5
|
+
import { ExclamationCircleOutline } from "../../primitives/Icons";
|
|
6
|
+
import { typography } from "../../tokens/typography";
|
|
7
|
+
import { formInputSizes } from "../../tokens/sizing";
|
|
8
|
+
import CountrySelector from "./phoneInput/CountrySelector.svelte";
|
|
9
|
+
import { COUNTRIES, getCountry, type Country } from "./phoneInput/countryData";
|
|
10
|
+
import {
|
|
11
|
+
parseStoredPhone,
|
|
12
|
+
formatAsYouType,
|
|
13
|
+
toE164,
|
|
14
|
+
isValidE164,
|
|
15
|
+
} from "../../utils/phoneUtils";
|
|
16
|
+
import type { CountryCode } from "libphonenumber-js/min";
|
|
17
|
+
|
|
18
|
+
const defaultLabels = {
|
|
19
|
+
searchPlaceholder: 'Search countries...',
|
|
20
|
+
noResults: 'No countries found',
|
|
21
|
+
countrySelector: 'Select country',
|
|
22
|
+
suggested: 'Suggested',
|
|
23
|
+
allCountries: 'All countries',
|
|
24
|
+
optional: '(optional)',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
value?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
id?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
required?: boolean;
|
|
34
|
+
disabled?: boolean;
|
|
35
|
+
optional?: boolean;
|
|
36
|
+
size?: 'sm' | 'md' | 'lg';
|
|
37
|
+
errorText?: string;
|
|
38
|
+
helperText?: string;
|
|
39
|
+
color?: 'base' | 'red';
|
|
40
|
+
controlled?: boolean;
|
|
41
|
+
buttonText?: string | null;
|
|
42
|
+
buttonDisabled?: boolean;
|
|
43
|
+
onButtonClick?: ((value: string) => void) | null;
|
|
44
|
+
defaultCountry?: string;
|
|
45
|
+
labels?: Partial<typeof defaultLabels>;
|
|
46
|
+
onchange?: (detail: { value: string; country: string; isValid: boolean }) => void;
|
|
47
|
+
animateFocus?: boolean;
|
|
48
|
+
statusText?: string;
|
|
49
|
+
statusType?: string;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let {
|
|
54
|
+
value = $bindable(''),
|
|
55
|
+
label = '',
|
|
56
|
+
id = '',
|
|
57
|
+
name = '',
|
|
58
|
+
placeholder = '',
|
|
59
|
+
required = false,
|
|
60
|
+
disabled = false,
|
|
61
|
+
optional = false,
|
|
62
|
+
size = 'md',
|
|
63
|
+
errorText = '',
|
|
64
|
+
helperText = '',
|
|
65
|
+
color = 'base',
|
|
66
|
+
controlled = false,
|
|
67
|
+
buttonText = null,
|
|
68
|
+
buttonDisabled = false,
|
|
69
|
+
onButtonClick = null,
|
|
70
|
+
defaultCountry = 'US',
|
|
71
|
+
labels: userLabels = {},
|
|
72
|
+
onchange,
|
|
73
|
+
animateFocus = true,
|
|
74
|
+
statusText = '',
|
|
75
|
+
statusType = '',
|
|
76
|
+
...restProps
|
|
77
|
+
}: Props = $props();
|
|
78
|
+
|
|
79
|
+
let labels = $derived({ ...defaultLabels, ...userLabels });
|
|
80
|
+
|
|
81
|
+
// Internal state
|
|
82
|
+
let selectedCountry = $state<Country>(getCountry(defaultCountry) || COUNTRIES.find((c) => c.code === 'US')!);
|
|
83
|
+
let nationalDigits = $state('');
|
|
84
|
+
let displayValue = $state('');
|
|
85
|
+
let inputElement = $state<HTMLInputElement | null>(null);
|
|
86
|
+
let hasInitialized = $state(false);
|
|
87
|
+
|
|
88
|
+
let hasError = $derived(color === 'red' || Boolean(errorText));
|
|
89
|
+
let sizeClass = $derived(formInputSizes[size] || formInputSizes.md);
|
|
90
|
+
let shouldAnimate = $derived(animateFocus && !disabled);
|
|
91
|
+
|
|
92
|
+
// Parse incoming value on mount or when value changes externally
|
|
93
|
+
$effect(() => {
|
|
94
|
+
const currentValue = value;
|
|
95
|
+
if (!hasInitialized || currentValue !== lastEmittedValue) {
|
|
96
|
+
parseIncomingValue(currentValue);
|
|
97
|
+
hasInitialized = true;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
let lastEmittedValue = '';
|
|
102
|
+
|
|
103
|
+
function parseIncomingValue(incoming: string) {
|
|
104
|
+
if (!incoming) {
|
|
105
|
+
nationalDigits = '';
|
|
106
|
+
displayValue = '';
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsed = parseStoredPhone(incoming);
|
|
111
|
+
if (parsed) {
|
|
112
|
+
const country = getCountry(parsed.countryCode);
|
|
113
|
+
if (country) {
|
|
114
|
+
selectedCountry = country;
|
|
115
|
+
}
|
|
116
|
+
nationalDigits = parsed.nationalNumber;
|
|
117
|
+
displayValue = formatAsYouType(parsed.countryCode as CountryCode, parsed.nationalNumber);
|
|
118
|
+
} else {
|
|
119
|
+
// Can't parse — treat as raw digits
|
|
120
|
+
nationalDigits = incoming.replace(/\D/g, '');
|
|
121
|
+
displayValue = nationalDigits;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleInput(event: Event) {
|
|
126
|
+
const target = event.target as HTMLInputElement;
|
|
127
|
+
const raw = target.value.replace(/\D/g, '');
|
|
128
|
+
nationalDigits = raw;
|
|
129
|
+
|
|
130
|
+
// Format as-you-type
|
|
131
|
+
const formatted = formatAsYouType(selectedCountry.code as CountryCode, raw);
|
|
132
|
+
displayValue = formatted;
|
|
133
|
+
target.value = formatted;
|
|
134
|
+
|
|
135
|
+
// Compute E.164 and emit
|
|
136
|
+
const e164 = toE164(selectedCountry.code as CountryCode, raw);
|
|
137
|
+
lastEmittedValue = e164;
|
|
138
|
+
value = e164;
|
|
139
|
+
|
|
140
|
+
const valid = e164 ? isValidE164(e164) : false;
|
|
141
|
+
onchange?.({ value: e164, country: selectedCountry.code, isValid: valid });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleCountryChange() {
|
|
145
|
+
// Re-format existing digits for new country
|
|
146
|
+
displayValue = formatAsYouType(selectedCountry.code as CountryCode, nationalDigits);
|
|
147
|
+
|
|
148
|
+
// Recalculate E.164
|
|
149
|
+
const e164 = toE164(selectedCountry.code as CountryCode, nationalDigits);
|
|
150
|
+
lastEmittedValue = e164;
|
|
151
|
+
value = e164;
|
|
152
|
+
|
|
153
|
+
const valid = e164 ? isValidE164(e164) : false;
|
|
154
|
+
onchange?.({ value: e164, country: selectedCountry.code, isValid: valid });
|
|
155
|
+
|
|
156
|
+
// Focus the input after country change
|
|
157
|
+
inputElement?.focus();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Watch for country changes from the selector
|
|
161
|
+
$effect(() => {
|
|
162
|
+
// Trigger on selectedCountry change (read it to create dependency)
|
|
163
|
+
const _code = selectedCountry.code;
|
|
164
|
+
if (hasInitialized && nationalDigits) {
|
|
165
|
+
handleCountryChange();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
function handleButtonClick() {
|
|
170
|
+
if (typeof onButtonClick === 'function') {
|
|
171
|
+
onButtonClick(value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function focus() {
|
|
176
|
+
inputElement?.focus();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function clear() {
|
|
180
|
+
nationalDigits = '';
|
|
181
|
+
displayValue = '';
|
|
182
|
+
value = '';
|
|
183
|
+
lastEmittedValue = '';
|
|
184
|
+
}
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<div class="flex flex-col gap-2" {...restProps}>
|
|
188
|
+
{#if label}
|
|
189
|
+
<div class="flex justify-start items-center gap-1">
|
|
190
|
+
<label for={id} class={`${typography.label} leading-tight sm:leading-none`}>
|
|
191
|
+
{label}{#if required}<span class="text-red-500 font-medium text-sm ml-0.5">*</span>{/if}
|
|
192
|
+
</label>
|
|
193
|
+
{#if statusText}
|
|
194
|
+
<span class="text-sm font-medium {statusType === 'success' ? 'text-green-600' : statusType === 'error' ? 'text-red-500' : ''}">({statusText})</span>
|
|
195
|
+
{/if}
|
|
196
|
+
{#if optional}
|
|
197
|
+
<span class={typography.smMuted}>{labels.optional}</span>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
{/if}
|
|
201
|
+
|
|
202
|
+
<div class="relative w-full">
|
|
203
|
+
<div class="flex items-stretch w-full">
|
|
204
|
+
<CountrySelector
|
|
205
|
+
bind:country={selectedCountry}
|
|
206
|
+
{disabled}
|
|
207
|
+
{size}
|
|
208
|
+
labels={{
|
|
209
|
+
searchPlaceholder: labels.searchPlaceholder,
|
|
210
|
+
noResults: labels.noResults,
|
|
211
|
+
countrySelector: labels.countrySelector,
|
|
212
|
+
suggested: labels.suggested,
|
|
213
|
+
allCountries: labels.allCountries,
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
|
|
217
|
+
<div class="relative flex-1 flex items-center">
|
|
218
|
+
<input
|
|
219
|
+
bind:this={inputElement}
|
|
220
|
+
{id}
|
|
221
|
+
type="tel"
|
|
222
|
+
{name}
|
|
223
|
+
{placeholder}
|
|
224
|
+
value={displayValue}
|
|
225
|
+
oninput={handleInput}
|
|
226
|
+
inputmode="tel"
|
|
227
|
+
autocomplete="tel"
|
|
228
|
+
class="{typography.body} w-full {sizeClass} bg-gray-50 dark:bg-gray-800 border border-l-0 rounded-r-lg font-medium placeholder-gray-500 dark:placeholder-gray-400 transition-all focus:outline-hidden focus:ring-4 focus:ring-blue-300 dark:focus:ring-blue-300 {hasError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600 hover:border-blue-500 focus:border-blue-500'} {controlled && (buttonText) ? 'pr-20' : ''} {shouldAnimate ? 'focus:scale-[1.01]' : ''} {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
229
|
+
required={false}
|
|
230
|
+
{disabled}
|
|
231
|
+
aria-required={required}
|
|
232
|
+
/>
|
|
233
|
+
|
|
234
|
+
{#if controlled && buttonText}
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onclick={handleButtonClick}
|
|
238
|
+
disabled={buttonDisabled}
|
|
239
|
+
class="absolute inset-y-0 right-0 gap-1 flex items-center justify-center px-4 text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed {helperText || errorText ? 'mb-7' : ''}"
|
|
240
|
+
>
|
|
241
|
+
<span class="text-sm font-medium">{buttonText}</span>
|
|
242
|
+
</button>
|
|
243
|
+
{/if}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
{#if errorText}
|
|
248
|
+
<div transition:safeSlide={{ duration: 300, easing: cubicOut }} class="flex items-start gap-1.5 mt-2" role="alert" aria-live="assertive">
|
|
249
|
+
<ExclamationCircleOutline class="w-4 h-4 shrink-0 text-red-500 mt-0.5" />
|
|
250
|
+
<p class={typography.error}>{errorText}</p>
|
|
251
|
+
</div>
|
|
252
|
+
{:else if helperText}
|
|
253
|
+
<div class={`mt-2 flex items-center ${typography.xsMuted} opacity-65`}>
|
|
254
|
+
<span>{helperText}</span>
|
|
255
|
+
</div>
|
|
256
|
+
{/if}
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
declare const PhoneInput: import("svelte").Component<{
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
value?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
required?: boolean;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
optional?: boolean;
|
|
11
|
+
size?: "sm" | "md" | "lg";
|
|
12
|
+
errorText?: string;
|
|
13
|
+
helperText?: string;
|
|
14
|
+
color?: "base" | "red";
|
|
15
|
+
controlled?: boolean;
|
|
16
|
+
buttonText?: string | null;
|
|
17
|
+
buttonDisabled?: boolean;
|
|
18
|
+
onButtonClick?: ((value: string) => void) | null;
|
|
19
|
+
defaultCountry?: string;
|
|
20
|
+
labels?: Partial<{
|
|
21
|
+
searchPlaceholder: string;
|
|
22
|
+
noResults: string;
|
|
23
|
+
countrySelector: string;
|
|
24
|
+
suggested: string;
|
|
25
|
+
allCountries: string;
|
|
26
|
+
optional: string;
|
|
27
|
+
}>;
|
|
28
|
+
onchange?: (detail: {
|
|
29
|
+
value: string;
|
|
30
|
+
country: string;
|
|
31
|
+
isValid: boolean;
|
|
32
|
+
}) => void;
|
|
33
|
+
animateFocus?: boolean;
|
|
34
|
+
statusText?: string;
|
|
35
|
+
statusType?: string;
|
|
36
|
+
}, {
|
|
37
|
+
focus: () => void;
|
|
38
|
+
clear: () => void;
|
|
39
|
+
}, "value">;
|
|
40
|
+
type PhoneInput = ReturnType<typeof PhoneInput>;
|
|
41
|
+
export default PhoneInput;
|
|
42
|
+
//# sourceMappingURL=PhoneInput.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"PhoneInput.svelte.d.ts","sourceRoot":"","sources":["../../../src/lib/recipes/inputs/PhoneInput.svelte.ts"],"names":[],"mappings":"AA2PA,QAAA,MAAM,UAAU;;YAlNJ,MAAM;YACN,MAAM;SACT,MAAM;WACJ,MAAM;kBACC,MAAM;eACT,OAAO;eACP,OAAO;eACP,OAAO;WACX,IAAI,GAAG,IAAI,GAAG,IAAI;gBACb,MAAM;iBACL,MAAM;YACX,MAAM,GAAG,KAAK;iBACT,OAAO;iBACP,MAAM,GAAG,IAAI;qBACT,OAAO;oBACR,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC,GAAG,IAAI;qBAC/B,MAAM;aACd,OAAO;;;;;;;MAAsB;eAC3B,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI;mBAClE,OAAO;iBACT,MAAM;iBACN,MAAM;;;;WA6LiC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -3,5 +3,6 @@ export { default as OTPInput } from "./OTPInput.svelte";
|
|
|
3
3
|
export { default as PasswordInput } from "./PasswordInput.svelte";
|
|
4
4
|
export { default as PasswordStrengthIndicator } from "./PasswordStrengthIndicator/PasswordStrengthIndicator.svelte";
|
|
5
5
|
export { default as PlaceAutocomplete } from "./PlaceAutocomplete/PlaceAutocomplete.svelte";
|
|
6
|
+
export { default as PhoneInput } from "./PhoneInput.svelte";
|
|
6
7
|
export { default as Search } from "./Search.svelte";
|
|
7
8
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -4,4 +4,5 @@ export { default as OTPInput } from './OTPInput.svelte';
|
|
|
4
4
|
export { default as PasswordInput } from './PasswordInput.svelte';
|
|
5
5
|
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator/PasswordStrengthIndicator.svelte';
|
|
6
6
|
export { default as PlaceAutocomplete } from './PlaceAutocomplete/PlaceAutocomplete.svelte';
|
|
7
|
+
export { default as PhoneInput } from './PhoneInput.svelte';
|
|
7
8
|
export { default as Search } from './Search.svelte';
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick } from "svelte";
|
|
3
|
+
import { ChevronDownOutline } from "../../../primitives/Icons";
|
|
4
|
+
import { portal as portalAction } from "../../../utils/portal.js";
|
|
5
|
+
import { typography } from "../../../tokens/typography";
|
|
6
|
+
import { bloom } from "../../../utils/transitions.js";
|
|
7
|
+
import { COUNTRIES, getSuggestedCountries, type Country } from "./countryData";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
country?: Country;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
size?: "sm" | "md" | "lg";
|
|
13
|
+
labels?: {
|
|
14
|
+
searchPlaceholder?: string;
|
|
15
|
+
noResults?: string;
|
|
16
|
+
countrySelector?: string;
|
|
17
|
+
suggested?: string;
|
|
18
|
+
allCountries?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultLabels = {
|
|
23
|
+
searchPlaceholder: 'Search countries...',
|
|
24
|
+
noResults: 'No countries found',
|
|
25
|
+
countrySelector: 'Select country',
|
|
26
|
+
suggested: 'Suggested',
|
|
27
|
+
allCountries: 'All countries',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
country = $bindable(COUNTRIES.find((c) => c.code === 'US')!),
|
|
32
|
+
disabled = false,
|
|
33
|
+
size = "md",
|
|
34
|
+
labels: userLabels = {},
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
let labels = $derived({ ...defaultLabels, ...userLabels });
|
|
38
|
+
|
|
39
|
+
let isOpen = $state(false);
|
|
40
|
+
let searchQuery = $state('');
|
|
41
|
+
let triggerElement = $state<HTMLButtonElement | null>(null);
|
|
42
|
+
let dropdownElement = $state<HTMLDivElement | null>(null);
|
|
43
|
+
let searchInputElement = $state<HTMLInputElement | null>(null);
|
|
44
|
+
let focusedIndex = $state(-1);
|
|
45
|
+
let dropdownPosition = $state({ top: 0, left: 0, width: 0 });
|
|
46
|
+
|
|
47
|
+
const instanceId = Math.random().toString(36).substring(2, 9);
|
|
48
|
+
const suggestedCountries = getSuggestedCountries();
|
|
49
|
+
|
|
50
|
+
let filteredCountries = $derived.by(() => {
|
|
51
|
+
if (!searchQuery) return COUNTRIES;
|
|
52
|
+
const q = searchQuery.toLowerCase();
|
|
53
|
+
return COUNTRIES.filter(
|
|
54
|
+
(c) =>
|
|
55
|
+
c.name.toLowerCase().includes(q) ||
|
|
56
|
+
c.dialCode.includes(q) ||
|
|
57
|
+
c.code.toLowerCase().includes(q)
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let showSuggested = $derived(!searchQuery);
|
|
62
|
+
|
|
63
|
+
// Build flat list of visible items for keyboard nav
|
|
64
|
+
let flatItems = $derived.by(() => {
|
|
65
|
+
const items: Country[] = [];
|
|
66
|
+
if (showSuggested) {
|
|
67
|
+
items.push(...suggestedCountries);
|
|
68
|
+
}
|
|
69
|
+
items.push(...filteredCountries);
|
|
70
|
+
return items;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const sizeMap = {
|
|
74
|
+
sm: 'h-9 px-2 text-sm gap-1',
|
|
75
|
+
md: 'h-10 px-2.5 text-sm gap-1.5',
|
|
76
|
+
lg: 'h-12 px-3 text-base gap-2',
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
let sizeClass = $derived(sizeMap[size] || sizeMap.md);
|
|
80
|
+
|
|
81
|
+
function updateDropdownPosition() {
|
|
82
|
+
if (!triggerElement) return;
|
|
83
|
+
const rect = triggerElement.getBoundingClientRect();
|
|
84
|
+
dropdownPosition = {
|
|
85
|
+
top: rect.bottom + 4,
|
|
86
|
+
left: rect.left,
|
|
87
|
+
width: Math.max(280, rect.width),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function toggle() {
|
|
92
|
+
if (disabled) return;
|
|
93
|
+
isOpen = !isOpen;
|
|
94
|
+
if (isOpen) {
|
|
95
|
+
searchQuery = '';
|
|
96
|
+
focusedIndex = -1;
|
|
97
|
+
window.dispatchEvent(new CustomEvent('select-opened', { detail: { instanceId } }));
|
|
98
|
+
await tick();
|
|
99
|
+
updateDropdownPosition();
|
|
100
|
+
searchInputElement?.focus();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleOtherSelectOpened(event: Event) {
|
|
105
|
+
const customEvent = event as CustomEvent<{ instanceId: string }>;
|
|
106
|
+
if (customEvent.detail.instanceId !== instanceId && isOpen) {
|
|
107
|
+
close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function close() {
|
|
112
|
+
isOpen = false;
|
|
113
|
+
searchQuery = '';
|
|
114
|
+
focusedIndex = -1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function selectCountry(c: Country) {
|
|
118
|
+
country = c;
|
|
119
|
+
close();
|
|
120
|
+
triggerElement?.focus();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
124
|
+
if (disabled) return;
|
|
125
|
+
|
|
126
|
+
switch (event.key) {
|
|
127
|
+
case "Enter":
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
if (isOpen && focusedIndex >= 0 && focusedIndex < flatItems.length) {
|
|
130
|
+
selectCountry(flatItems[focusedIndex]);
|
|
131
|
+
} else if (!isOpen) {
|
|
132
|
+
toggle();
|
|
133
|
+
}
|
|
134
|
+
break;
|
|
135
|
+
case "ArrowDown":
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
if (!isOpen) {
|
|
138
|
+
toggle();
|
|
139
|
+
} else {
|
|
140
|
+
focusedIndex = Math.min(focusedIndex + 1, flatItems.length - 1);
|
|
141
|
+
scrollToFocused();
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
case "ArrowUp":
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
if (isOpen) {
|
|
147
|
+
focusedIndex = Math.max(focusedIndex - 1, 0);
|
|
148
|
+
scrollToFocused();
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "Escape":
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
close();
|
|
154
|
+
triggerElement?.focus();
|
|
155
|
+
break;
|
|
156
|
+
case "Tab":
|
|
157
|
+
close();
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function scrollToFocused() {
|
|
163
|
+
tick().then(() => {
|
|
164
|
+
const el = dropdownElement?.querySelector(`[data-index="${focusedIndex}"]`);
|
|
165
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function handleClickOutside(event: MouseEvent) {
|
|
170
|
+
if (!isOpen) return;
|
|
171
|
+
const target = event.target as Node;
|
|
172
|
+
const clickedOutsideTrigger = triggerElement && !triggerElement.contains(target);
|
|
173
|
+
const clickedOutsideDropdown = !dropdownElement || !dropdownElement.contains(target);
|
|
174
|
+
if (clickedOutsideTrigger && clickedOutsideDropdown) {
|
|
175
|
+
close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onMount(() => {
|
|
180
|
+
document.addEventListener("click", handleClickOutside, true);
|
|
181
|
+
window.addEventListener("select-opened", handleOtherSelectOpened);
|
|
182
|
+
window.addEventListener("scroll", updateDropdownPosition, true);
|
|
183
|
+
window.addEventListener("resize", updateDropdownPosition);
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
document.removeEventListener("click", handleClickOutside, true);
|
|
187
|
+
window.removeEventListener("select-opened", handleOtherSelectOpened);
|
|
188
|
+
window.removeEventListener("scroll", updateDropdownPosition, true);
|
|
189
|
+
window.removeEventListener("resize", updateDropdownPosition);
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
let itemIndex = 0;
|
|
194
|
+
function resetIndex() {
|
|
195
|
+
itemIndex = 0;
|
|
196
|
+
}
|
|
197
|
+
function nextIndex() {
|
|
198
|
+
return itemIndex++;
|
|
199
|
+
}
|
|
200
|
+
</script>
|
|
201
|
+
|
|
202
|
+
<div class="relative shrink-0">
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
bind:this={triggerElement}
|
|
206
|
+
class="flex items-center {sizeClass} bg-gray-50 dark:bg-gray-800 border border-r-0 rounded-l-lg cursor-pointer transition-colors text-left focus:outline-hidden focus:ring-4 focus:ring-blue-300 dark:focus:ring-blue-800 border-gray-300 dark:border-gray-600 hover:border-blue-500 dark:hover:border-blue-500 {disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
207
|
+
{disabled}
|
|
208
|
+
aria-haspopup="listbox"
|
|
209
|
+
aria-expanded={isOpen}
|
|
210
|
+
aria-label={labels.countrySelector}
|
|
211
|
+
onclick={toggle}
|
|
212
|
+
onkeydown={handleKeydown}
|
|
213
|
+
>
|
|
214
|
+
<span class="text-lg leading-none">{country.flag}</span>
|
|
215
|
+
<span class={`${typography.sm} whitespace-nowrap`}>{country.dialCode}</span>
|
|
216
|
+
<ChevronDownOutline class="w-3 h-3 shrink-0 {typography.iconMuted} transition-transform duration-200 {isOpen ? 'rotate-180' : ''}" />
|
|
217
|
+
</button>
|
|
218
|
+
|
|
219
|
+
{#if isOpen}
|
|
220
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
221
|
+
<div
|
|
222
|
+
bind:this={dropdownElement}
|
|
223
|
+
use:portalAction
|
|
224
|
+
class="fixed z-[9999] bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg overflow-hidden"
|
|
225
|
+
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; width: {dropdownPosition.width}px;"
|
|
226
|
+
role="listbox"
|
|
227
|
+
tabindex="-1"
|
|
228
|
+
aria-label={labels.countrySelector}
|
|
229
|
+
transition:bloom={{ origin: "top left" }}
|
|
230
|
+
>
|
|
231
|
+
<!-- Search -->
|
|
232
|
+
<div class="p-2 border-b border-gray-200 dark:border-gray-600">
|
|
233
|
+
<input
|
|
234
|
+
bind:this={searchInputElement}
|
|
235
|
+
type="text"
|
|
236
|
+
class="w-full px-3 py-2 text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-hidden focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-800 {typography.sm} placeholder-gray-400 dark:placeholder-gray-500"
|
|
237
|
+
placeholder={labels.searchPlaceholder}
|
|
238
|
+
bind:value={searchQuery}
|
|
239
|
+
onkeydown={handleKeydown}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<!-- List -->
|
|
244
|
+
<div class="max-h-60 overflow-y-auto py-1">
|
|
245
|
+
{resetIndex()}
|
|
246
|
+
|
|
247
|
+
{#if showSuggested}
|
|
248
|
+
<div class="px-3 py-1.5">
|
|
249
|
+
<span class="text-xs font-semibold uppercase tracking-wider {typography.textMuted}">{labels.suggested}</span>
|
|
250
|
+
</div>
|
|
251
|
+
{#each suggestedCountries as c}
|
|
252
|
+
{@const idx = nextIndex()}
|
|
253
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
254
|
+
<li
|
|
255
|
+
class="flex items-center gap-3 px-3 py-2 cursor-pointer {typography.sm} transition-colors {c.code === country.code ? 'bg-blue-50 dark:bg-blue-900/30' : ''} {idx === focusedIndex ? 'bg-gray-100 dark:bg-gray-600' : 'hover:bg-gray-100 dark:hover:bg-gray-600'}"
|
|
256
|
+
role="option"
|
|
257
|
+
aria-selected={c.code === country.code}
|
|
258
|
+
data-index={idx}
|
|
259
|
+
onclick={() => selectCountry(c)}
|
|
260
|
+
onmouseenter={() => (focusedIndex = idx)}
|
|
261
|
+
>
|
|
262
|
+
<span class="text-lg leading-none">{c.flag}</span>
|
|
263
|
+
<span class="flex-1">{c.name}</span>
|
|
264
|
+
<span class={typography.textMuted}>{c.dialCode}</span>
|
|
265
|
+
</li>
|
|
266
|
+
{/each}
|
|
267
|
+
|
|
268
|
+
<div class="border-t border-gray-200 dark:border-gray-600 mx-3 my-1"></div>
|
|
269
|
+
<div class="px-3 py-1.5">
|
|
270
|
+
<span class="text-xs font-semibold uppercase tracking-wider {typography.textMuted}">{labels.allCountries}</span>
|
|
271
|
+
</div>
|
|
272
|
+
{/if}
|
|
273
|
+
|
|
274
|
+
{#each filteredCountries as c}
|
|
275
|
+
{@const idx = nextIndex()}
|
|
276
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
277
|
+
<li
|
|
278
|
+
class="flex items-center gap-3 px-3 py-2 cursor-pointer {typography.sm} transition-colors {c.code === country.code ? 'bg-blue-50 dark:bg-blue-900/30' : ''} {idx === focusedIndex ? 'bg-gray-100 dark:bg-gray-600' : 'hover:bg-gray-100 dark:hover:bg-gray-600'}"
|
|
279
|
+
role="option"
|
|
280
|
+
aria-selected={c.code === country.code}
|
|
281
|
+
data-index={idx}
|
|
282
|
+
onclick={() => selectCountry(c)}
|
|
283
|
+
onmouseenter={() => (focusedIndex = idx)}
|
|
284
|
+
>
|
|
285
|
+
<span class="text-lg leading-none">{c.flag}</span>
|
|
286
|
+
<span class="flex-1">{c.name}</span>
|
|
287
|
+
<span class={typography.textMuted}>{c.dialCode}</span>
|
|
288
|
+
</li>
|
|
289
|
+
{:else}
|
|
290
|
+
<div class="px-3 py-4 text-center {typography.textMuted}">
|
|
291
|
+
{labels.noResults}
|
|
292
|
+
</div>
|
|
293
|
+
{/each}
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
{/if}
|
|
297
|
+
</div>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Country } from "./countryData";
|
|
2
|
+
interface Props {
|
|
3
|
+
country?: Country;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
size?: "sm" | "md" | "lg";
|
|
6
|
+
labels?: {
|
|
7
|
+
searchPlaceholder?: string;
|
|
8
|
+
noResults?: string;
|
|
9
|
+
countrySelector?: string;
|
|
10
|
+
suggested?: string;
|
|
11
|
+
allCountries?: string;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
declare const CountrySelector: import("svelte").Component<Props, {}, "country">;
|
|
15
|
+
type CountrySelector = ReturnType<typeof CountrySelector>;
|
|
16
|
+
export default CountrySelector;
|
|
17
|
+
//# sourceMappingURL=CountrySelector.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CountrySelector.svelte.d.ts","sourceRoot":"","sources":["../../../../src/lib/recipes/inputs/phoneInput/CountrySelector.svelte.ts"],"names":[],"mappings":"AAQA,OAAO,EAAoC,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAG7E,UAAU,KAAK;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,MAAM,CAAC,EAAE;QACP,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAC3B,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,YAAY,CAAC,EAAE,MAAM,CAAC;KACvB,CAAC;CACH;AAyPH,QAAA,MAAM,eAAe,kDAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Country {
|
|
2
|
+
/** ISO 3166-1 alpha-2 code */
|
|
3
|
+
code: string;
|
|
4
|
+
/** Display name */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Dial code with + prefix */
|
|
7
|
+
dialCode: string;
|
|
8
|
+
/** Emoji flag */
|
|
9
|
+
flag: string;
|
|
10
|
+
/** Priority for shared dial codes (lower = higher priority) */
|
|
11
|
+
priority?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Countries pinned to the top of the selector */
|
|
14
|
+
export declare const SUGGESTED_CODES: string[];
|
|
15
|
+
export declare const COUNTRIES: Country[];
|
|
16
|
+
/** Get a country by ISO code */
|
|
17
|
+
export declare function getCountry(code: string): Country | undefined;
|
|
18
|
+
/** Get the suggested countries (pinned to top of selector) */
|
|
19
|
+
export declare function getSuggestedCountries(): Country[];
|
|
20
|
+
//# sourceMappingURL=countryData.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"countryData.d.ts","sourceRoot":"","sources":["../../../../src/lib/recipes/inputs/phoneInput/countryData.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,OAAO;IACtB,8BAA8B;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,8BAA8B;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,kDAAkD;AAClD,eAAO,MAAM,eAAe,UAAiC,CAAC;AAE9D,eAAO,MAAM,SAAS,EAAE,OAAO,EAwM9B,CAAC;AAEF,gCAAgC;AAChC,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAE5D;AAED,8DAA8D;AAC9D,wBAAgB,qBAAqB,IAAI,OAAO,EAAE,CAEjD"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/** Countries pinned to the top of the selector */
|
|
2
|
+
export const SUGGESTED_CODES = ['US', 'CA', 'GB', 'MX', 'AU'];
|
|
3
|
+
export const COUNTRIES = [
|
|
4
|
+
{ code: 'AF', name: 'Afghanistan', dialCode: '+93', flag: '🇦🇫' },
|
|
5
|
+
{ code: 'AL', name: 'Albania', dialCode: '+355', flag: '🇦🇱' },
|
|
6
|
+
{ code: 'DZ', name: 'Algeria', dialCode: '+213', flag: '🇩🇿' },
|
|
7
|
+
{ code: 'AD', name: 'Andorra', dialCode: '+376', flag: '🇦🇩' },
|
|
8
|
+
{ code: 'AO', name: 'Angola', dialCode: '+244', flag: '🇦🇴' },
|
|
9
|
+
{ code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268', flag: '🇦🇬' },
|
|
10
|
+
{ code: 'AR', name: 'Argentina', dialCode: '+54', flag: '🇦🇷' },
|
|
11
|
+
{ code: 'AM', name: 'Armenia', dialCode: '+374', flag: '🇦🇲' },
|
|
12
|
+
{ code: 'AU', name: 'Australia', dialCode: '+61', flag: '🇦🇺' },
|
|
13
|
+
{ code: 'AT', name: 'Austria', dialCode: '+43', flag: '🇦🇹' },
|
|
14
|
+
{ code: 'AZ', name: 'Azerbaijan', dialCode: '+994', flag: '🇦🇿' },
|
|
15
|
+
{ code: 'BS', name: 'Bahamas', dialCode: '+1242', flag: '🇧🇸' },
|
|
16
|
+
{ code: 'BH', name: 'Bahrain', dialCode: '+973', flag: '🇧🇭' },
|
|
17
|
+
{ code: 'BD', name: 'Bangladesh', dialCode: '+880', flag: '🇧🇩' },
|
|
18
|
+
{ code: 'BB', name: 'Barbados', dialCode: '+1246', flag: '🇧🇧' },
|
|
19
|
+
{ code: 'BY', name: 'Belarus', dialCode: '+375', flag: '🇧🇾' },
|
|
20
|
+
{ code: 'BE', name: 'Belgium', dialCode: '+32', flag: '🇧🇪' },
|
|
21
|
+
{ code: 'BZ', name: 'Belize', dialCode: '+501', flag: '🇧🇿' },
|
|
22
|
+
{ code: 'BJ', name: 'Benin', dialCode: '+229', flag: '🇧🇯' },
|
|
23
|
+
{ code: 'BT', name: 'Bhutan', dialCode: '+975', flag: '🇧🇹' },
|
|
24
|
+
{ code: 'BO', name: 'Bolivia', dialCode: '+591', flag: '🇧🇴' },
|
|
25
|
+
{ code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387', flag: '🇧🇦' },
|
|
26
|
+
{ code: 'BW', name: 'Botswana', dialCode: '+267', flag: '🇧🇼' },
|
|
27
|
+
{ code: 'BR', name: 'Brazil', dialCode: '+55', flag: '🇧🇷' },
|
|
28
|
+
{ code: 'BN', name: 'Brunei', dialCode: '+673', flag: '🇧🇳' },
|
|
29
|
+
{ code: 'BG', name: 'Bulgaria', dialCode: '+359', flag: '🇧🇬' },
|
|
30
|
+
{ code: 'BF', name: 'Burkina Faso', dialCode: '+226', flag: '🇧🇫' },
|
|
31
|
+
{ code: 'BI', name: 'Burundi', dialCode: '+257', flag: '🇧🇮' },
|
|
32
|
+
{ code: 'CV', name: 'Cabo Verde', dialCode: '+238', flag: '🇨🇻' },
|
|
33
|
+
{ code: 'KH', name: 'Cambodia', dialCode: '+855', flag: '🇰🇭' },
|
|
34
|
+
{ code: 'CM', name: 'Cameroon', dialCode: '+237', flag: '🇨🇲' },
|
|
35
|
+
{ code: 'CA', name: 'Canada', dialCode: '+1', flag: '🇨🇦', priority: 1 },
|
|
36
|
+
{ code: 'CF', name: 'Central African Republic', dialCode: '+236', flag: '🇨🇫' },
|
|
37
|
+
{ code: 'TD', name: 'Chad', dialCode: '+235', flag: '🇹🇩' },
|
|
38
|
+
{ code: 'CL', name: 'Chile', dialCode: '+56', flag: '🇨🇱' },
|
|
39
|
+
{ code: 'CN', name: 'China', dialCode: '+86', flag: '🇨🇳' },
|
|
40
|
+
{ code: 'CO', name: 'Colombia', dialCode: '+57', flag: '🇨🇴' },
|
|
41
|
+
{ code: 'KM', name: 'Comoros', dialCode: '+269', flag: '🇰🇲' },
|
|
42
|
+
{ code: 'CG', name: 'Congo', dialCode: '+242', flag: '🇨🇬' },
|
|
43
|
+
{ code: 'CD', name: 'Congo (DRC)', dialCode: '+243', flag: '🇨🇩' },
|
|
44
|
+
{ code: 'CR', name: 'Costa Rica', dialCode: '+506', flag: '🇨🇷' },
|
|
45
|
+
{ code: 'CI', name: "Côte d'Ivoire", dialCode: '+225', flag: '🇨🇮' },
|
|
46
|
+
{ code: 'HR', name: 'Croatia', dialCode: '+385', flag: '🇭🇷' },
|
|
47
|
+
{ code: 'CU', name: 'Cuba', dialCode: '+53', flag: '🇨🇺' },
|
|
48
|
+
{ code: 'CY', name: 'Cyprus', dialCode: '+357', flag: '🇨🇾' },
|
|
49
|
+
{ code: 'CZ', name: 'Czech Republic', dialCode: '+420', flag: '🇨🇿' },
|
|
50
|
+
{ code: 'DK', name: 'Denmark', dialCode: '+45', flag: '🇩🇰' },
|
|
51
|
+
{ code: 'DJ', name: 'Djibouti', dialCode: '+253', flag: '🇩🇯' },
|
|
52
|
+
{ code: 'DM', name: 'Dominica', dialCode: '+1767', flag: '🇩🇲' },
|
|
53
|
+
{ code: 'DO', name: 'Dominican Republic', dialCode: '+1809', flag: '🇩🇴' },
|
|
54
|
+
{ code: 'EC', name: 'Ecuador', dialCode: '+593', flag: '🇪🇨' },
|
|
55
|
+
{ code: 'EG', name: 'Egypt', dialCode: '+20', flag: '🇪🇬' },
|
|
56
|
+
{ code: 'SV', name: 'El Salvador', dialCode: '+503', flag: '🇸🇻' },
|
|
57
|
+
{ code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240', flag: '🇬🇶' },
|
|
58
|
+
{ code: 'ER', name: 'Eritrea', dialCode: '+291', flag: '🇪🇷' },
|
|
59
|
+
{ code: 'EE', name: 'Estonia', dialCode: '+372', flag: '🇪🇪' },
|
|
60
|
+
{ code: 'SZ', name: 'Eswatini', dialCode: '+268', flag: '🇸🇿' },
|
|
61
|
+
{ code: 'ET', name: 'Ethiopia', dialCode: '+251', flag: '🇪🇹' },
|
|
62
|
+
{ code: 'FJ', name: 'Fiji', dialCode: '+679', flag: '🇫🇯' },
|
|
63
|
+
{ code: 'FI', name: 'Finland', dialCode: '+358', flag: '🇫🇮' },
|
|
64
|
+
{ code: 'FR', name: 'France', dialCode: '+33', flag: '🇫🇷' },
|
|
65
|
+
{ code: 'GA', name: 'Gabon', dialCode: '+241', flag: '🇬🇦' },
|
|
66
|
+
{ code: 'GM', name: 'Gambia', dialCode: '+220', flag: '🇬🇲' },
|
|
67
|
+
{ code: 'GE', name: 'Georgia', dialCode: '+995', flag: '🇬🇪' },
|
|
68
|
+
{ code: 'DE', name: 'Germany', dialCode: '+49', flag: '🇩🇪' },
|
|
69
|
+
{ code: 'GH', name: 'Ghana', dialCode: '+233', flag: '🇬🇭' },
|
|
70
|
+
{ code: 'GR', name: 'Greece', dialCode: '+30', flag: '🇬🇷' },
|
|
71
|
+
{ code: 'GD', name: 'Grenada', dialCode: '+1473', flag: '🇬🇩' },
|
|
72
|
+
{ code: 'GT', name: 'Guatemala', dialCode: '+502', flag: '🇬🇹' },
|
|
73
|
+
{ code: 'GN', name: 'Guinea', dialCode: '+224', flag: '🇬🇳' },
|
|
74
|
+
{ code: 'GW', name: 'Guinea-Bissau', dialCode: '+245', flag: '🇬🇼' },
|
|
75
|
+
{ code: 'GY', name: 'Guyana', dialCode: '+592', flag: '🇬🇾' },
|
|
76
|
+
{ code: 'HT', name: 'Haiti', dialCode: '+509', flag: '🇭🇹' },
|
|
77
|
+
{ code: 'HN', name: 'Honduras', dialCode: '+504', flag: '🇭🇳' },
|
|
78
|
+
{ code: 'HK', name: 'Hong Kong', dialCode: '+852', flag: '🇭🇰' },
|
|
79
|
+
{ code: 'HU', name: 'Hungary', dialCode: '+36', flag: '🇭🇺' },
|
|
80
|
+
{ code: 'IS', name: 'Iceland', dialCode: '+354', flag: '🇮🇸' },
|
|
81
|
+
{ code: 'IN', name: 'India', dialCode: '+91', flag: '🇮🇳' },
|
|
82
|
+
{ code: 'ID', name: 'Indonesia', dialCode: '+62', flag: '🇮🇩' },
|
|
83
|
+
{ code: 'IR', name: 'Iran', dialCode: '+98', flag: '🇮🇷' },
|
|
84
|
+
{ code: 'IQ', name: 'Iraq', dialCode: '+964', flag: '🇮🇶' },
|
|
85
|
+
{ code: 'IE', name: 'Ireland', dialCode: '+353', flag: '🇮🇪' },
|
|
86
|
+
{ code: 'IL', name: 'Israel', dialCode: '+972', flag: '🇮🇱' },
|
|
87
|
+
{ code: 'IT', name: 'Italy', dialCode: '+39', flag: '🇮🇹' },
|
|
88
|
+
{ code: 'JM', name: 'Jamaica', dialCode: '+1876', flag: '🇯🇲' },
|
|
89
|
+
{ code: 'JP', name: 'Japan', dialCode: '+81', flag: '🇯🇵' },
|
|
90
|
+
{ code: 'JO', name: 'Jordan', dialCode: '+962', flag: '🇯🇴' },
|
|
91
|
+
{ code: 'KZ', name: 'Kazakhstan', dialCode: '+7', flag: '🇰🇿', priority: 1 },
|
|
92
|
+
{ code: 'KE', name: 'Kenya', dialCode: '+254', flag: '🇰🇪' },
|
|
93
|
+
{ code: 'KI', name: 'Kiribati', dialCode: '+686', flag: '🇰🇮' },
|
|
94
|
+
{ code: 'KP', name: 'North Korea', dialCode: '+850', flag: '🇰🇵' },
|
|
95
|
+
{ code: 'KR', name: 'South Korea', dialCode: '+82', flag: '🇰🇷' },
|
|
96
|
+
{ code: 'KW', name: 'Kuwait', dialCode: '+965', flag: '🇰🇼' },
|
|
97
|
+
{ code: 'KG', name: 'Kyrgyzstan', dialCode: '+996', flag: '🇰🇬' },
|
|
98
|
+
{ code: 'LA', name: 'Laos', dialCode: '+856', flag: '🇱🇦' },
|
|
99
|
+
{ code: 'LV', name: 'Latvia', dialCode: '+371', flag: '🇱🇻' },
|
|
100
|
+
{ code: 'LB', name: 'Lebanon', dialCode: '+961', flag: '🇱🇧' },
|
|
101
|
+
{ code: 'LS', name: 'Lesotho', dialCode: '+266', flag: '🇱🇸' },
|
|
102
|
+
{ code: 'LR', name: 'Liberia', dialCode: '+231', flag: '🇱🇷' },
|
|
103
|
+
{ code: 'LY', name: 'Libya', dialCode: '+218', flag: '🇱🇾' },
|
|
104
|
+
{ code: 'LI', name: 'Liechtenstein', dialCode: '+423', flag: '🇱🇮' },
|
|
105
|
+
{ code: 'LT', name: 'Lithuania', dialCode: '+370', flag: '🇱🇹' },
|
|
106
|
+
{ code: 'LU', name: 'Luxembourg', dialCode: '+352', flag: '🇱🇺' },
|
|
107
|
+
{ code: 'MO', name: 'Macao', dialCode: '+853', flag: '🇲🇴' },
|
|
108
|
+
{ code: 'MG', name: 'Madagascar', dialCode: '+261', flag: '🇲🇬' },
|
|
109
|
+
{ code: 'MW', name: 'Malawi', dialCode: '+265', flag: '🇲🇼' },
|
|
110
|
+
{ code: 'MY', name: 'Malaysia', dialCode: '+60', flag: '🇲🇾' },
|
|
111
|
+
{ code: 'MV', name: 'Maldives', dialCode: '+960', flag: '🇲🇻' },
|
|
112
|
+
{ code: 'ML', name: 'Mali', dialCode: '+223', flag: '🇲🇱' },
|
|
113
|
+
{ code: 'MT', name: 'Malta', dialCode: '+356', flag: '🇲🇹' },
|
|
114
|
+
{ code: 'MH', name: 'Marshall Islands', dialCode: '+692', flag: '🇲🇭' },
|
|
115
|
+
{ code: 'MR', name: 'Mauritania', dialCode: '+222', flag: '🇲🇷' },
|
|
116
|
+
{ code: 'MU', name: 'Mauritius', dialCode: '+230', flag: '🇲🇺' },
|
|
117
|
+
{ code: 'MX', name: 'Mexico', dialCode: '+52', flag: '🇲🇽' },
|
|
118
|
+
{ code: 'FM', name: 'Micronesia', dialCode: '+691', flag: '🇫🇲' },
|
|
119
|
+
{ code: 'MD', name: 'Moldova', dialCode: '+373', flag: '🇲🇩' },
|
|
120
|
+
{ code: 'MC', name: 'Monaco', dialCode: '+377', flag: '🇲🇨' },
|
|
121
|
+
{ code: 'MN', name: 'Mongolia', dialCode: '+976', flag: '🇲🇳' },
|
|
122
|
+
{ code: 'ME', name: 'Montenegro', dialCode: '+382', flag: '🇲🇪' },
|
|
123
|
+
{ code: 'MA', name: 'Morocco', dialCode: '+212', flag: '🇲🇦' },
|
|
124
|
+
{ code: 'MZ', name: 'Mozambique', dialCode: '+258', flag: '🇲🇿' },
|
|
125
|
+
{ code: 'MM', name: 'Myanmar', dialCode: '+95', flag: '🇲🇲' },
|
|
126
|
+
{ code: 'NA', name: 'Namibia', dialCode: '+264', flag: '🇳🇦' },
|
|
127
|
+
{ code: 'NR', name: 'Nauru', dialCode: '+674', flag: '🇳🇷' },
|
|
128
|
+
{ code: 'NP', name: 'Nepal', dialCode: '+977', flag: '🇳🇵' },
|
|
129
|
+
{ code: 'NL', name: 'Netherlands', dialCode: '+31', flag: '🇳🇱' },
|
|
130
|
+
{ code: 'NZ', name: 'New Zealand', dialCode: '+64', flag: '🇳🇿' },
|
|
131
|
+
{ code: 'NI', name: 'Nicaragua', dialCode: '+505', flag: '🇳🇮' },
|
|
132
|
+
{ code: 'NE', name: 'Niger', dialCode: '+227', flag: '🇳🇪' },
|
|
133
|
+
{ code: 'NG', name: 'Nigeria', dialCode: '+234', flag: '🇳🇬' },
|
|
134
|
+
{ code: 'MK', name: 'North Macedonia', dialCode: '+389', flag: '🇲🇰' },
|
|
135
|
+
{ code: 'NO', name: 'Norway', dialCode: '+47', flag: '🇳🇴' },
|
|
136
|
+
{ code: 'OM', name: 'Oman', dialCode: '+968', flag: '🇴🇲' },
|
|
137
|
+
{ code: 'PK', name: 'Pakistan', dialCode: '+92', flag: '🇵🇰' },
|
|
138
|
+
{ code: 'PW', name: 'Palau', dialCode: '+680', flag: '🇵🇼' },
|
|
139
|
+
{ code: 'PS', name: 'Palestine', dialCode: '+970', flag: '🇵🇸' },
|
|
140
|
+
{ code: 'PA', name: 'Panama', dialCode: '+507', flag: '🇵🇦' },
|
|
141
|
+
{ code: 'PG', name: 'Papua New Guinea', dialCode: '+675', flag: '🇵🇬' },
|
|
142
|
+
{ code: 'PY', name: 'Paraguay', dialCode: '+595', flag: '🇵🇾' },
|
|
143
|
+
{ code: 'PE', name: 'Peru', dialCode: '+51', flag: '🇵🇪' },
|
|
144
|
+
{ code: 'PH', name: 'Philippines', dialCode: '+63', flag: '🇵🇭' },
|
|
145
|
+
{ code: 'PL', name: 'Poland', dialCode: '+48', flag: '🇵🇱' },
|
|
146
|
+
{ code: 'PT', name: 'Portugal', dialCode: '+351', flag: '🇵🇹' },
|
|
147
|
+
{ code: 'PR', name: 'Puerto Rico', dialCode: '+1787', flag: '🇵🇷' },
|
|
148
|
+
{ code: 'QA', name: 'Qatar', dialCode: '+974', flag: '🇶🇦' },
|
|
149
|
+
{ code: 'RO', name: 'Romania', dialCode: '+40', flag: '🇷🇴' },
|
|
150
|
+
{ code: 'RU', name: 'Russia', dialCode: '+7', flag: '🇷🇺', priority: 0 },
|
|
151
|
+
{ code: 'RW', name: 'Rwanda', dialCode: '+250', flag: '🇷🇼' },
|
|
152
|
+
{ code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869', flag: '🇰🇳' },
|
|
153
|
+
{ code: 'LC', name: 'Saint Lucia', dialCode: '+1758', flag: '🇱🇨' },
|
|
154
|
+
{ code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784', flag: '🇻🇨' },
|
|
155
|
+
{ code: 'WS', name: 'Samoa', dialCode: '+685', flag: '🇼🇸' },
|
|
156
|
+
{ code: 'SM', name: 'San Marino', dialCode: '+378', flag: '🇸🇲' },
|
|
157
|
+
{ code: 'ST', name: 'São Tomé and Príncipe', dialCode: '+239', flag: '🇸🇹' },
|
|
158
|
+
{ code: 'SA', name: 'Saudi Arabia', dialCode: '+966', flag: '🇸🇦' },
|
|
159
|
+
{ code: 'SN', name: 'Senegal', dialCode: '+221', flag: '🇸🇳' },
|
|
160
|
+
{ code: 'RS', name: 'Serbia', dialCode: '+381', flag: '🇷🇸' },
|
|
161
|
+
{ code: 'SC', name: 'Seychelles', dialCode: '+248', flag: '🇸🇨' },
|
|
162
|
+
{ code: 'SL', name: 'Sierra Leone', dialCode: '+232', flag: '🇸🇱' },
|
|
163
|
+
{ code: 'SG', name: 'Singapore', dialCode: '+65', flag: '🇸🇬' },
|
|
164
|
+
{ code: 'SK', name: 'Slovakia', dialCode: '+421', flag: '🇸🇰' },
|
|
165
|
+
{ code: 'SI', name: 'Slovenia', dialCode: '+386', flag: '🇸🇮' },
|
|
166
|
+
{ code: 'SB', name: 'Solomon Islands', dialCode: '+677', flag: '🇸🇧' },
|
|
167
|
+
{ code: 'SO', name: 'Somalia', dialCode: '+252', flag: '🇸🇴' },
|
|
168
|
+
{ code: 'ZA', name: 'South Africa', dialCode: '+27', flag: '🇿🇦' },
|
|
169
|
+
{ code: 'SS', name: 'South Sudan', dialCode: '+211', flag: '🇸🇸' },
|
|
170
|
+
{ code: 'ES', name: 'Spain', dialCode: '+34', flag: '🇪🇸' },
|
|
171
|
+
{ code: 'LK', name: 'Sri Lanka', dialCode: '+94', flag: '🇱🇰' },
|
|
172
|
+
{ code: 'SD', name: 'Sudan', dialCode: '+249', flag: '🇸🇩' },
|
|
173
|
+
{ code: 'SR', name: 'Suriname', dialCode: '+597', flag: '🇸🇷' },
|
|
174
|
+
{ code: 'SE', name: 'Sweden', dialCode: '+46', flag: '🇸🇪' },
|
|
175
|
+
{ code: 'CH', name: 'Switzerland', dialCode: '+41', flag: '🇨🇭' },
|
|
176
|
+
{ code: 'SY', name: 'Syria', dialCode: '+963', flag: '🇸🇾' },
|
|
177
|
+
{ code: 'TW', name: 'Taiwan', dialCode: '+886', flag: '🇹🇼' },
|
|
178
|
+
{ code: 'TJ', name: 'Tajikistan', dialCode: '+992', flag: '🇹🇯' },
|
|
179
|
+
{ code: 'TZ', name: 'Tanzania', dialCode: '+255', flag: '🇹🇿' },
|
|
180
|
+
{ code: 'TH', name: 'Thailand', dialCode: '+66', flag: '🇹🇭' },
|
|
181
|
+
{ code: 'TL', name: 'Timor-Leste', dialCode: '+670', flag: '🇹🇱' },
|
|
182
|
+
{ code: 'TG', name: 'Togo', dialCode: '+228', flag: '🇹🇬' },
|
|
183
|
+
{ code: 'TO', name: 'Tonga', dialCode: '+676', flag: '🇹🇴' },
|
|
184
|
+
{ code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1868', flag: '🇹🇹' },
|
|
185
|
+
{ code: 'TN', name: 'Tunisia', dialCode: '+216', flag: '🇹🇳' },
|
|
186
|
+
{ code: 'TR', name: 'Turkey', dialCode: '+90', flag: '🇹🇷' },
|
|
187
|
+
{ code: 'TM', name: 'Turkmenistan', dialCode: '+993', flag: '🇹🇲' },
|
|
188
|
+
{ code: 'TV', name: 'Tuvalu', dialCode: '+688', flag: '🇹🇻' },
|
|
189
|
+
{ code: 'UG', name: 'Uganda', dialCode: '+256', flag: '🇺🇬' },
|
|
190
|
+
{ code: 'UA', name: 'Ukraine', dialCode: '+380', flag: '🇺🇦' },
|
|
191
|
+
{ code: 'AE', name: 'United Arab Emirates', dialCode: '+971', flag: '🇦🇪' },
|
|
192
|
+
{ code: 'GB', name: 'United Kingdom', dialCode: '+44', flag: '🇬🇧' },
|
|
193
|
+
{ code: 'US', name: 'United States', dialCode: '+1', flag: '🇺🇸', priority: 0 },
|
|
194
|
+
{ code: 'UY', name: 'Uruguay', dialCode: '+598', flag: '🇺🇾' },
|
|
195
|
+
{ code: 'UZ', name: 'Uzbekistan', dialCode: '+998', flag: '🇺🇿' },
|
|
196
|
+
{ code: 'VU', name: 'Vanuatu', dialCode: '+678', flag: '🇻🇺' },
|
|
197
|
+
{ code: 'VA', name: 'Vatican City', dialCode: '+379', flag: '🇻🇦' },
|
|
198
|
+
{ code: 'VE', name: 'Venezuela', dialCode: '+58', flag: '🇻🇪' },
|
|
199
|
+
{ code: 'VN', name: 'Vietnam', dialCode: '+84', flag: '🇻🇳' },
|
|
200
|
+
{ code: 'YE', name: 'Yemen', dialCode: '+967', flag: '🇾🇪' },
|
|
201
|
+
{ code: 'ZM', name: 'Zambia', dialCode: '+260', flag: '🇿🇲' },
|
|
202
|
+
{ code: 'ZW', name: 'Zimbabwe', dialCode: '+263', flag: '🇿🇼' },
|
|
203
|
+
];
|
|
204
|
+
/** Get a country by ISO code */
|
|
205
|
+
export function getCountry(code) {
|
|
206
|
+
return COUNTRIES.find((c) => c.code === code);
|
|
207
|
+
}
|
|
208
|
+
/** Get the suggested countries (pinned to top of selector) */
|
|
209
|
+
export function getSuggestedCountries() {
|
|
210
|
+
return SUGGESTED_CODES.map((code) => COUNTRIES.find((c) => c.code === code)).filter(Boolean);
|
|
211
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CountryCode } from 'libphonenumber-js/min';
|
|
2
|
+
export interface ParsedPhone {
|
|
3
|
+
countryCode: CountryCode;
|
|
4
|
+
nationalNumber: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse an E.164 string or bare 10-digit US number into country + national number.
|
|
8
|
+
* Returns null if unparseable.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseStoredPhone(value: string): ParsedPhone | null;
|
|
11
|
+
/**
|
|
12
|
+
* Format digits for display using the country's national format.
|
|
13
|
+
* e.g. formatNational('US', '5551234567') → '(555) 123-4567'
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatNational(isoCode: CountryCode, digits: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Format digits as-you-type for live input formatting.
|
|
18
|
+
* e.g. formatAsYouType('US', '555123') → '(555) 123'
|
|
19
|
+
*/
|
|
20
|
+
export declare function formatAsYouType(isoCode: CountryCode, digits: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Combine country code + national digits into E.164.
|
|
23
|
+
* e.g. toE164('US', '5551234567') → '+15551234567'
|
|
24
|
+
*/
|
|
25
|
+
export declare function toE164(isoCode: CountryCode, digits: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Validate a complete E.164 number.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isValidE164(value: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Backward-compat: normalize bare 10-digit US numbers to E.164.
|
|
32
|
+
* Already-E.164 numbers pass through unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeIncoming(value: string): string;
|
|
35
|
+
//# sourceMappingURL=phoneUtils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"phoneUtils.d.ts","sourceRoot":"","sources":["../../src/lib/utils/phoneUtils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AAEzD,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,WAAW,CAAC;IACzB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CA4BlE;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAK3E;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAI5E;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAWnE;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAQlD;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAOvD"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AsYouType, parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js/min';
|
|
2
|
+
/**
|
|
3
|
+
* Parse an E.164 string or bare 10-digit US number into country + national number.
|
|
4
|
+
* Returns null if unparseable.
|
|
5
|
+
*/
|
|
6
|
+
export function parseStoredPhone(value) {
|
|
7
|
+
if (!value)
|
|
8
|
+
return null;
|
|
9
|
+
// Bare 10-digit US number (legacy format)
|
|
10
|
+
const digits = value.replace(/\D/g, '');
|
|
11
|
+
if (digits.length === 10 && !value.startsWith('+')) {
|
|
12
|
+
return { countryCode: 'US', nationalNumber: digits };
|
|
13
|
+
}
|
|
14
|
+
// E.164 or other formatted number
|
|
15
|
+
try {
|
|
16
|
+
const parsed = parsePhoneNumber(value);
|
|
17
|
+
if (parsed && parsed.country) {
|
|
18
|
+
return {
|
|
19
|
+
countryCode: parsed.country,
|
|
20
|
+
nationalNumber: parsed.nationalNumber
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Fall through
|
|
26
|
+
}
|
|
27
|
+
// +1 with 10 digits but parsePhoneNumber didn't resolve country (US/CA ambiguity)
|
|
28
|
+
if (value.startsWith('+1') && digits.length === 11) {
|
|
29
|
+
return { countryCode: 'US', nationalNumber: digits.slice(1) };
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Format digits for display using the country's national format.
|
|
35
|
+
* e.g. formatNational('US', '5551234567') → '(555) 123-4567'
|
|
36
|
+
*/
|
|
37
|
+
export function formatNational(isoCode, digits) {
|
|
38
|
+
if (!digits)
|
|
39
|
+
return '';
|
|
40
|
+
const formatter = new AsYouType(isoCode);
|
|
41
|
+
formatter.input(digits);
|
|
42
|
+
return formatter.getNumber()?.formatNational() ?? formatter.getNumber()?.number ?? digits;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Format digits as-you-type for live input formatting.
|
|
46
|
+
* e.g. formatAsYouType('US', '555123') → '(555) 123'
|
|
47
|
+
*/
|
|
48
|
+
export function formatAsYouType(isoCode, digits) {
|
|
49
|
+
if (!digits)
|
|
50
|
+
return '';
|
|
51
|
+
const formatter = new AsYouType(isoCode);
|
|
52
|
+
return formatter.input(digits);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Combine country code + national digits into E.164.
|
|
56
|
+
* e.g. toE164('US', '5551234567') → '+15551234567'
|
|
57
|
+
*/
|
|
58
|
+
export function toE164(isoCode, digits) {
|
|
59
|
+
if (!digits)
|
|
60
|
+
return '';
|
|
61
|
+
const clean = digits.replace(/\D/g, '');
|
|
62
|
+
if (!clean)
|
|
63
|
+
return '';
|
|
64
|
+
try {
|
|
65
|
+
const parsed = parsePhoneNumber(clean, isoCode);
|
|
66
|
+
if (parsed)
|
|
67
|
+
return parsed.format('E.164');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Fall through
|
|
71
|
+
}
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Validate a complete E.164 number.
|
|
76
|
+
*/
|
|
77
|
+
export function isValidE164(value) {
|
|
78
|
+
if (!value)
|
|
79
|
+
return false;
|
|
80
|
+
if (!value.startsWith('+'))
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
return isValidPhoneNumber(value);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Backward-compat: normalize bare 10-digit US numbers to E.164.
|
|
91
|
+
* Already-E.164 numbers pass through unchanged.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeIncoming(value) {
|
|
94
|
+
if (!value)
|
|
95
|
+
return '';
|
|
96
|
+
if (value.startsWith('+'))
|
|
97
|
+
return value;
|
|
98
|
+
const digits = value.replace(/\D/g, '');
|
|
99
|
+
if (digits.length === 10)
|
|
100
|
+
return `+1${digits}`;
|
|
101
|
+
if (digits.length === 11 && digits.startsWith('1'))
|
|
102
|
+
return `+${digits}`;
|
|
103
|
+
return value;
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getmicdrop/svelte-components",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.18.0",
|
|
4
4
|
"description": "Shared component library for Micdrop applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -107,6 +107,10 @@
|
|
|
107
107
|
"./utils/imageValidation": {
|
|
108
108
|
"import": "./dist/utils/imageValidation.js"
|
|
109
109
|
},
|
|
110
|
+
"./utils/phoneUtils": {
|
|
111
|
+
"types": "./dist/utils/phoneUtils.d.ts",
|
|
112
|
+
"import": "./dist/utils/phoneUtils.js"
|
|
113
|
+
},
|
|
110
114
|
"./utils/portal": {
|
|
111
115
|
"import": "./dist/utils/portal.js"
|
|
112
116
|
},
|
|
@@ -227,6 +231,7 @@
|
|
|
227
231
|
"filepond-plugin-image-exif-orientation": "^1.0.11",
|
|
228
232
|
"filepond-plugin-image-preview": "^4.6.12",
|
|
229
233
|
"jwt-decode": "^4.0.0",
|
|
234
|
+
"libphonenumber-js": "^1.12.38",
|
|
230
235
|
"sortablejs": "^1.15.6",
|
|
231
236
|
"svelte-easy-crop": "^5.0.0",
|
|
232
237
|
"svelte-filepond": "^0.2.2",
|