@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.
Files changed (91) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +467 -0
  3. package/lib/commonjs/CountryPicker.js +669 -0
  4. package/lib/commonjs/CountryPicker.js.map +1 -0
  5. package/lib/commonjs/Flag.js +23 -0
  6. package/lib/commonjs/Flag.js.map +1 -0
  7. package/lib/commonjs/PhoneInput.js +252 -0
  8. package/lib/commonjs/PhoneInput.js.map +1 -0
  9. package/lib/commonjs/countries.js +75 -0
  10. package/lib/commonjs/countries.js.map +1 -0
  11. package/lib/commonjs/data/countries.json +1472 -0
  12. package/lib/commonjs/index.js +71 -0
  13. package/lib/commonjs/index.js.map +1 -0
  14. package/lib/commonjs/package.json +1 -0
  15. package/lib/commonjs/safeArea.js +44 -0
  16. package/lib/commonjs/safeArea.js.map +1 -0
  17. package/lib/commonjs/theme.js +85 -0
  18. package/lib/commonjs/theme.js.map +1 -0
  19. package/lib/commonjs/types.js +6 -0
  20. package/lib/commonjs/types.js.map +1 -0
  21. package/lib/commonjs/value.js +68 -0
  22. package/lib/commonjs/value.js.map +1 -0
  23. package/lib/module/CountryPicker.js +665 -0
  24. package/lib/module/CountryPicker.js.map +1 -0
  25. package/lib/module/Flag.js +19 -0
  26. package/lib/module/Flag.js.map +1 -0
  27. package/lib/module/PhoneInput.js +248 -0
  28. package/lib/module/PhoneInput.js.map +1 -0
  29. package/lib/module/countries.js +65 -0
  30. package/lib/module/countries.js.map +1 -0
  31. package/lib/module/data/countries.json +1472 -0
  32. package/lib/module/index.js +8 -0
  33. package/lib/module/index.js.map +1 -0
  34. package/lib/module/package.json +1 -0
  35. package/lib/module/safeArea.js +39 -0
  36. package/lib/module/safeArea.js.map +1 -0
  37. package/lib/module/theme.js +81 -0
  38. package/lib/module/theme.js.map +1 -0
  39. package/lib/module/types.js +4 -0
  40. package/lib/module/types.js.map +1 -0
  41. package/lib/module/value.js +62 -0
  42. package/lib/module/value.js.map +1 -0
  43. package/lib/typescript/commonjs/package.json +1 -0
  44. package/lib/typescript/commonjs/src/CountryPicker.d.ts +37 -0
  45. package/lib/typescript/commonjs/src/CountryPicker.d.ts.map +1 -0
  46. package/lib/typescript/commonjs/src/Flag.d.ts +10 -0
  47. package/lib/typescript/commonjs/src/Flag.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/src/PhoneInput.d.ts +3 -0
  49. package/lib/typescript/commonjs/src/PhoneInput.d.ts.map +1 -0
  50. package/lib/typescript/commonjs/src/countries.d.ts +28 -0
  51. package/lib/typescript/commonjs/src/countries.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/src/index.d.ts +9 -0
  53. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/src/safeArea.d.ts +11 -0
  55. package/lib/typescript/commonjs/src/safeArea.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/src/theme.d.ts +53 -0
  57. package/lib/typescript/commonjs/src/theme.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/src/types.d.ts +211 -0
  59. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/src/value.d.ts +22 -0
  61. package/lib/typescript/commonjs/src/value.d.ts.map +1 -0
  62. package/lib/typescript/module/package.json +1 -0
  63. package/lib/typescript/module/src/CountryPicker.d.ts +37 -0
  64. package/lib/typescript/module/src/CountryPicker.d.ts.map +1 -0
  65. package/lib/typescript/module/src/Flag.d.ts +10 -0
  66. package/lib/typescript/module/src/Flag.d.ts.map +1 -0
  67. package/lib/typescript/module/src/PhoneInput.d.ts +3 -0
  68. package/lib/typescript/module/src/PhoneInput.d.ts.map +1 -0
  69. package/lib/typescript/module/src/countries.d.ts +28 -0
  70. package/lib/typescript/module/src/countries.d.ts.map +1 -0
  71. package/lib/typescript/module/src/index.d.ts +9 -0
  72. package/lib/typescript/module/src/index.d.ts.map +1 -0
  73. package/lib/typescript/module/src/safeArea.d.ts +11 -0
  74. package/lib/typescript/module/src/safeArea.d.ts.map +1 -0
  75. package/lib/typescript/module/src/theme.d.ts +53 -0
  76. package/lib/typescript/module/src/theme.d.ts.map +1 -0
  77. package/lib/typescript/module/src/types.d.ts +211 -0
  78. package/lib/typescript/module/src/types.d.ts.map +1 -0
  79. package/lib/typescript/module/src/value.d.ts +22 -0
  80. package/lib/typescript/module/src/value.d.ts.map +1 -0
  81. package/package.json +215 -0
  82. package/src/CountryPicker.tsx +805 -0
  83. package/src/Flag.tsx +17 -0
  84. package/src/PhoneInput.tsx +363 -0
  85. package/src/countries.ts +95 -0
  86. package/src/data/countries.json +1472 -0
  87. package/src/index.tsx +21 -0
  88. package/src/safeArea.ts +50 -0
  89. package/src/theme.ts +112 -0
  90. package/src/types.ts +229 -0
  91. 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
+ });
@@ -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
+ }