@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
@@ -0,0 +1,805 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ElementRef,
8
+ type ReactNode,
9
+ } from 'react';
10
+ import {
11
+ Modal,
12
+ Platform,
13
+ Pressable,
14
+ ScrollView,
15
+ StyleSheet,
16
+ Text,
17
+ TextInput,
18
+ View,
19
+ type GestureResponderEvent,
20
+ } from 'react-native';
21
+ import { groupCountriesByLetter, type Country } from './countries';
22
+ import { Flag } from './Flag';
23
+ import { useOptionalSafeAreaInsets } from './safeArea';
24
+ import { usePhoneInputPalette, type PhoneInputPalette } from './theme';
25
+ import type {
26
+ CountryPickerRenderItemInfo,
27
+ CountryPickerStyles,
28
+ PhoneInputColorScheme,
29
+ } from './types';
30
+
31
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
32
+ const ROW_HEIGHT = 48;
33
+ const HEADER_HEIGHT = 28;
34
+ /** Width reserved on the right for the A-Z index, so list content and the
35
+ * search box stop at the same place instead of running under it. */
36
+ const SIDEBAR_GUTTER = 32;
37
+
38
+ export interface CountryPickerProps {
39
+ visible: boolean;
40
+ countries: Country[];
41
+ selected?: Country;
42
+ onSelect: (country: Country) => void;
43
+ onClose: () => void;
44
+ searchPlaceholder?: string;
45
+ disableSearch?: boolean;
46
+ renderCountryItem?: (info: CountryPickerRenderItemInfo) => ReactNode;
47
+ /**
48
+ * Safe-area padding for the sheet. Usually unnecessary: when
49
+ * `react-native-safe-area-context` is installed (an optional peer) the
50
+ * insets are detected automatically, and the defaults work without it
51
+ * either way. Any edge set here overrides the detected value for that edge
52
+ * only — `{ top: 0 }` opts out of the top inset and keeps the rest.
53
+ */
54
+ safeAreaInsets?: {
55
+ top?: number;
56
+ bottom?: number;
57
+ left?: number;
58
+ right?: number;
59
+ };
60
+ /** Resolved palette. Passed down by PhoneInput; when the picker is used on
61
+ * its own it resolves one from `colorScheme` instead. */
62
+ palette?: PhoneInputPalette;
63
+ colorScheme?: PhoneInputColorScheme;
64
+ presentationStyle?: 'pageSheet' | 'fullScreen' | 'formSheet';
65
+ groupAlphabetically?: boolean;
66
+ showAlphabetIndex?: boolean;
67
+ styles?: CountryPickerStyles;
68
+ }
69
+
70
+ export function CountryPicker({
71
+ visible,
72
+ countries,
73
+ selected,
74
+ onSelect,
75
+ onClose,
76
+ searchPlaceholder = 'Search country or code',
77
+ disableSearch,
78
+ renderCountryItem,
79
+ palette: providedPalette,
80
+ colorScheme,
81
+ safeAreaInsets,
82
+ presentationStyle,
83
+ groupAlphabetically = true,
84
+ showAlphabetIndex = true,
85
+ styles: styleOverrides,
86
+ }: CountryPickerProps) {
87
+ const fallbackPalette = usePhoneInputPalette(colorScheme);
88
+ const palette = providedPalette ?? fallbackPalette;
89
+
90
+ // An explicit prop is applied as given; auto-detected insets are adjusted,
91
+ // because the ambient value describes the *window*, not this sheet. Inside
92
+ // an iOS pageSheet/formSheet the system has already inset the top, so
93
+ // adding the window's top inset there would push the header down by the
94
+ // notch height for no reason.
95
+ const ambientInsets = useOptionalSafeAreaInsets();
96
+ const isIosSheet =
97
+ Platform.OS === 'ios' &&
98
+ (presentationStyle ?? 'pageSheet') !== 'fullScreen';
99
+
100
+ // Read edge by edge rather than depending on the prop object, which callers
101
+ // naturally write inline.
102
+ const insetTop = safeAreaInsets?.top;
103
+ const insetBottom = safeAreaInsets?.bottom;
104
+ const insetLeft = safeAreaInsets?.left;
105
+ const insetRight = safeAreaInsets?.right;
106
+
107
+ const insets = useMemo(() => {
108
+ const auto = ambientInsets
109
+ ? { ...ambientInsets, top: isIosSheet ? 0 : ambientInsets.top }
110
+ : null;
111
+ if (
112
+ insetTop === undefined &&
113
+ insetBottom === undefined &&
114
+ insetLeft === undefined &&
115
+ insetRight === undefined
116
+ ) {
117
+ return auto;
118
+ }
119
+ // Each edge given explicitly wins; the rest still fall back to the
120
+ // detected value, so `{ top: 0 }` overrides only the top.
121
+ return {
122
+ top: insetTop ?? auto?.top ?? 0,
123
+ bottom: insetBottom ?? auto?.bottom ?? 0,
124
+ left: insetLeft ?? auto?.left ?? 0,
125
+ right: insetRight ?? auto?.right ?? 0,
126
+ };
127
+ }, [insetTop, insetBottom, insetLeft, insetRight, ambientInsets, isIosSheet]);
128
+ const [query, setQuery] = useState('');
129
+ const scrollRef = useRef<ElementRef<typeof ScrollView>>(null);
130
+ const sidebarRef = useRef<ElementRef<typeof View>>(null);
131
+ const [activeLetter, setActiveLetter] = useState<string | null>(null);
132
+ const sidebarLayout = useRef({ y: 0, height: 1 });
133
+ const hideActiveLetterTimer = useRef<ReturnType<typeof setTimeout> | null>(
134
+ null
135
+ );
136
+
137
+ useEffect(
138
+ () => () => {
139
+ if (hideActiveLetterTimer.current) {
140
+ clearTimeout(hideActiveLetterTimer.current);
141
+ }
142
+ },
143
+ []
144
+ );
145
+
146
+ // Reset the search when the sheet closes, so reopening it starts on the
147
+ // full list rather than on whatever was typed last time.
148
+ useEffect(() => {
149
+ if (!visible) setQuery('');
150
+ }, [visible]);
151
+
152
+ const showActiveLetterBriefly = (letter: string) => {
153
+ setActiveLetter(letter);
154
+ if (hideActiveLetterTimer.current) {
155
+ clearTimeout(hideActiveLetterTimer.current);
156
+ }
157
+ hideActiveLetterTimer.current = setTimeout(() => {
158
+ setActiveLetter(null);
159
+ hideActiveLetterTimer.current = null;
160
+ }, 600);
161
+ };
162
+
163
+ // Every color the picker draws lives here rather than in the StyleSheet
164
+ // below, which now holds layout only — the two hardcoded light values that
165
+ // used to sit in `row` and `search` were exactly what made dark mode
166
+ // unreadable (bright separator lines, a near-white search box).
167
+ const colors = useMemo(
168
+ () => ({
169
+ container: { backgroundColor: palette.background },
170
+ headerSection: {
171
+ backgroundColor: palette.background,
172
+ borderBottomColor: palette.border,
173
+ },
174
+ searchContainer: {
175
+ backgroundColor: palette.surface,
176
+ borderColor: palette.border,
177
+ },
178
+ search: { color: palette.text },
179
+ searchIcon: { borderColor: palette.placeholder },
180
+ searchIconFill: { backgroundColor: palette.placeholder },
181
+ handle: { backgroundColor: palette.handle },
182
+ title: { color: palette.text },
183
+ closeButton: { backgroundColor: palette.surface },
184
+ closeButtonPressed: { backgroundColor: palette.surfacePressed },
185
+ closeButtonText: { color: palette.textMuted },
186
+ rowSeparator: { borderBottomColor: palette.border },
187
+ rowSelected: { backgroundColor: palette.surface },
188
+ name: { color: palette.text },
189
+ dialCode: { color: palette.textMuted },
190
+ empty: { color: palette.textMuted },
191
+ sectionHeader: { backgroundColor: palette.background },
192
+ sectionHeaderText: { color: palette.textMuted },
193
+ sidebarLetter: { color: palette.accent },
194
+ sidebarLetterDisabled: { color: palette.disabled },
195
+ sidebarLetterActive: {
196
+ color: palette.accentContrast,
197
+ backgroundColor: palette.accent,
198
+ },
199
+ bubble: { backgroundColor: palette.bubble },
200
+ bubbleText: { color: palette.bubbleText },
201
+ }),
202
+ [palette]
203
+ );
204
+
205
+ const filtered = useMemo(() => {
206
+ const q = query.trim().toLowerCase();
207
+ if (!q) return countries;
208
+ return countries.filter(
209
+ (c) =>
210
+ c.name.toLowerCase().includes(q) ||
211
+ c.dialCode.includes(q.replace(/^\+/, '')) ||
212
+ c.code.toLowerCase().includes(q)
213
+ );
214
+ }, [countries, query]);
215
+
216
+ const isSearching = !!query.trim();
217
+
218
+ // Countries are always grouped into A-Z chunks (unless searching) so the
219
+ // alphabet index has something to jump to even when section headers
220
+ // (groupAlphabetically) are turned off — the two are independent: headers
221
+ // control what's visible in the list, the index controls the sidebar.
222
+ // `filtered` is the `countries` array itself whenever the query is empty,
223
+ // so the unsearched sections and the sidebar's letters are the same grouping
224
+ // — computed once here instead of twice.
225
+ const allSections = useMemo(
226
+ () => groupCountriesByLetter(countries),
227
+ [countries]
228
+ );
229
+
230
+ const sections = useMemo(
231
+ () => (isSearching ? [{ letter: '', data: filtered }] : allSections),
232
+ [filtered, isSearching, allSections]
233
+ );
234
+
235
+ const showHeaders = groupAlphabetically && !isSearching;
236
+
237
+ // Whether the A-Z gutter is reserved is deliberately independent of the
238
+ // query: the sidebar itself hides while searching, but the space stays
239
+ // claimed so the search box and the list don't jump wider mid-keystroke.
240
+ const reservesSidebar = showAlphabetIndex && allSections.length > 3;
241
+ const showSidebar = reservesSidebar && !isSearching;
242
+
243
+ const availableLetters = useMemo(
244
+ () => new Set(sections.map((s) => s.letter)),
245
+ [sections]
246
+ );
247
+
248
+ // Offset of each section header, computed from fixed row/header heights
249
+ // (styles.row / styles.sectionHeader below) rather than measured via
250
+ // onLayout — this content isn't virtualized, so the analytical offset is
251
+ // exact and avoids relying on layout events firing through the Modal.
252
+ const sectionOffsets = useMemo(() => {
253
+ const offsets: Record<string, number> = {};
254
+ let offset = 0;
255
+ for (const section of sections) {
256
+ offsets[section.letter] = offset;
257
+ if (showHeaders && section.letter) offset += HEADER_HEIGHT;
258
+ offset += section.data.length * ROW_HEIGHT;
259
+ }
260
+ return offsets;
261
+ }, [sections, showHeaders]);
262
+
263
+ const jumpToLetter = (letter: string) => {
264
+ let target = letter;
265
+ if (!availableLetters.has(target)) {
266
+ const forward = ALPHABET.find(
267
+ (l) => l >= letter && availableLetters.has(l)
268
+ );
269
+ const fallback =
270
+ forward ?? [...ALPHABET].reverse().find((l) => availableLetters.has(l));
271
+ if (!fallback) return;
272
+ target = fallback;
273
+ }
274
+ showActiveLetterBriefly(target);
275
+ const offset = sectionOffsets[target];
276
+ if (offset === undefined) return;
277
+ scrollRef.current?.scrollTo({ y: offset, animated: false });
278
+ };
279
+
280
+ const jumpFromPageY = (pageY: number) => {
281
+ const { height } = sidebarLayout.current;
282
+ const y = pageY - sidebarLayout.current.y;
283
+ const idx = Math.min(
284
+ ALPHABET.length - 1,
285
+ Math.max(0, Math.floor((y / height) * ALPHABET.length))
286
+ );
287
+ const letter = ALPHABET[idx];
288
+ if (letter) jumpToLetter(letter);
289
+ };
290
+
291
+ const handleSidebarTouch = (evt: GestureResponderEvent) => {
292
+ jumpFromPageY(evt.nativeEvent.pageY);
293
+ };
294
+
295
+ // react-native-web doesn't synthesize touch events from mouse input, so
296
+ // onTouchStart/onTouchMove alone never fire for a mouse click/drag — the
297
+ // sidebar would be entirely inert on web without these. isMouseDown tracks
298
+ // whether the button is held, mirroring onTouchMove's "only while touching"
299
+ // behavior, since onMouseMove otherwise fires on hover too.
300
+ const isMouseDown = useRef(false);
301
+ const webMouseHandlers =
302
+ Platform.OS === 'web'
303
+ ? {
304
+ onMouseDown: (e: { nativeEvent: { pageY: number } }) => {
305
+ isMouseDown.current = true;
306
+ jumpFromPageY(e.nativeEvent.pageY);
307
+ },
308
+ onMouseMove: (e: { nativeEvent: { pageY: number } }) => {
309
+ if (isMouseDown.current) jumpFromPageY(e.nativeEvent.pageY);
310
+ },
311
+ onMouseUp: () => {
312
+ isMouseDown.current = false;
313
+ if (hideActiveLetterTimer.current) {
314
+ clearTimeout(hideActiveLetterTimer.current);
315
+ hideActiveLetterTimer.current = null;
316
+ }
317
+ setActiveLetter(null);
318
+ },
319
+ }
320
+ : {};
321
+
322
+ const renderRow = useCallback(
323
+ (item: Country) => {
324
+ const isSelected = item.code === selected?.code;
325
+ const onPress = () => onSelect(item);
326
+ if (renderCountryItem) {
327
+ return <>{renderCountryItem({ item, isSelected, onPress })}</>;
328
+ }
329
+ return (
330
+ <Pressable
331
+ onPress={onPress}
332
+ style={({ pressed }) => [
333
+ styles.row,
334
+ styleOverrides?.row,
335
+ isSelected && [
336
+ styles.rowSelected,
337
+ colors.rowSelected,
338
+ styleOverrides?.rowSelected,
339
+ ],
340
+ pressed && styles.rowPressed,
341
+ ]}
342
+ accessibilityRole="button"
343
+ accessibilityLabel={`${item.name}, +${item.dialCode}`}
344
+ >
345
+ {/* The separator lives on this inner view, which starts where the
346
+ flag does, so it is inset from the screen edge the way a native
347
+ list is rather than running the full width under the padding. */}
348
+ <View style={[styles.rowInner, colors.rowSeparator]}>
349
+ <Flag
350
+ country={item}
351
+ size={22}
352
+ style={[styles.flag, styleOverrides?.flag]}
353
+ />
354
+ <Text
355
+ style={[styles.name, colors.name, styleOverrides?.name]}
356
+ numberOfLines={1}
357
+ >
358
+ {item.name}
359
+ </Text>
360
+ <Text
361
+ style={[
362
+ styles.dialCode,
363
+ colors.dialCode,
364
+ styleOverrides?.dialCode,
365
+ ]}
366
+ >
367
+ +{item.dialCode}
368
+ </Text>
369
+ </View>
370
+ </Pressable>
371
+ );
372
+ },
373
+ [selected, onSelect, renderCountryItem, styleOverrides, colors]
374
+ );
375
+
376
+ // Memoized separately from the rest of the tree so sidebar drag-scrubbing
377
+ // (which updates `activeLetter` on every touch move) doesn't force React to
378
+ // re-render every one of the ~240 rows on each frame — only the sidebar and
379
+ // the letter bubble, which actually depend on `activeLetter`, do.
380
+ const listContent = useMemo(
381
+ () => (
382
+ <>
383
+ {filtered.length === 0 && (
384
+ <Text style={[styles.empty, colors.empty]}>No countries found</Text>
385
+ )}
386
+ {sections.map((section) => (
387
+ <View key={section.letter || 'all'}>
388
+ {showHeaders && section.letter ? (
389
+ <View
390
+ style={[
391
+ styles.sectionHeader,
392
+ colors.sectionHeader,
393
+ styleOverrides?.sectionHeader,
394
+ ]}
395
+ >
396
+ <Text
397
+ style={[
398
+ styles.sectionHeaderText,
399
+ colors.sectionHeaderText,
400
+ styleOverrides?.sectionHeaderText,
401
+ ]}
402
+ >
403
+ {section.letter}
404
+ </Text>
405
+ </View>
406
+ ) : null}
407
+ {section.data.map((item) => (
408
+ <View key={item.code}>{renderRow(item)}</View>
409
+ ))}
410
+ </View>
411
+ ))}
412
+ </>
413
+ ),
414
+ [filtered.length, sections, showHeaders, styleOverrides, renderRow, colors]
415
+ );
416
+
417
+ return (
418
+ <Modal
419
+ visible={visible}
420
+ animationType="slide"
421
+ onRequestClose={onClose}
422
+ presentationStyle={
423
+ Platform.OS === 'ios' ? (presentationStyle ?? 'pageSheet') : undefined
424
+ }
425
+ >
426
+ <View
427
+ style={[
428
+ styles.container,
429
+ colors.container,
430
+ insets
431
+ ? {
432
+ paddingTop: insets.top,
433
+ paddingBottom: insets.bottom,
434
+ paddingLeft: insets.left,
435
+ paddingRight: insets.right,
436
+ }
437
+ : null,
438
+ styleOverrides?.container,
439
+ ]}
440
+ >
441
+ <View
442
+ style={[
443
+ styles.headerSection,
444
+ colors.headerSection,
445
+ styleOverrides?.headerSection,
446
+ ]}
447
+ >
448
+ {Platform.OS === 'ios' &&
449
+ (presentationStyle ?? 'pageSheet') === 'pageSheet' && (
450
+ <View style={styles.dragHandleWrap}>
451
+ <View style={[styles.dragHandle, colors.handle]} />
452
+ </View>
453
+ )}
454
+
455
+ <View style={[styles.header, styleOverrides?.header]}>
456
+ <Text style={[styles.title, colors.title, styleOverrides?.title]}>
457
+ Select a country
458
+ </Text>
459
+ <Pressable
460
+ onPress={onClose}
461
+ hitSlop={8}
462
+ style={({ pressed }) => [
463
+ styles.closeButton,
464
+ colors.closeButton,
465
+ styleOverrides?.closeButton,
466
+ pressed && colors.closeButtonPressed,
467
+ ]}
468
+ accessibilityRole="button"
469
+ accessibilityLabel="Close"
470
+ >
471
+ <Text
472
+ style={[
473
+ styles.closeButtonText,
474
+ colors.closeButtonText,
475
+ styleOverrides?.closeButtonText,
476
+ ]}
477
+ >
478
+
479
+ </Text>
480
+ </Pressable>
481
+ </View>
482
+
483
+ {!disableSearch && (
484
+ <View
485
+ style={[
486
+ styles.searchContainer,
487
+ reservesSidebar ? styles.searchWithJump : null,
488
+ colors.searchContainer,
489
+ styleOverrides?.searchContainer,
490
+ ]}
491
+ >
492
+ {/* Magnifier drawn from two views — no icon font, no asset, and
493
+ it takes its color from the palette like everything else. */}
494
+ <View style={styles.searchIcon}>
495
+ <View style={[styles.searchIconLens, colors.searchIcon]} />
496
+ <View
497
+ style={[styles.searchIconHandle, colors.searchIconFill]}
498
+ />
499
+ </View>
500
+ <TextInput
501
+ value={query}
502
+ onChangeText={setQuery}
503
+ placeholder={searchPlaceholder}
504
+ placeholderTextColor={palette.placeholder}
505
+ style={[styles.search, colors.search, styleOverrides?.search]}
506
+ autoCorrect={false}
507
+ autoCapitalize="none"
508
+ clearButtonMode="while-editing"
509
+ />
510
+ </View>
511
+ )}
512
+ </View>
513
+
514
+ <View style={styles.body}>
515
+ <ScrollView
516
+ ref={scrollRef}
517
+ keyboardShouldPersistTaps="handled"
518
+ // Fallback for when no insets are available: UIKit adds the bottom
519
+ // inset itself, since the scroll view's frame overlaps the home
520
+ // indicator but not the notch. Turned off once we pad the
521
+ // container ourselves, so the two don't stack.
522
+ contentInsetAdjustmentBehavior={
523
+ insets?.bottom ? 'never' : 'automatic'
524
+ }
525
+ contentContainerStyle={
526
+ reservesSidebar ? styles.listContentWithJump : undefined
527
+ }
528
+ >
529
+ {listContent}
530
+ </ScrollView>
531
+
532
+ {showSidebar && (
533
+ <View
534
+ ref={sidebarRef}
535
+ style={styles.sidebar}
536
+ onLayout={() => {
537
+ // Measure the sidebar's absolute position in the same
538
+ // coordinate space as touch events' `pageY` (measure()'s
539
+ // pageY, not measureInWindow()'s window-relative y, which can
540
+ // differ by the status bar/safe-area inset) rather than
541
+ // deriving it from `pageY - locationY` on each touch — that
542
+ // calculation is unreliable once the touch target is a nested
543
+ // child (each letter below), which was causing the touch
544
+ // position -> letter mapping to drift, sometimes badly enough
545
+ // to land several letters off (e.g. tapping "M" landing on "P").
546
+ sidebarRef.current?.measure(
547
+ (_x, _y, _w, height, _px, pageY) => {
548
+ sidebarLayout.current = { y: pageY, height };
549
+ }
550
+ );
551
+ }}
552
+ onTouchStart={handleSidebarTouch}
553
+ onTouchMove={handleSidebarTouch}
554
+ onTouchEnd={() => {
555
+ if (hideActiveLetterTimer.current) {
556
+ clearTimeout(hideActiveLetterTimer.current);
557
+ hideActiveLetterTimer.current = null;
558
+ }
559
+ setActiveLetter(null);
560
+ }}
561
+ {...webMouseHandlers}
562
+ >
563
+ {ALPHABET.map((letter) => (
564
+ <View key={letter} style={styles.sidebarLetterTouchable}>
565
+ <Text
566
+ style={[
567
+ styles.sidebarLetter,
568
+ colors.sidebarLetter,
569
+ styleOverrides?.sidebarLetter,
570
+ !availableLetters.has(letter) &&
571
+ colors.sidebarLetterDisabled,
572
+ activeLetter === letter && [
573
+ styles.sidebarLetterActive,
574
+ colors.sidebarLetterActive,
575
+ styleOverrides?.sidebarLetterActive,
576
+ ],
577
+ ]}
578
+ >
579
+ {letter}
580
+ </Text>
581
+ </View>
582
+ ))}
583
+ </View>
584
+ )}
585
+
586
+ {activeLetter && (
587
+ <View style={[styles.bubble, colors.bubble]} pointerEvents="none">
588
+ <Text style={[styles.bubbleText, colors.bubbleText]}>
589
+ {activeLetter}
590
+ </Text>
591
+ </View>
592
+ )}
593
+ </View>
594
+ </View>
595
+ </Modal>
596
+ );
597
+ }
598
+
599
+ const styles = StyleSheet.create({
600
+ container: {
601
+ flex: 1,
602
+ },
603
+ dragHandleWrap: {
604
+ alignItems: 'center',
605
+ paddingTop: 8,
606
+ },
607
+ dragHandle: {
608
+ width: 36,
609
+ height: 5,
610
+ borderRadius: 3,
611
+ },
612
+ // Title + search read as one block, ruled off from the list, instead of the
613
+ // search floating loose above the first section band.
614
+ headerSection: {
615
+ borderBottomWidth: StyleSheet.hairlineWidth,
616
+ // Sits above the list so the shadow falls over the rows scrolling beneath
617
+ // it, which separates the two far better than a line on its own.
618
+ zIndex: 1,
619
+ ...Platform.select({
620
+ ios: {
621
+ shadowColor: '#000',
622
+ shadowOpacity: 0.06,
623
+ shadowRadius: 8,
624
+ shadowOffset: { width: 0, height: 3 },
625
+ },
626
+ android: { elevation: 3 },
627
+ default: {},
628
+ }),
629
+ },
630
+ header: {
631
+ flexDirection: 'row',
632
+ alignItems: 'center',
633
+ justifyContent: 'space-between',
634
+ paddingHorizontal: 20,
635
+ paddingTop: 16,
636
+ paddingBottom: 16,
637
+ },
638
+ title: {
639
+ fontSize: 20,
640
+ fontWeight: '700',
641
+ },
642
+ closeButton: {
643
+ width: 30,
644
+ height: 30,
645
+ borderRadius: 15,
646
+ alignItems: 'center',
647
+ justifyContent: 'center',
648
+ },
649
+ closeButtonText: {
650
+ fontSize: 14,
651
+ fontWeight: '700',
652
+ },
653
+ searchContainer: {
654
+ flexDirection: 'row',
655
+ alignItems: 'center',
656
+ gap: 8,
657
+ marginLeft: 20,
658
+ marginRight: 20,
659
+ marginBottom: 14,
660
+ paddingHorizontal: 12,
661
+ height: 48,
662
+ borderRadius: 12,
663
+ borderWidth: StyleSheet.hairlineWidth,
664
+ },
665
+ searchIcon: {
666
+ width: 15,
667
+ height: 15,
668
+ alignItems: 'center',
669
+ justifyContent: 'center',
670
+ },
671
+ searchIconLens: {
672
+ width: 11,
673
+ height: 11,
674
+ borderRadius: 6,
675
+ borderWidth: 1.5,
676
+ position: 'absolute',
677
+ top: 0,
678
+ left: 0,
679
+ },
680
+ searchIconHandle: {
681
+ position: 'absolute',
682
+ width: 1.5,
683
+ height: 6,
684
+ borderRadius: 1,
685
+ right: 1,
686
+ bottom: 0,
687
+ transform: [{ rotate: '-45deg' }],
688
+ },
689
+ search: {
690
+ flex: 1,
691
+ minWidth: 0,
692
+ paddingVertical: 0,
693
+ fontSize: 16,
694
+ ...(Platform.OS === 'web' ? ({ outlineStyle: 'none' } as object) : null),
695
+ ...(Platform.OS === 'android'
696
+ ? { textAlignVertical: 'center' as const, includeFontPadding: false }
697
+ : null),
698
+ },
699
+ searchWithJump: {
700
+ marginRight: 20 + SIDEBAR_GUTTER,
701
+ },
702
+ body: {
703
+ flex: 1,
704
+ flexDirection: 'row',
705
+ },
706
+ listContentWithJump: {
707
+ paddingRight: SIDEBAR_GUTTER,
708
+ },
709
+ row: {
710
+ height: ROW_HEIGHT,
711
+ flexDirection: 'row',
712
+ paddingLeft: 20,
713
+ },
714
+ rowInner: {
715
+ flex: 1,
716
+ flexDirection: 'row',
717
+ alignItems: 'center',
718
+ paddingRight: 20,
719
+ gap: 12,
720
+ borderBottomWidth: StyleSheet.hairlineWidth,
721
+ },
722
+ rowSelected: {},
723
+ rowPressed: {
724
+ opacity: 0.6,
725
+ },
726
+ flag: {
727
+ marginRight: 0,
728
+ },
729
+ name: {
730
+ flex: 1,
731
+ fontSize: 16,
732
+ },
733
+ dialCode: {
734
+ fontSize: 16,
735
+ },
736
+ empty: {
737
+ textAlign: 'center',
738
+ marginTop: 32,
739
+ },
740
+ sectionHeader: {
741
+ height: HEADER_HEIGHT,
742
+ justifyContent: 'flex-end',
743
+ paddingHorizontal: 20,
744
+ paddingBottom: 4,
745
+ },
746
+ sectionHeaderText: {
747
+ fontSize: 12,
748
+ fontWeight: '700',
749
+ letterSpacing: 0.6,
750
+ },
751
+ sidebar: {
752
+ // Inset from top/bottom (rather than padding the content box) so the
753
+ // measured height used by handleSidebarTouch still matches exactly what
754
+ // the flex:1 letters occupy — padding would leave the touch math using a
755
+ // taller box than the letters actually fill, reintroducing the same
756
+ // mismatch fixed above. The larger bottom inset keeps "Z" clear of the
757
+ // safe-area edge/home indicator instead of sitting flush against it.
758
+ position: 'absolute',
759
+ // On web, a visible OS scrollbar can render on top of a sidebar pinned
760
+ // to the very edge (browsers reserve/overlay scrollbar width there),
761
+ // eating its clicks — inset it clear of that on web specifically.
762
+ right: Platform.OS === 'web' ? 14 : 0,
763
+ top: 8,
764
+ bottom: 28,
765
+ width: 28,
766
+ alignItems: 'stretch',
767
+ ...(Platform.OS === 'web' ? ({ cursor: 'pointer' } as object) : null),
768
+ },
769
+ // Each letter gets an equal flex share of the sidebar's full height, so
770
+ // its rendered slice matches exactly what `handleSidebarTouch` computes
771
+ // (`floor((y / height) * 26)`) — with the old center-packed layout, the 26
772
+ // letters occupied a short block in the middle of a much taller container,
773
+ // so a touch position mapped from the FULL height landed on the wrong
774
+ // letter (sometimes several letters off) almost everywhere except near the
775
+ // exact center.
776
+ sidebarLetterTouchable: {
777
+ flex: 1,
778
+ alignItems: 'center',
779
+ justifyContent: 'center',
780
+ },
781
+ sidebarLetter: {
782
+ fontSize: 12,
783
+ fontWeight: '600',
784
+ lineHeight: Platform.OS === 'ios' ? 15 : 17,
785
+ textAlign: 'center',
786
+ },
787
+ sidebarLetterActive: {
788
+ borderRadius: 6,
789
+ overflow: 'hidden',
790
+ },
791
+ bubble: {
792
+ position: 'absolute',
793
+ right: 40,
794
+ top: '45%',
795
+ width: 64,
796
+ height: 64,
797
+ borderRadius: 32,
798
+ alignItems: 'center',
799
+ justifyContent: 'center',
800
+ },
801
+ bubbleText: {
802
+ fontSize: 28,
803
+ fontWeight: '700',
804
+ },
805
+ });