react-native-country-select 0.1.3 โ†’ 0.2.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/README.md CHANGED
@@ -47,11 +47,13 @@
47
47
 
48
48
  ## Features
49
49
 
50
- - ๐Ÿ“ฑ Cross-platform: works with iOS, Android and Expo;
51
- - ๐ŸŽจ Lib with custom and modern UI;
52
- - ๐Ÿ‘จโ€๐Ÿ’ป Functional and class component support;
53
- - ๐Ÿˆถ 32 languages supported;
54
- - โ™ฟ Accessibility.
50
+ - ๐Ÿ“ฑ **Cross-Platform** โ€“ Works seamlessly on **iOS, Android and Web**;
51
+ - ๐Ÿงฉ **Flexible Integration** โ€“ Supports both **React Native CLI & Expo**;
52
+ - ๐ŸŽจ **Modern UI** - Custom component with sleek design;
53
+ - ๐Ÿ‘จโ€๐Ÿ’ป **Component Versatility** - Works with **functional & class components**;
54
+ - ๐Ÿˆถ **internationalization** - Supports **32 languages**;
55
+ - ๐Ÿงช **Test Ready** โ€“ Smooth testing integration;
56
+ - โ™ฟ **Accessibility** โ€“ Accessibility standards to screen readers.
55
57
 
56
58
  <br>
57
59
 
@@ -71,6 +73,51 @@ yarn add react-native-country-select
71
73
 
72
74
  <br>
73
75
 
76
+ ## Additional config to WEB
77
+
78
+ - ### Using React Native CLI:
79
+
80
+ Create a `react-native.config.js` file at the root of your react-native project with:
81
+
82
+ ```bash
83
+ module.exports = {
84
+ project: {
85
+ ios: {},
86
+ android: {},
87
+ },
88
+ assets: [
89
+ './node_modules/react-native-country-select/lib/assets/fonts',
90
+ ],
91
+ };
92
+ ```
93
+
94
+ Then link the font to your native projects with:
95
+
96
+ ```bash
97
+ npx react-native-asset
98
+ ```
99
+
100
+ - ### Using Expo:
101
+
102
+ 1. Install [expo-fonts](https://docs.expo.dev/versions/latest/sdk/font/): `npx expo install expo-font`;
103
+ 2. Initialize the `expo-font`:
104
+
105
+ ```bash
106
+ import { useFonts } from 'expo-font';
107
+
108
+ ...
109
+
110
+ useFonts({
111
+ 'TwemojiMozilla': require('./node_modules/react-native-country-select/lib/assets/fonts/TwemojiMozilla.woff2'),
112
+ });
113
+
114
+ ...
115
+ ```
116
+
117
+ > Observation: you need to recompile your project after adding new fonts.
118
+
119
+ <br>
120
+
74
121
  ## Basic Usage
75
122
 
76
123
  - Class Component
@@ -201,28 +248,34 @@ export default function App() {
201
248
 
202
249
  ## CountrySelect Props
203
250
 
204
- | Prop | Type | Required | Default | Description |
205
- | ---------------------------- | ------------------------------------- | -------- | ------------------- | ---------------------------------------------------------- |
206
- | visible | boolean | Yes | false | Controls the visibility of the country picker modal |
207
- | onClose | () => void | Yes | - | Callback function called when the modal is closed |
208
- | onSelect | (country: ICountry) => void | Yes | - | Callback function called when a country is selected |
209
- | countrySelectStyle | ICountrySelectStyle | No | - | Custom styles for the country picker |
210
- | isFullScreen | boolean | No | false | Whether the modal should be full screen |
211
- | popularCountries | string[] | No | [] | Array of country codes to show in popular section |
212
- | visibleCountries | ICountryCca2[] | No | [] | Array of country codes to show (whitelist) |
213
- | hiddenCountries | ICountryCca2[] | No | [] | Array of country codes to hide (blacklist) |
214
- | theme | 'light' \| 'dark' | No | 'light' | Theme for the country picker |
215
- | language | ICountrySelectLanguages | No | 'eng' | Language for country names (see supported languages below) |
216
- | showSearchInput | boolean | No | true | Whether to show the search input field |
217
- | searchPlaceholder | string | No | 'Search country...' | Placeholder text for search input |
218
- | disabledBackdropPress | boolean | No | false | Whether to disable backdrop press to close |
219
- | removedBackdrop | boolean | No | false | Whether to remove the backdrop completely |
220
- | onBackdropPress | () => void | No | - | Custom callback for backdrop press |
221
- | countryItemComponent | (item: ICountry) => ReactElement | No | - | Custom component for country items |
222
- | sectionTitleComponent | (item: ISectionTitle) => ReactElement | No | - | Custom component for section titles |
223
- | popularCountriesTitle | string | No | 'Popular Countries' | Popular Countries section title |
224
- | allCountriesTitle | string | No | 'All Countries' | All Countries section title |
225
- | showsVerticalScrollIndicator | boolean | No | false | displays a horizontal scroll indicator |
251
+ | Prop | Type | Required | Default | Description |
252
+ | ---------------------------- | ----------------------------------------------------------------------- | -------- | ------------------- | ---------------------------------------------------------- |
253
+ | visible | boolean | Yes | false | Controls the visibility of the country picker modal |
254
+ | onClose | () => void | Yes | - | Callback function called when the modal is closed |
255
+ | onSelect | (country: [ICountry](lib/interfaces/country.ts)) => void | Yes | - | Callback function called when a country is selected |
256
+ | modalType | 'bottomSheet' \| 'popup' | No | 'bottomSheet' | Type of modal to display |
257
+ | countrySelectStyle | [ICountrySelectStyle](lib/interfaces/countrySelectStyles.ts) | No | - | Custom styles for the country picker |
258
+ | isFullScreen | boolean | No | false | Whether the modal should be full screen |
259
+ | popularCountries | string[] | No | [] | Array of country codes to show in popular section |
260
+ | visibleCountries | [ICountryCca2[]](lib/interfaces/countryCca2.ts) | No | [] | Array of country codes to show (whitelist) |
261
+ | hiddenCountries | [ICountryCca2[]](lib/interfaces/countryCca2.ts) | No | [] | Array of country codes to hide (blacklist) |
262
+ | theme | 'light' \| 'dark' | No | 'light' | Theme for the country picker |
263
+ | language | [ICountrySelectLanguages](lib/interfaces/countrySelectLanguages.ts) | No | 'eng' | Language for country names (see supported languages below) |
264
+ | showSearchInput | boolean | No | true | Whether to show the search input field |
265
+ | searchPlaceholder | string | No | 'Search country...' | Placeholder text for search input |
266
+ | minBottomsheetHeight | number \| string | No | 30% | Minimum height for bottom sheet modal |
267
+ | maxBottomsheetHeight | number \| string | No | 80% | Maximum height for bottom sheet modal |
268
+ | initialBottomsheetHeight | number \| string | No | 50% | Initial height for bottom sheet modal |
269
+ | disabledBackdropPress | boolean | No | false | Whether to disable backdrop press to close |
270
+ | removedBackdrop | boolean | No | false | Whether to remove the backdrop completely |
271
+ | onBackdropPress | () => void | No | - | Custom callback for backdrop press |
272
+ | countryItemComponent | (item: [ICountry](lib/interfaces/country.ts)) => ReactElement | No | - | Custom component for country items |
273
+ | sectionTitleComponent | (item: [ISectionTitle](lib/interfaces/sectionTitle.ts)) => ReactElement | No | - | Custom component for section titles |
274
+ | closeButtonComponent | () => ReactElement | No | - | Custom component for closeButton |
275
+ | showCloseButton | boolean | No | false | Whether to show the close button |
276
+ | popularCountriesTitle | string | No | 'Popular Countries' | Popular Countries section title |
277
+ | allCountriesTitle | string | No | 'All Countries' | All Countries section title |
278
+ | showsVerticalScrollIndicator | boolean | No | false | Displays a horizontal scroll indicator |
226
279
 
227
280
  <br>
228
281
 
@@ -274,8 +327,11 @@ When utilizing this package, you may need to target the CountrySelect component
274
327
 
275
328
  ```js
276
329
  const countrySelect = getByTestId('countrySelectSearchInput');
330
+ const countrySelectBackdrop = getByTestId('countrySelectBackdrop');
277
331
  const countrySelectList = getByTestId('countrySelectList');
332
+ const countrySelectSearchInput = getByTestId('countrySelectSearchInput');
278
333
  const countrySelectItem = getByTestId('countrySelectItem');
334
+ const countrySelectCloseButton = getByTestId('countrySelectCloseButton');
279
335
  ```
280
336
 
281
337
  <br>
Binary file
@@ -13,6 +13,7 @@ export const CountryItem = memo<ICountryItemProps>(
13
13
  onClose,
14
14
  theme = 'light',
15
15
  language,
16
+ modalType,
16
17
  countrySelectStyle,
17
18
  }) => {
18
19
  const styles = createStyles(theme);
@@ -23,23 +24,40 @@ export const CountryItem = memo<ICountryItemProps>(
23
24
  accessibilityRole="button"
24
25
  accessibilityLabel="Country Select Item"
25
26
  accessibilityHint="Click to select a country"
26
- style={[styles.countryItem, countrySelectStyle?.popup?.countryItem]}
27
+ style={[
28
+ styles.countryItem,
29
+ modalType === 'popup'
30
+ ? countrySelectStyle?.popup?.countryItem
31
+ : countrySelectStyle?.bottomSheet?.countryItem,
32
+ ]}
27
33
  onPress={() => {
28
34
  onSelect(item);
29
35
  onClose();
30
36
  }}>
31
37
  <Text
32
38
  testID="countrySelectItemFlag"
33
- style={[styles.flag, countrySelectStyle?.popup?.flag]}>
39
+ style={[
40
+ styles.flag,
41
+ modalType === 'popup'
42
+ ? countrySelectStyle?.popup?.flag
43
+ : countrySelectStyle?.bottomSheet?.flag,
44
+ ]}>
34
45
  {item.flag}
35
46
  </Text>
36
47
  <View
37
- style={[styles.countryInfo, countrySelectStyle?.popup?.countryInfo]}>
48
+ style={[
49
+ styles.countryInfo,
50
+ modalType === 'popup'
51
+ ? countrySelectStyle?.popup?.countryInfo
52
+ : countrySelectStyle?.bottomSheet?.countryInfo,
53
+ ]}>
38
54
  <Text
39
55
  testID="countrySelectItemCallingCode"
40
56
  style={[
41
57
  styles.callingCode,
42
- countrySelectStyle?.popup?.callingCode,
58
+ modalType === 'popup'
59
+ ? countrySelectStyle?.popup?.callingCode
60
+ : countrySelectStyle?.bottomSheet?.callingCode,
43
61
  ]}>
44
62
  {item.idd.root}
45
63
  </Text>
@@ -47,7 +65,9 @@ export const CountryItem = memo<ICountryItemProps>(
47
65
  testID="countrySelectItemName"
48
66
  style={[
49
67
  styles.countryName,
50
- countrySelectStyle?.popup?.countryName,
68
+ modalType === 'popup'
69
+ ? countrySelectStyle?.popup?.countryName
70
+ : countrySelectStyle?.bottomSheet?.countryName,
51
71
  ]}>
52
72
  {item?.translations[language]?.common ||
53
73
  item?.translations[DEFAULT_LANGUAGE]?.common}
@@ -1,13 +1,17 @@
1
1
  /* eslint-disable react-native/no-inline-styles */
2
2
  /* eslint-disable react-hooks/exhaustive-deps */
3
- import React, {useCallback, useMemo, useState} from 'react';
3
+ import React, {useCallback, useMemo, useState, useRef, useEffect} from 'react';
4
4
  import {
5
5
  View,
6
6
  TextInput,
7
7
  FlatList,
8
+ useWindowDimensions,
8
9
  Pressable,
10
+ Animated,
11
+ PanResponder,
9
12
  ListRenderItem,
10
13
  Modal,
14
+ Keyboard,
11
15
  Text,
12
16
  TouchableOpacity,
13
17
  } from 'react-native';
@@ -15,6 +19,7 @@ import {
15
19
  import {CountryItem} from '../CountryItem';
16
20
 
17
21
  import {createStyles} from '../styles';
22
+ import parseHeight from '../../utils/parseHeight';
18
23
  import countries from '../../constants/countries.json';
19
24
  import {translations} from '../../utils/getTranslation';
20
25
  import {
@@ -24,12 +29,20 @@ import {
24
29
  IListItem,
25
30
  } from '../../interface';
26
31
 
32
+ const ITEM_HEIGHT = 56;
33
+ const SECTION_HEADER_HEIGHT = 40;
34
+
35
+ const MIN_HEIGHT_PERCENTAGE = 0.3;
36
+ const MAX_HEIGHT_PERCENTAGE = 0.9;
37
+ const INITIAL_HEIGHT_PERCENTAGE = 0.5;
38
+
27
39
  const DEFAULT_LANGUAGE: ICountrySelectLanguages = 'eng';
28
40
 
29
41
  export const CountrySelect: React.FC<ICountrySelectProps> = ({
30
42
  visible,
31
43
  onClose,
32
44
  onSelect,
45
+ modalType = 'bottomSheet',
33
46
  theme = 'light',
34
47
  isFullScreen = false,
35
48
  countrySelectStyle,
@@ -39,19 +52,184 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
39
52
  language = DEFAULT_LANGUAGE,
40
53
  showSearchInput = true,
41
54
  searchPlaceholder,
55
+ showCloseButton = false,
56
+ minBottomsheetHeight,
57
+ maxBottomsheetHeight,
58
+ initialBottomsheetHeight,
42
59
  disabledBackdropPress,
43
60
  removedBackdrop,
44
61
  onBackdropPress,
45
62
  sectionTitleComponent,
46
63
  countryItemComponent,
64
+ closeButtonComponent,
47
65
  popularCountriesTitle,
48
66
  allCountriesTitle,
49
67
  showsVerticalScrollIndicator = false,
50
68
  ...props
51
69
  }) => {
70
+ const {height: windowHeight} = useWindowDimensions();
52
71
  const styles = createStyles(theme);
53
72
 
54
73
  const [searchQuery, setSearchQuery] = useState('');
74
+ const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
75
+ const [bottomSheetSize, setBottomSheetSize] = useState({
76
+ minHeight: MIN_HEIGHT_PERCENTAGE * windowHeight,
77
+ maxHeight: MAX_HEIGHT_PERCENTAGE * windowHeight,
78
+ initialHeight: INITIAL_HEIGHT_PERCENTAGE * windowHeight,
79
+ });
80
+
81
+ const sheetHeight = useRef(
82
+ new Animated.Value(bottomSheetSize.initialHeight),
83
+ ).current;
84
+ const lastHeight = useRef(bottomSheetSize.initialHeight);
85
+ const dragStartY = useRef(0);
86
+
87
+ useEffect(() => {
88
+ if (modalType === 'popup') {
89
+ return;
90
+ }
91
+
92
+ const DRAG_HANDLE_HEIGHT = 20;
93
+ const availableHeight = windowHeight - DRAG_HANDLE_HEIGHT;
94
+
95
+ const parsedMinHeight = parseHeight(minBottomsheetHeight, availableHeight);
96
+ const parsedMaxHeight = parseHeight(maxBottomsheetHeight, availableHeight);
97
+ const parsedInitialHeight = parseHeight(
98
+ initialBottomsheetHeight,
99
+ availableHeight,
100
+ );
101
+
102
+ setBottomSheetSize({
103
+ minHeight: parsedMinHeight || MIN_HEIGHT_PERCENTAGE * availableHeight,
104
+ maxHeight: parsedMaxHeight || MAX_HEIGHT_PERCENTAGE * availableHeight,
105
+ initialHeight:
106
+ parsedInitialHeight || INITIAL_HEIGHT_PERCENTAGE * availableHeight,
107
+ });
108
+ }, [
109
+ minBottomsheetHeight,
110
+ maxBottomsheetHeight,
111
+ initialBottomsheetHeight,
112
+ windowHeight,
113
+ modalType,
114
+ ]);
115
+
116
+ // Resets to initial height when the modal is opened
117
+ useEffect(() => {
118
+ if (modalType === 'popup') {
119
+ return;
120
+ }
121
+
122
+ if (visible) {
123
+ sheetHeight.setValue(bottomSheetSize.initialHeight);
124
+ lastHeight.current = bottomSheetSize.initialHeight;
125
+ }
126
+ }, [visible, bottomSheetSize.initialHeight, modalType]);
127
+
128
+ useEffect(() => {
129
+ if (modalType === 'popup') {
130
+ return;
131
+ }
132
+
133
+ if (isKeyboardVisible) {
134
+ sheetHeight.setValue(
135
+ parseHeight(bottomSheetSize.maxHeight, windowHeight),
136
+ );
137
+ lastHeight.current = bottomSheetSize.maxHeight;
138
+ } else {
139
+ sheetHeight.setValue(parseHeight(lastHeight.current, windowHeight));
140
+ }
141
+ }, [isKeyboardVisible, modalType]);
142
+
143
+ // Monitors keyboard events
144
+ useEffect(() => {
145
+ if (modalType === 'popup') {
146
+ return;
147
+ }
148
+
149
+ const keyboardDidShowListener = Keyboard.addListener(
150
+ 'keyboardDidShow',
151
+ () => {
152
+ setIsKeyboardVisible(true);
153
+ },
154
+ );
155
+ const keyboardDidHideListener = Keyboard.addListener(
156
+ 'keyboardDidHide',
157
+ () => {
158
+ setIsKeyboardVisible(false);
159
+ },
160
+ );
161
+
162
+ return () => {
163
+ keyboardDidShowListener?.remove();
164
+ keyboardDidHideListener?.remove();
165
+ };
166
+ }, [modalType]);
167
+
168
+ const handlePanResponder = useMemo(
169
+ () =>
170
+ PanResponder.create({
171
+ onStartShouldSetPanResponder: () => {
172
+ // Only respond to touches on the drag handle
173
+ return true;
174
+ },
175
+ onMoveShouldSetPanResponder: (evt, gestureState) => {
176
+ // Only respond to vertical movements with sufficient distance
177
+ return Math.abs(gestureState.dy) > 5;
178
+ },
179
+ onPanResponderGrant: e => {
180
+ dragStartY.current = e.nativeEvent.pageY;
181
+ sheetHeight.stopAnimation();
182
+ },
183
+ onPanResponderMove: e => {
184
+ const currentY = e.nativeEvent.pageY;
185
+ const dy = currentY - dragStartY.current;
186
+ const proposedHeight = lastHeight.current - dy;
187
+
188
+ // Allows free dragging but with smooth limits
189
+ sheetHeight.setValue(proposedHeight);
190
+ },
191
+ onPanResponderRelease: e => {
192
+ const currentY = e.nativeEvent.pageY;
193
+ const dy = currentY - dragStartY.current;
194
+ const currentHeight = lastHeight.current - dy;
195
+
196
+ // Close modal if the final height is less than minHeight
197
+ if (currentHeight < bottomSheetSize.minHeight) {
198
+ Animated.timing(sheetHeight, {
199
+ toValue: 0,
200
+ duration: 200,
201
+ useNativeDriver: false,
202
+ }).start(onClose);
203
+ return;
204
+ }
205
+
206
+ // Snap to nearest limit
207
+ const finalHeight = Math.min(
208
+ Math.max(currentHeight, bottomSheetSize.minHeight),
209
+ bottomSheetSize.maxHeight,
210
+ );
211
+
212
+ Animated.spring(sheetHeight, {
213
+ toValue: finalHeight,
214
+ useNativeDriver: false,
215
+ tension: 50,
216
+ friction: 12,
217
+ }).start(() => {
218
+ lastHeight.current = finalHeight;
219
+ });
220
+ },
221
+ onPanResponderTerminate: () => {
222
+ // Reset to last stable height if gesture is terminated
223
+ Animated.spring(sheetHeight, {
224
+ toValue: lastHeight.current,
225
+ useNativeDriver: false,
226
+ tension: 50,
227
+ friction: 12,
228
+ }).start();
229
+ },
230
+ }),
231
+ [bottomSheetSize, sheetHeight, onClose],
232
+ );
55
233
 
56
234
  // Obtain the country name in the selected language
57
235
  const getCountryNameInLanguage = (country: ICountry): string => {
@@ -118,6 +296,7 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
118
296
  );
119
297
  });
120
298
 
299
+ // Ordenar os paรญses filtrados alfabeticamente
121
300
  return sortCountriesAlphabetically(filteredCountries);
122
301
  }
123
302
 
@@ -177,6 +356,98 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
177
356
  [],
178
357
  );
179
358
 
359
+ const getItemLayout = useCallback(
360
+ (data: IListItem[] | null | undefined, index: number) => {
361
+ let offset = 0;
362
+ let length = ITEM_HEIGHT;
363
+
364
+ if (data) {
365
+ const item = data[index];
366
+ if ('isSection' in item) {
367
+ length = SECTION_HEADER_HEIGHT;
368
+ }
369
+ }
370
+
371
+ return {
372
+ length,
373
+ offset: offset + index * ITEM_HEIGHT,
374
+ index,
375
+ };
376
+ },
377
+ [],
378
+ );
379
+
380
+ const renderCloseButton = () => {
381
+ if (closeButtonComponent) {
382
+ return closeButtonComponent();
383
+ }
384
+
385
+ return (
386
+ <TouchableOpacity
387
+ testID="countrySelectCloseButton"
388
+ accessibilityRole="button"
389
+ accessibilityLabel="Country Select Modal Close Button"
390
+ accessibilityHint="Click to close the Country Select modal"
391
+ style={[styles.closeButton, countrySelectStyle?.popup?.closeButton]}
392
+ activeOpacity={0.6}
393
+ onPress={onClose}>
394
+ <Text
395
+ style={[
396
+ styles.closeButtonText,
397
+ countrySelectStyle?.popup?.closeButtonText,
398
+ ]}>
399
+ {'\u00D7'}
400
+ </Text>
401
+ </TouchableOpacity>
402
+ );
403
+ };
404
+
405
+ const renderSearchInput = () => {
406
+ return (
407
+ <TextInput
408
+ testID="countrySelectSearchInput"
409
+ accessibilityRole="text"
410
+ accessibilityLabel="Country Select Search Input"
411
+ accessibilityHint="Type to search for a country"
412
+ style={[
413
+ styles.searchInput,
414
+ modalType === 'popup'
415
+ ? countrySelectStyle?.popup?.searchInput
416
+ : countrySelectStyle?.bottomSheet?.searchInput,
417
+ ]}
418
+ placeholder={
419
+ searchPlaceholder ||
420
+ translations.searchPlaceholder[language as ICountrySelectLanguages]
421
+ }
422
+ placeholderTextColor={
423
+ (modalType === 'popup'
424
+ ? countrySelectStyle?.popup?.searchInputPlaceholder?.color
425
+ : countrySelectStyle?.bottomSheet?.searchInputPlaceholder?.color) ||
426
+ styles.searchInputPlaceholder.color
427
+ }
428
+ value={searchQuery}
429
+ onChangeText={setSearchQuery}
430
+ />
431
+ );
432
+ };
433
+
434
+ const renderFlatList = () => {
435
+ return (
436
+ <FlatList
437
+ testID="countrySelectList"
438
+ accessibilityRole="list"
439
+ accessibilityLabel="Country Select List"
440
+ accessibilityHint="List of countries"
441
+ data={getCountries}
442
+ keyExtractor={keyExtractor}
443
+ renderItem={renderItem}
444
+ getItemLayout={getItemLayout}
445
+ keyboardShouldPersistTaps="handled"
446
+ showsVerticalScrollIndicator={showsVerticalScrollIndicator || false}
447
+ />
448
+ );
449
+ };
450
+
180
451
  const renderItem: ListRenderItem<IListItem> = useCallback(
181
452
  ({item, index}) => {
182
453
  if ('isSection' in item) {
@@ -189,7 +460,9 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
189
460
  accessibilityRole="header"
190
461
  style={[
191
462
  styles.sectionTitle,
192
- countrySelectStyle?.popup?.sectionTitle,
463
+ modalType === 'popup'
464
+ ? countrySelectStyle?.popup?.sectionTitle
465
+ : countrySelectStyle?.bottomSheet?.sectionTitle,
193
466
  ]}>
194
467
  {popularCountriesTitle && index === 0
195
468
  ? popularCountriesTitle
@@ -211,6 +484,7 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
211
484
  onClose={onClose}
212
485
  theme={theme}
213
486
  language={language}
487
+ modalType={modalType}
214
488
  countrySelectStyle={countrySelectStyle}
215
489
  />
216
490
  );
@@ -225,95 +499,111 @@ export const CountrySelect: React.FC<ICountrySelectProps> = ({
225
499
  ],
226
500
  );
227
501
 
502
+ if (modalType === 'popup' || isFullScreen) {
503
+ return (
504
+ <Modal
505
+ visible={visible}
506
+ transparent
507
+ animationType="fade"
508
+ onRequestClose={onClose}
509
+ statusBarTranslucent
510
+ {...props}>
511
+ <Pressable
512
+ testID="countrySelectBackdrop"
513
+ accessibilityRole="button"
514
+ accessibilityLabel="Country Select Modal Backdrop"
515
+ accessibilityHint="Click to close the Country Select modal"
516
+ disabled={disabledBackdropPress || removedBackdrop}
517
+ style={[
518
+ styles.backdrop,
519
+ {alignItems: 'center', justifyContent: 'center'},
520
+ countrySelectStyle?.popup?.backdrop,
521
+ removedBackdrop && {backgroundColor: 'transparent'},
522
+ ]}
523
+ onPress={onBackdropPress || onClose}>
524
+ <Pressable
525
+ style={[
526
+ styles.popupContainer,
527
+ countrySelectStyle?.popup?.popupContainer,
528
+ isFullScreen && {
529
+ flex: 1,
530
+ width: '100%',
531
+ height: '100%',
532
+ },
533
+ ]}>
534
+ <View
535
+ style={[
536
+ styles.popupContent,
537
+ countrySelectStyle?.popup?.popupContent,
538
+ isFullScreen && {
539
+ borderRadius: 0,
540
+ },
541
+ ]}>
542
+ {(isFullScreen || showSearchInput || showCloseButton) && (
543
+ <View
544
+ style={[
545
+ styles.searchContainer,
546
+ countrySelectStyle?.popup?.searchContainer,
547
+ ]}>
548
+ {(isFullScreen || showCloseButton) && renderCloseButton()}
549
+ {showSearchInput && renderSearchInput()}
550
+ </View>
551
+ )}
552
+
553
+ {renderFlatList()}
554
+ </View>
555
+ </Pressable>
556
+ </Pressable>
557
+ </Modal>
558
+ );
559
+ }
560
+
228
561
  return (
229
562
  <Modal
230
563
  visible={visible}
231
564
  transparent
232
- animationType="fade"
565
+ animationType="slide"
233
566
  onRequestClose={onClose}
234
567
  statusBarTranslucent
235
568
  {...props}>
236
- <Pressable
569
+ <View
237
570
  style={[
238
571
  styles.backdrop,
239
- {alignItems: 'center', justifyContent: 'center'},
240
- countrySelectStyle?.popup?.backdrop,
241
572
  removedBackdrop && {backgroundColor: 'transparent'},
242
- ]}
243
- disabled={disabledBackdropPress || removedBackdrop}
244
- onPress={onBackdropPress || onClose}>
573
+ ]}>
245
574
  <Pressable
246
- style={[
247
- styles.popupContainer,
248
- countrySelectStyle?.popup?.popupContainer,
249
- isFullScreen && {flex: 1, width: '100%', height: '100%'},
250
- ]}>
251
- <View
575
+ testID="countrySelectBackdrop"
576
+ accessibilityRole="button"
577
+ accessibilityLabel="Country Select Modal Backdrop"
578
+ accessibilityHint="Click to close the Country Select modal"
579
+ disabled={disabledBackdropPress || removedBackdrop}
580
+ style={{flex: 1}}
581
+ onPress={onBackdropPress || onClose}
582
+ />
583
+ <View style={styles.sheetContainer} pointerEvents="auto">
584
+ <View {...handlePanResponder.panHandlers} style={styles.dragHandle}>
585
+ <View style={styles.dragIndicator} />
586
+ </View>
587
+ <Animated.View
252
588
  style={[
253
- styles.popupContent,
254
- countrySelectStyle?.popup?.popupContent,
589
+ styles.sheetContent,
590
+ {
591
+ height: sheetHeight,
592
+ minHeight: bottomSheetSize.minHeight,
593
+ maxHeight: bottomSheetSize.maxHeight,
594
+ },
255
595
  ]}>
256
- <View
257
- style={[
258
- styles.searchContainer,
259
- countrySelectStyle?.popup?.searchContainer,
260
- ]}>
261
- {isFullScreen && (
262
- <TouchableOpacity
263
- style={[
264
- styles.closeButton,
265
- countrySelectStyle?.popup?.closeButton,
266
- ]}
267
- activeOpacity={0.6}
268
- onPress={onClose}>
269
- <Text
270
- style={[
271
- styles.closeButtonText,
272
- countrySelectStyle?.popup?.closeButtonText,
273
- ]}>
274
- {'\u00D7'}
275
- </Text>
276
- </TouchableOpacity>
277
- )}
278
- {showSearchInput && (
279
- <TextInput
280
- testID="countrySelectSearchInput"
281
- accessibilityRole="text"
282
- accessibilityLabel="Country Select Search Input"
283
- accessibilityHint="Type to search for a country"
284
- style={[
285
- styles.searchInput,
286
- countrySelectStyle?.popup?.searchInput,
287
- ]}
288
- placeholder={
289
- searchPlaceholder ||
290
- translations.searchPlaceholder[
291
- language as ICountrySelectLanguages
292
- ]
293
- }
294
- placeholderTextColor={styles.searchInputPlaceholder.color}
295
- value={searchQuery}
296
- onChangeText={setSearchQuery}
297
- />
298
- )}
299
- </View>
596
+ {(showSearchInput || showCloseButton) && (
597
+ <View style={styles.searchContainer}>
598
+ {showCloseButton && renderCloseButton()}
599
+ {showSearchInput && renderSearchInput()}
600
+ </View>
601
+ )}
300
602
 
301
- <FlatList
302
- testID="countrySelectList"
303
- accessibilityRole="list"
304
- accessibilityLabel="Country Select List"
305
- accessibilityHint="List of countries"
306
- data={getCountries}
307
- keyExtractor={keyExtractor}
308
- renderItem={renderItem}
309
- keyboardShouldPersistTaps="handled"
310
- showsVerticalScrollIndicator={
311
- showsVerticalScrollIndicator || false
312
- }
313
- />
314
- </View>
315
- </Pressable>
316
- </Pressable>
603
+ <Animated.View style={{flex: 1}}>{renderFlatList()}</Animated.View>
604
+ </Animated.View>
605
+ </View>
606
+ </View>
317
607
  </Modal>
318
608
  );
319
609
  };
@@ -1,13 +1,13 @@
1
- import {StatusBar, StyleSheet} from 'react-native';
1
+ import {Platform, StatusBar, StyleSheet} from 'react-native';
2
2
 
3
- export const createStyles = (theme: 'light' | 'dark') =>
3
+ export const createStyles = theme =>
4
4
  StyleSheet.create({
5
5
  backdrop: {
6
6
  flex: 1,
7
7
  backgroundColor: 'rgba(0, 0, 0, 0.5)',
8
8
  },
9
9
  popupContainer: {
10
- paddingTop: StatusBar.currentHeight,
10
+ marginTop: StatusBar.currentHeight,
11
11
  width: '90%',
12
12
  maxWidth: 600,
13
13
  height: '60%',
@@ -23,9 +23,40 @@ export const createStyles = (theme: 'light' | 'dark') =>
23
23
  alignSelf: 'center',
24
24
  padding: 16,
25
25
  },
26
+ sheetContainer: {
27
+ marginTop: StatusBar.currentHeight,
28
+ position: 'absolute',
29
+ bottom: 0,
30
+ left: 0,
31
+ right: 0,
32
+ width: '100%',
33
+ alignItems: 'center',
34
+ },
35
+ sheetContent: {
36
+ width: '100%',
37
+ backgroundColor: theme === 'dark' ? '#202020' : '#FFFFFF',
38
+ padding: 16,
39
+ paddingTop: 0,
40
+ },
41
+ dragHandle: {
42
+ width: '100%',
43
+ height: 24,
44
+ justifyContent: 'center',
45
+ alignItems: 'center',
46
+ backgroundColor: theme === 'dark' ? '#202020' : '#FFFFFF',
47
+ borderTopLeftRadius: 20,
48
+ borderTopRightRadius: 20,
49
+ marginBottom: -1,
50
+ },
51
+ dragIndicator: {
52
+ width: 40,
53
+ height: 4,
54
+ backgroundColor: theme === 'dark' ? '#FFFFFF40' : '#00000040',
55
+ borderRadius: 2,
56
+ },
26
57
  searchContainer: {
27
58
  marginBottom: 16,
28
- paddingTop: 16,
59
+ paddingTop: 8,
29
60
  flexDirection: 'row',
30
61
  },
31
62
  searchInput: {
@@ -59,6 +90,13 @@ export const createStyles = (theme: 'light' | 'dark') =>
59
90
  flex: 0.1,
60
91
  fontSize: 16,
61
92
  color: theme === 'dark' ? '#FFFFFF' : '#000000',
93
+ fontFamily:
94
+ Platform.OS === 'web'
95
+ ? typeof navigator !== 'undefined' &&
96
+ navigator?.userAgent?.includes('Win')
97
+ ? 'TwemojiMozilla'
98
+ : 'System'
99
+ : 'System',
62
100
  },
63
101
  countryInfo: {
64
102
  flex: 0.9,
@@ -87,7 +125,9 @@ export const createStyles = (theme: 'light' | 'dark') =>
87
125
  paddingHorizontal: 18,
88
126
  alignItems: 'center',
89
127
  justifyContent: 'center',
90
- backgroundColor: theme === 'dark' ? '#303030' : '#F5F5F5',
128
+ backgroundColor: theme === 'dark' ? '#303030' : '#F3F3F3',
129
+ borderColor: theme === 'dark' ? '#F3F3F3' : '#303030',
130
+ borderWidth: 1,
91
131
  borderRadius: 12,
92
132
  },
93
133
  closeButtonText: {
package/lib/index.d.ts CHANGED
@@ -9,6 +9,31 @@ import {
9
9
  ICountrySelectLanguages,
10
10
  } from './interface';
11
11
 
12
+ declare function getAllCountries(): ICountry[];
13
+
14
+ declare function getCountryByCca2(cca2: string): ICountry | undefined;
15
+
16
+ declare function getCountryByCca3(cca3: string): ICountry | undefined;
17
+
18
+ declare function getCountriesByCallingCode(
19
+ callingCode: string,
20
+ ): ICountry[] | undefined;
21
+
22
+ declare function getCountriesByName(
23
+ name: string,
24
+ language: ICountrySelectLanguages,
25
+ ): ICountry[] | undefined;
26
+
27
+ declare function getCountriesByRegion(region: string): ICountry[] | undefined;
28
+
29
+ declare function getCountriesBySubregion(
30
+ subregion: string,
31
+ ): ICountry[] | undefined;
32
+
33
+ declare function getContriesDependents(cca2: string): ICountry[] | undefined;
34
+
35
+ declare function getCountriesIndependents(): ICountry[] | undefined;
36
+
12
37
  declare const CountrySelect: React.FC<ICountrySelectProps>;
13
38
 
14
39
  export default CountrySelect;
@@ -20,4 +45,13 @@ export {
20
45
  ICountrySelectProps,
21
46
  ICountrySelectStyle,
22
47
  ICountrySelectLanguages,
48
+ getAllCountries,
49
+ getCountryByCca2,
50
+ getCountryByCca3,
51
+ getCountriesByCallingCode,
52
+ getCountriesByName,
53
+ getCountriesByRegion,
54
+ getCountriesBySubregion,
55
+ getContriesDependents,
56
+ getCountriesIndependents,
23
57
  };
package/lib/index.tsx CHANGED
@@ -4,15 +4,40 @@ import {
4
4
  ICountryCca2,
5
5
  ICountryItemProps,
6
6
  ICountrySelectProps,
7
+ ICountrySelectStyle,
7
8
  ICountrySelectLanguages,
8
9
  } from './interface';
10
+ import {
11
+ getAllCountries,
12
+ getCountryByCca2,
13
+ getCountryByCca3,
14
+ getCountriesByCallingCode,
15
+ getCountriesByName,
16
+ getCountriesByRegion,
17
+ getCountriesBySubregion,
18
+ getContriesDependents,
19
+ getCountriesIndependents,
20
+ } from './utils/countryHelpers';
9
21
 
10
22
  export default CountrySelect;
11
23
 
24
+ export {
25
+ getAllCountries,
26
+ getCountryByCca2,
27
+ getCountryByCca3,
28
+ getCountriesByCallingCode,
29
+ getCountriesByName,
30
+ getCountriesByRegion,
31
+ getCountriesBySubregion,
32
+ getContriesDependents,
33
+ getCountriesIndependents,
34
+ };
35
+
12
36
  export type {
13
37
  ICountry,
14
38
  ICountryCca2,
15
39
  ICountryItemProps,
16
40
  ICountrySelectProps,
41
+ ICountrySelectStyle,
17
42
  ICountrySelectLanguages,
18
43
  };
@@ -8,5 +8,6 @@ export interface ICountryItemProps {
8
8
  onClose: () => void;
9
9
  language: ICountrySelectLanguages;
10
10
  theme?: 'light' | 'dark';
11
+ modalType?: 'bottomSheet' | 'popup';
11
12
  countrySelectStyle?: ICountrySelectStyle;
12
13
  }
@@ -1,16 +1,16 @@
1
- import React from 'react';
1
+ import * as React from 'react';
2
2
  import {ModalProps} from 'react-native';
3
-
4
3
  import {ICountry} from './country';
5
4
  import {ICountryCca2} from './countryCca2';
6
- import {ISectionTitle} from './sectionTitle';
7
- import {ICountrySelectStyle} from './countrySelectStyles';
8
5
  import {ICountrySelectLanguages} from './countrySelectLanguages';
6
+ import {ICountrySelectStyle} from './countrySelectStyles';
7
+ import {ISectionTitle} from './sectionTitle';
9
8
 
10
9
  export interface ICountrySelectProps extends ModalProps {
11
10
  visible: boolean;
12
11
  onClose: () => void;
13
12
  onSelect: (country: ICountry) => void;
13
+ modalType?: 'bottomSheet' | 'popup';
14
14
  countrySelectStyle?: ICountrySelectStyle;
15
15
  theme?: 'light' | 'dark';
16
16
  isFullScreen?: boolean;
@@ -20,11 +20,16 @@ export interface ICountrySelectProps extends ModalProps {
20
20
  language?: ICountrySelectLanguages;
21
21
  showSearchInput?: boolean;
22
22
  searchPlaceholder?: string;
23
+ showCloseButton?: boolean;
24
+ minBottomsheetHeight?: number | string;
25
+ maxBottomsheetHeight?: number | string;
26
+ initialBottomsheetHeight?: number | string;
23
27
  disabledBackdropPress?: boolean;
24
28
  removedBackdrop?: boolean;
25
29
  onBackdropPress?: () => void;
26
30
  countryItemComponent?: (item: ICountry) => React.ReactElement;
27
31
  sectionTitleComponent?: (item: ISectionTitle) => React.ReactElement;
32
+ closeButtonComponent?: () => React.ReactElement;
28
33
  popularCountriesTitle?: string;
29
34
  allCountriesTitle?: string;
30
35
  showsVerticalScrollIndicator?: boolean;
@@ -23,6 +23,12 @@ interface IPopupStyle extends IBaseModalStyle {
23
23
  popupContent?: StyleProp<ViewStyle>;
24
24
  }
25
25
 
26
+ interface IBottomSheetStyle extends IBaseModalStyle {
27
+ sheetContainer?: StyleProp<ViewStyle>;
28
+ sheetContent?: StyleProp<ViewStyle>;
29
+ }
30
+
26
31
  export interface ICountrySelectStyle {
27
32
  popup?: IPopupStyle;
33
+ bottomSheet?: IBottomSheetStyle;
28
34
  }
@@ -0,0 +1,59 @@
1
+ import {ICountry, ICountryCca2} from '../interface';
2
+ import countriesData from '../constants/countries.json';
3
+
4
+ const countries: ICountry[] = countriesData as unknown as ICountry[];
5
+
6
+ export const getAllCountries = (): ICountry[] => {
7
+ return countries;
8
+ };
9
+
10
+ export const getCountriesByCallingCode = (callingCode: string): ICountry[] => {
11
+ return countries.filter(
12
+ (country: ICountry) => country.idd.root === callingCode,
13
+ );
14
+ };
15
+
16
+ export const getCountriesByName = (
17
+ name: string,
18
+ language: keyof ICountry['translations'] = 'eng',
19
+ ): ICountry[] => {
20
+ return countries.filter((country: ICountry) => {
21
+ const translation = country.translations[language];
22
+ if (translation) {
23
+ return (
24
+ translation.common.toLowerCase().includes(name.toLowerCase()) ||
25
+ translation.official.toLowerCase().includes(name.toLowerCase())
26
+ );
27
+ }
28
+ return (
29
+ country.name.common.toLowerCase().includes(name.toLowerCase()) ||
30
+ country.name.official.toLowerCase().includes(name.toLowerCase())
31
+ );
32
+ });
33
+ };
34
+
35
+ export const getCountryByCca2 = (cca2: ICountryCca2): ICountry | undefined => {
36
+ return countries.find((country: ICountry) => country.cca2 === cca2);
37
+ };
38
+
39
+ export const getCountryByCca3 = (cca3: string): ICountry | undefined => {
40
+ return countries.find((country: ICountry) => country.cca3 === cca3);
41
+ };
42
+
43
+ export const getCountriesByRegion = (region: string): ICountry[] => {
44
+ return countries.filter((country: ICountry) => country.region === region);
45
+ };
46
+
47
+ export const getCountriesBySubregion = (subregion: string): ICountry[] => {
48
+ return countries.filter(
49
+ (country: ICountry) => country.subregion === subregion,
50
+ );
51
+ };
52
+
53
+ export const getCountriesIndependents = (): ICountry[] => {
54
+ return countries.filter((country: ICountry) => country.independent);
55
+ };
56
+
57
+ export const getContriesDependents = (): ICountry[] => {
58
+ return countries.filter((country: ICountry) => !country.independent);
59
+ };
@@ -0,0 +1,35 @@
1
+ const parseHeight = (
2
+ value: number | string | undefined,
3
+ windowHeight: number,
4
+ ): number => {
5
+ if (value === undefined) {
6
+ return 0;
7
+ }
8
+
9
+ const MIN_ALLOWED_PERCENTAGE = 0.1; // 10%
10
+ const MAX_ALLOWED_PERCENTAGE = windowHeight; // 100%
11
+
12
+ if (typeof value === 'number') {
13
+ return Math.min(
14
+ Math.max(value, MIN_ALLOWED_PERCENTAGE * MAX_ALLOWED_PERCENTAGE),
15
+ MAX_ALLOWED_PERCENTAGE,
16
+ );
17
+ }
18
+
19
+ if (typeof value === 'string') {
20
+ const percentageMatch = value.match(/^(\d+(?:\.\d+)?)%$/);
21
+
22
+ if (percentageMatch) {
23
+ const percentage = parseFloat(percentageMatch[1]) / 100;
24
+ const height = percentage * MAX_ALLOWED_PERCENTAGE;
25
+ return Math.min(
26
+ Math.max(height, MIN_ALLOWED_PERCENTAGE * MAX_ALLOWED_PERCENTAGE),
27
+ MAX_ALLOWED_PERCENTAGE,
28
+ );
29
+ }
30
+ }
31
+
32
+ return 0;
33
+ };
34
+
35
+ export default parseHeight;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-country-select",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "๐ŸŒ A lightweight and customizable country picker for React Native with modern UI, flags, search engine, and i18n support. Includes TypeScript types, offline support and no dependencies.",
5
5
  "main": "lib/index.tsx",
6
6
  "types": "lib/index.d.ts",