@kaisarsofi/react-native-phone-input 1.0.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/LICENSE +20 -0
- package/README.md +467 -0
- package/lib/commonjs/CountryPicker.js +669 -0
- package/lib/commonjs/CountryPicker.js.map +1 -0
- package/lib/commonjs/Flag.js +23 -0
- package/lib/commonjs/Flag.js.map +1 -0
- package/lib/commonjs/PhoneInput.js +252 -0
- package/lib/commonjs/PhoneInput.js.map +1 -0
- package/lib/commonjs/countries.js +75 -0
- package/lib/commonjs/countries.js.map +1 -0
- package/lib/commonjs/data/countries.json +1472 -0
- package/lib/commonjs/index.js +71 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/safeArea.js +44 -0
- package/lib/commonjs/safeArea.js.map +1 -0
- package/lib/commonjs/theme.js +85 -0
- package/lib/commonjs/theme.js.map +1 -0
- package/lib/commonjs/types.js +6 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/commonjs/value.js +68 -0
- package/lib/commonjs/value.js.map +1 -0
- package/lib/module/CountryPicker.js +665 -0
- package/lib/module/CountryPicker.js.map +1 -0
- package/lib/module/Flag.js +19 -0
- package/lib/module/Flag.js.map +1 -0
- package/lib/module/PhoneInput.js +248 -0
- package/lib/module/PhoneInput.js.map +1 -0
- package/lib/module/countries.js +65 -0
- package/lib/module/countries.js.map +1 -0
- package/lib/module/data/countries.json +1472 -0
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/safeArea.js +39 -0
- package/lib/module/safeArea.js.map +1 -0
- package/lib/module/theme.js +81 -0
- package/lib/module/theme.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/value.js +62 -0
- package/lib/module/value.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/CountryPicker.d.ts +37 -0
- package/lib/typescript/commonjs/src/CountryPicker.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/Flag.d.ts +10 -0
- package/lib/typescript/commonjs/src/Flag.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/PhoneInput.d.ts +3 -0
- package/lib/typescript/commonjs/src/PhoneInput.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/countries.d.ts +28 -0
- package/lib/typescript/commonjs/src/countries.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +9 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/safeArea.d.ts +11 -0
- package/lib/typescript/commonjs/src/safeArea.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/theme.d.ts +53 -0
- package/lib/typescript/commonjs/src/theme.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +211 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/value.d.ts +22 -0
- package/lib/typescript/commonjs/src/value.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/CountryPicker.d.ts +37 -0
- package/lib/typescript/module/src/CountryPicker.d.ts.map +1 -0
- package/lib/typescript/module/src/Flag.d.ts +10 -0
- package/lib/typescript/module/src/Flag.d.ts.map +1 -0
- package/lib/typescript/module/src/PhoneInput.d.ts +3 -0
- package/lib/typescript/module/src/PhoneInput.d.ts.map +1 -0
- package/lib/typescript/module/src/countries.d.ts +28 -0
- package/lib/typescript/module/src/countries.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +9 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/safeArea.d.ts +11 -0
- package/lib/typescript/module/src/safeArea.d.ts.map +1 -0
- package/lib/typescript/module/src/theme.d.ts +53 -0
- package/lib/typescript/module/src/theme.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +211 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/lib/typescript/module/src/value.d.ts +22 -0
- package/lib/typescript/module/src/value.d.ts.map +1 -0
- package/package.json +215 -0
- package/src/CountryPicker.tsx +805 -0
- package/src/Flag.tsx +17 -0
- package/src/PhoneInput.tsx +363 -0
- package/src/countries.ts +95 -0
- package/src/data/countries.json +1472 -0
- package/src/index.tsx +21 -0
- package/src/safeArea.ts +50 -0
- package/src/theme.ts +112 -0
- package/src/types.ts +229 -0
- package/src/value.ts +70 -0
package/src/Flag.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Text, type StyleProp, type TextStyle } from 'react-native';
|
|
2
|
+
import type { Country } from './countries';
|
|
3
|
+
|
|
4
|
+
export interface FlagProps {
|
|
5
|
+
country: Country;
|
|
6
|
+
size?: number;
|
|
7
|
+
style?: StyleProp<TextStyle>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Renders a country's Unicode flag emoji, e.g. 🇺🇸. */
|
|
11
|
+
export function Flag({ country, size = 22, style }: FlagProps) {
|
|
12
|
+
return (
|
|
13
|
+
<Text style={[{ fontSize: size }, style]} accessibilityElementsHidden>
|
|
14
|
+
{country.flag}
|
|
15
|
+
</Text>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';
|
|
2
|
+
import type { CountryCode } from 'libphonenumber-js';
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useImperativeHandle,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ElementRef,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import {
|
|
13
|
+
Platform,
|
|
14
|
+
Pressable,
|
|
15
|
+
Text,
|
|
16
|
+
TextInput,
|
|
17
|
+
View,
|
|
18
|
+
StyleSheet,
|
|
19
|
+
} from 'react-native';
|
|
20
|
+
import {
|
|
21
|
+
COUNTRIES,
|
|
22
|
+
getCountryByCallingCode,
|
|
23
|
+
getCountryByCode,
|
|
24
|
+
getDeviceCountry,
|
|
25
|
+
} from './countries';
|
|
26
|
+
import { CountryPicker } from './CountryPicker';
|
|
27
|
+
import { Flag } from './Flag';
|
|
28
|
+
import { usePhoneInputPalette } from './theme';
|
|
29
|
+
import { buildValue, readControlledValue } from './value';
|
|
30
|
+
import type { Country, PhoneInputProps, PhoneInputRef } from './types';
|
|
31
|
+
|
|
32
|
+
export const PhoneInput = forwardRef<PhoneInputRef, PhoneInputProps>(
|
|
33
|
+
function PhoneInputImpl(
|
|
34
|
+
{
|
|
35
|
+
defaultCountry,
|
|
36
|
+
fallbackCountry = 'US',
|
|
37
|
+
country: controlledCountry,
|
|
38
|
+
value: controlledValue,
|
|
39
|
+
onChangeText,
|
|
40
|
+
onChangeInternational,
|
|
41
|
+
onChangeCountry,
|
|
42
|
+
countries: allowedCountries,
|
|
43
|
+
excludedCountries,
|
|
44
|
+
disableValidation = false,
|
|
45
|
+
autoFormat = true,
|
|
46
|
+
showCountryPicker = true,
|
|
47
|
+
displayMode = 'both',
|
|
48
|
+
renderFlag,
|
|
49
|
+
renderCountryItem,
|
|
50
|
+
searchPlaceholder,
|
|
51
|
+
disableSearch,
|
|
52
|
+
groupAlphabetically = true,
|
|
53
|
+
showAlphabetIndex = true,
|
|
54
|
+
autoDetectCountry = true,
|
|
55
|
+
style,
|
|
56
|
+
containerStyle,
|
|
57
|
+
inputStyle,
|
|
58
|
+
dialCodeStyle,
|
|
59
|
+
flagStyle,
|
|
60
|
+
countryPickerButtonStyle,
|
|
61
|
+
pickerStyles,
|
|
62
|
+
pickerSafeAreaInsets,
|
|
63
|
+
theme,
|
|
64
|
+
colorScheme,
|
|
65
|
+
palette: paletteOverrides,
|
|
66
|
+
pickerPresentationStyle,
|
|
67
|
+
onFocus,
|
|
68
|
+
onBlur,
|
|
69
|
+
...textInputProps
|
|
70
|
+
},
|
|
71
|
+
ref
|
|
72
|
+
) {
|
|
73
|
+
const palette = usePhoneInputPalette(colorScheme, paletteOverrides);
|
|
74
|
+
|
|
75
|
+
// `theme` styles the field specifically and wins over the palette's
|
|
76
|
+
// neutral defaults; the picker keeps the palette untouched, so a field
|
|
77
|
+
// restyled for a colored background doesn't take the picker with it.
|
|
78
|
+
const field = {
|
|
79
|
+
background: theme?.backgroundColor ?? palette.inputBackground,
|
|
80
|
+
border: theme?.borderColor ?? palette.border,
|
|
81
|
+
borderFocused: theme?.focusedBorderColor ?? palette.accent,
|
|
82
|
+
borderError: theme?.errorBorderColor ?? palette.danger,
|
|
83
|
+
text: theme?.textColor ?? palette.text,
|
|
84
|
+
placeholder: theme?.placeholderColor ?? palette.placeholder,
|
|
85
|
+
dialCode: theme?.dialCodeColor ?? theme?.textColor ?? palette.text,
|
|
86
|
+
divider: theme?.dividerColor ?? palette.border,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const countryList = useMemo(() => {
|
|
90
|
+
let list = COUNTRIES;
|
|
91
|
+
if (allowedCountries?.length) {
|
|
92
|
+
const allow = new Set(allowedCountries);
|
|
93
|
+
list = list.filter((c) => allow.has(c.code));
|
|
94
|
+
}
|
|
95
|
+
if (excludedCountries?.length) {
|
|
96
|
+
const exclude = new Set(excludedCountries);
|
|
97
|
+
list = list.filter((c) => !exclude.has(c.code));
|
|
98
|
+
}
|
|
99
|
+
return list;
|
|
100
|
+
}, [allowedCountries, excludedCountries]);
|
|
101
|
+
|
|
102
|
+
const [internalCountryCode, setInternalCountryCode] = useState<CountryCode>(
|
|
103
|
+
() => {
|
|
104
|
+
if (controlledCountry) return controlledCountry;
|
|
105
|
+
if (defaultCountry) return defaultCountry;
|
|
106
|
+
const device = getDeviceCountry();
|
|
107
|
+
if (device && countryList.some((c) => c.code === device.code)) {
|
|
108
|
+
return device.code;
|
|
109
|
+
}
|
|
110
|
+
return fallbackCountry;
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
const [internalNational, setInternalNational] = useState(() =>
|
|
114
|
+
controlledValue?.startsWith('+') ? '' : (controlledValue ?? '')
|
|
115
|
+
);
|
|
116
|
+
const [pickerVisible, setPickerVisible] = useState(false);
|
|
117
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
118
|
+
|
|
119
|
+
const inputRef = useRef<ElementRef<typeof TextInput>>(null);
|
|
120
|
+
|
|
121
|
+
const baseCountry =
|
|
122
|
+
getCountryByCode(controlledCountry ?? internalCountryCode) ??
|
|
123
|
+
countryList[0] ??
|
|
124
|
+
COUNTRIES[0]!;
|
|
125
|
+
|
|
126
|
+
// A controlled international value carries its own country, so it is read
|
|
127
|
+
// back out of the value itself rather than tracked as separate state.
|
|
128
|
+
// Memoized because this parses, and every keystroke is a render.
|
|
129
|
+
const controlled = useMemo(
|
|
130
|
+
() =>
|
|
131
|
+
controlledValue !== undefined
|
|
132
|
+
? readControlledValue(controlledValue, baseCountry)
|
|
133
|
+
: undefined,
|
|
134
|
+
[controlledValue, baseCountry]
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const selectedCountry = controlledCountry
|
|
138
|
+
? baseCountry
|
|
139
|
+
: (controlled?.country ?? baseCountry);
|
|
140
|
+
const nationalNumber = controlled?.national ?? internalNational;
|
|
141
|
+
|
|
142
|
+
const formattedNational = useMemo(() => {
|
|
143
|
+
if (!autoFormat) return nationalNumber;
|
|
144
|
+
const formatter = new AsYouType(selectedCountry.code);
|
|
145
|
+
return formatter.input(nationalNumber);
|
|
146
|
+
}, [nationalNumber, selectedCountry.code, autoFormat]);
|
|
147
|
+
|
|
148
|
+
const value = useMemo(
|
|
149
|
+
() => buildValue(nationalNumber, selectedCountry),
|
|
150
|
+
[nationalNumber, selectedCountry]
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
useImperativeHandle(
|
|
154
|
+
ref,
|
|
155
|
+
() => ({
|
|
156
|
+
focus: () => inputRef.current?.focus(),
|
|
157
|
+
blur: () => inputRef.current?.blur(),
|
|
158
|
+
isValid: () => value.isValid,
|
|
159
|
+
getValue: () => value,
|
|
160
|
+
}),
|
|
161
|
+
[value]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const emitChange = useCallback(
|
|
165
|
+
(digits: string, country: Country) => {
|
|
166
|
+
const next = buildValue(digits, country);
|
|
167
|
+
onChangeText?.(next);
|
|
168
|
+
onChangeInternational?.(next.international);
|
|
169
|
+
},
|
|
170
|
+
[onChangeText, onChangeInternational]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const handleChangeText = useCallback(
|
|
174
|
+
(text: string) => {
|
|
175
|
+
if (autoDetectCountry && text.trim().startsWith('+')) {
|
|
176
|
+
const parsed = parsePhoneNumberFromString(text.trim());
|
|
177
|
+
const detected =
|
|
178
|
+
getCountryByCode(parsed?.country) ??
|
|
179
|
+
getCountryByCallingCode(parsed?.countryCallingCode);
|
|
180
|
+
if (
|
|
181
|
+
detected &&
|
|
182
|
+
detected.code !== selectedCountry.code &&
|
|
183
|
+
countryList.some((c) => c.code === detected.code)
|
|
184
|
+
) {
|
|
185
|
+
const digits = parsed!.nationalNumber as string;
|
|
186
|
+
if (controlledValue === undefined) setInternalNational(digits);
|
|
187
|
+
if (controlledCountry === undefined)
|
|
188
|
+
setInternalCountryCode(detected.code);
|
|
189
|
+
onChangeCountry?.(detected);
|
|
190
|
+
emitChange(digits, detected);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const digits = text.replace(/\D/g, '');
|
|
196
|
+
if (controlledValue === undefined) setInternalNational(digits);
|
|
197
|
+
emitChange(digits, selectedCountry);
|
|
198
|
+
},
|
|
199
|
+
[
|
|
200
|
+
autoDetectCountry,
|
|
201
|
+
controlledCountry,
|
|
202
|
+
controlledValue,
|
|
203
|
+
countryList,
|
|
204
|
+
emitChange,
|
|
205
|
+
onChangeCountry,
|
|
206
|
+
selectedCountry,
|
|
207
|
+
]
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const handleSelectCountry = useCallback(
|
|
211
|
+
(next: Country) => {
|
|
212
|
+
setPickerVisible(false);
|
|
213
|
+
if (controlledCountry === undefined) setInternalCountryCode(next.code);
|
|
214
|
+
onChangeCountry?.(next);
|
|
215
|
+
emitChange(nationalNumber, next);
|
|
216
|
+
},
|
|
217
|
+
[controlledCountry, emitChange, nationalNumber, onChangeCountry]
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const borderColor =
|
|
221
|
+
!disableValidation && nationalNumber.length > 0 && !value.isValid
|
|
222
|
+
? field.borderError
|
|
223
|
+
: isFocused
|
|
224
|
+
? field.borderFocused
|
|
225
|
+
: field.border;
|
|
226
|
+
|
|
227
|
+
const showFlag = displayMode !== 'code';
|
|
228
|
+
const showCode = displayMode !== 'flag';
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<View style={[style, containerStyle]}>
|
|
232
|
+
<View
|
|
233
|
+
style={[
|
|
234
|
+
styles.container,
|
|
235
|
+
{
|
|
236
|
+
borderColor,
|
|
237
|
+
borderRadius: theme?.borderRadius ?? 10,
|
|
238
|
+
backgroundColor: field.background,
|
|
239
|
+
},
|
|
240
|
+
]}
|
|
241
|
+
>
|
|
242
|
+
{showCountryPicker && (
|
|
243
|
+
<Pressable
|
|
244
|
+
onPress={() => setPickerVisible(true)}
|
|
245
|
+
style={[styles.picker, countryPickerButtonStyle]}
|
|
246
|
+
accessibilityRole="button"
|
|
247
|
+
accessibilityLabel={`Selected country ${selectedCountry.name}, dial code +${selectedCountry.dialCode}`}
|
|
248
|
+
>
|
|
249
|
+
{renderFlag ? (
|
|
250
|
+
renderFlag(selectedCountry)
|
|
251
|
+
) : showFlag ? (
|
|
252
|
+
<Flag country={selectedCountry} size={20} style={flagStyle} />
|
|
253
|
+
) : null}
|
|
254
|
+
{showCode && (
|
|
255
|
+
<Text
|
|
256
|
+
style={[
|
|
257
|
+
styles.dialCode,
|
|
258
|
+
{ color: field.dialCode },
|
|
259
|
+
dialCodeStyle,
|
|
260
|
+
]}
|
|
261
|
+
>
|
|
262
|
+
+{selectedCountry.dialCode}
|
|
263
|
+
</Text>
|
|
264
|
+
)}
|
|
265
|
+
</Pressable>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{showCountryPicker && (
|
|
269
|
+
<View
|
|
270
|
+
style={[styles.divider, { backgroundColor: field.divider }]}
|
|
271
|
+
/>
|
|
272
|
+
)}
|
|
273
|
+
|
|
274
|
+
<TextInput
|
|
275
|
+
ref={inputRef}
|
|
276
|
+
value={formattedNational}
|
|
277
|
+
onChangeText={handleChangeText}
|
|
278
|
+
keyboardType="phone-pad"
|
|
279
|
+
placeholder={textInputProps.placeholder ?? 'Phone number'}
|
|
280
|
+
placeholderTextColor={field.placeholder}
|
|
281
|
+
style={[
|
|
282
|
+
styles.input,
|
|
283
|
+
{
|
|
284
|
+
color: field.text,
|
|
285
|
+
fontSize: theme?.fontSize ?? 16,
|
|
286
|
+
},
|
|
287
|
+
inputStyle,
|
|
288
|
+
]}
|
|
289
|
+
onFocus={(e) => {
|
|
290
|
+
setIsFocused(true);
|
|
291
|
+
onFocus?.(e);
|
|
292
|
+
}}
|
|
293
|
+
onBlur={(e) => {
|
|
294
|
+
setIsFocused(false);
|
|
295
|
+
onBlur?.(e);
|
|
296
|
+
}}
|
|
297
|
+
{...textInputProps}
|
|
298
|
+
/>
|
|
299
|
+
</View>
|
|
300
|
+
|
|
301
|
+
{showCountryPicker && (
|
|
302
|
+
<CountryPicker
|
|
303
|
+
visible={pickerVisible}
|
|
304
|
+
countries={countryList}
|
|
305
|
+
selected={selectedCountry}
|
|
306
|
+
onSelect={handleSelectCountry}
|
|
307
|
+
onClose={() => setPickerVisible(false)}
|
|
308
|
+
searchPlaceholder={searchPlaceholder}
|
|
309
|
+
disableSearch={disableSearch}
|
|
310
|
+
renderCountryItem={renderCountryItem}
|
|
311
|
+
palette={palette}
|
|
312
|
+
presentationStyle={pickerPresentationStyle}
|
|
313
|
+
groupAlphabetically={groupAlphabetically}
|
|
314
|
+
showAlphabetIndex={showAlphabetIndex}
|
|
315
|
+
styles={pickerStyles}
|
|
316
|
+
safeAreaInsets={pickerSafeAreaInsets}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
</View>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const styles = StyleSheet.create({
|
|
325
|
+
container: {
|
|
326
|
+
flexDirection: 'row',
|
|
327
|
+
alignItems: 'center',
|
|
328
|
+
borderWidth: StyleSheet.hairlineWidth * 2,
|
|
329
|
+
paddingHorizontal: 10,
|
|
330
|
+
minHeight: 46,
|
|
331
|
+
},
|
|
332
|
+
picker: {
|
|
333
|
+
flexDirection: 'row',
|
|
334
|
+
alignItems: 'center',
|
|
335
|
+
flexShrink: 0,
|
|
336
|
+
paddingRight: 8,
|
|
337
|
+
gap: 5,
|
|
338
|
+
},
|
|
339
|
+
// A real rule rather than a border on the country control: the control's
|
|
340
|
+
// border shares a color with the field's outer border, so a borderless
|
|
341
|
+
// field used to lose this divider along with it.
|
|
342
|
+
divider: {
|
|
343
|
+
width: StyleSheet.hairlineWidth * 2,
|
|
344
|
+
alignSelf: 'center',
|
|
345
|
+
height: 22,
|
|
346
|
+
marginRight: 8,
|
|
347
|
+
borderRadius: 1,
|
|
348
|
+
},
|
|
349
|
+
dialCode: {
|
|
350
|
+
fontSize: 15,
|
|
351
|
+
fontWeight: '500',
|
|
352
|
+
},
|
|
353
|
+
input: {
|
|
354
|
+
flex: 1,
|
|
355
|
+
minWidth: 0,
|
|
356
|
+
paddingVertical: 10,
|
|
357
|
+
// On web, TextInput renders as a real <input>, which gets the browser's
|
|
358
|
+
// own default focus outline on top of our border-color focus styling —
|
|
359
|
+
// showing as a doubled border. This prop is a react-native-web-specific
|
|
360
|
+
// style extension (a no-op on native) that suppresses just that.
|
|
361
|
+
...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as object) : null),
|
|
362
|
+
},
|
|
363
|
+
});
|
package/src/countries.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CountryCode } from 'libphonenumber-js';
|
|
2
|
+
import metadata from 'libphonenumber-js/metadata.min.json';
|
|
3
|
+
import countriesData from './data/countries.json';
|
|
4
|
+
|
|
5
|
+
export interface Country {
|
|
6
|
+
/** ISO 3166-1 alpha-2 country code, e.g. "US" */
|
|
7
|
+
code: CountryCode;
|
|
8
|
+
/** Country calling code without the leading "+", e.g. "1" */
|
|
9
|
+
dialCode: string;
|
|
10
|
+
/** English display name */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Unicode regional-indicator flag emoji, e.g. "🇺🇸" */
|
|
13
|
+
flag: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CountrySection {
|
|
17
|
+
letter: string;
|
|
18
|
+
data: Country[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const COUNTRIES: Country[] = countriesData as Country[];
|
|
22
|
+
|
|
23
|
+
export const COUNTRIES_BY_CODE: Record<string, Country> = COUNTRIES.reduce(
|
|
24
|
+
(acc, c) => {
|
|
25
|
+
acc[c.code] = c;
|
|
26
|
+
return acc;
|
|
27
|
+
},
|
|
28
|
+
{} as Record<string, Country>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
export function getCountryByCode(code?: string | null): Country | undefined {
|
|
32
|
+
if (!code) return undefined;
|
|
33
|
+
return COUNTRIES_BY_CODE[code.toUpperCase()];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Some calling codes are shared by several countries (e.g. "44" -> GB, GG, IM,
|
|
37
|
+
* JE). libphonenumber-js's own metadata lists the primary/most-populous country
|
|
38
|
+
* for each shared calling code first — used as the fallback when a pasted/typed
|
|
39
|
+
* number's calling code is known but its digits don't match any one country's
|
|
40
|
+
* valid number patterns closely enough to disambiguate. */
|
|
41
|
+
const CALLING_CODE_TO_MAIN_COUNTRY: Record<string, string> = Object.fromEntries(
|
|
42
|
+
Object.entries(
|
|
43
|
+
(metadata as { country_calling_codes: Record<string, string[]> })
|
|
44
|
+
.country_calling_codes
|
|
45
|
+
).map(([callingCode, codes]) => [callingCode, codes[0]!])
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export function getCountryByCallingCode(
|
|
49
|
+
callingCode?: string | null
|
|
50
|
+
): Country | undefined {
|
|
51
|
+
if (!callingCode) return undefined;
|
|
52
|
+
return getCountryByCode(CALLING_CODE_TO_MAIN_COUNTRY[callingCode]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Reads the device's region from the JS engine's default locale (e.g. "en-IN"
|
|
56
|
+
* -> "IN"), with no native module required. Used as the initial country when
|
|
57
|
+
* the consumer doesn't pass `defaultCountry`. Returns undefined if the
|
|
58
|
+
* runtime can't resolve a locale/region (some Hermes builds, very old engines). */
|
|
59
|
+
export function getDeviceCountry(): Country | undefined {
|
|
60
|
+
try {
|
|
61
|
+
const locale = Intl.NumberFormat().resolvedOptions().locale;
|
|
62
|
+
const region = new Intl.Locale(locale).region;
|
|
63
|
+
return getCountryByCode(region);
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Base A–Z letter for grouping, folding accents (e.g. "Å" -> "A") so every
|
|
70
|
+
* section maps onto the plain-ASCII quick-jump index. */
|
|
71
|
+
const COMBINING_MARKS = /[̀-ͯ]/g;
|
|
72
|
+
|
|
73
|
+
export function sectionLetterFor(name: string): string {
|
|
74
|
+
const base = name
|
|
75
|
+
.normalize('NFD')
|
|
76
|
+
.replace(COMBINING_MARKS, '')
|
|
77
|
+
.charAt(0)
|
|
78
|
+
.toUpperCase();
|
|
79
|
+
return /[A-Z]/.test(base) ? base : '#';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Groups an (already name-sorted) country list into contiguous A–Z sections. */
|
|
83
|
+
export function groupCountriesByLetter(list: Country[]): CountrySection[] {
|
|
84
|
+
const sections: CountrySection[] = [];
|
|
85
|
+
let current: CountrySection | undefined;
|
|
86
|
+
for (const country of list) {
|
|
87
|
+
const letter = sectionLetterFor(country.name);
|
|
88
|
+
if (!current || current.letter !== letter) {
|
|
89
|
+
current = { letter, data: [] };
|
|
90
|
+
sections.push(current);
|
|
91
|
+
}
|
|
92
|
+
current.data.push(country);
|
|
93
|
+
}
|
|
94
|
+
return sections;
|
|
95
|
+
}
|