react-native-auto-positioned-popup 1.0.2

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.
@@ -0,0 +1,529 @@
1
+ import React, {
2
+ ForwardedRef,
3
+ forwardRef,
4
+ ForwardRefExoticComponent,
5
+ memo,
6
+ MemoExoticComponent,
7
+ useCallback,
8
+ useEffect,
9
+ useImperativeHandle,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import {
15
+ Dimensions,
16
+ Keyboard,
17
+ Text,
18
+ TextInput as RNTextInput,
19
+ TouchableOpacity,
20
+ View,
21
+ ViewStyle,
22
+ } from 'react-native';
23
+ import {AdvancedFlatList} from 'react-native-advanced-flatlist';
24
+ import {TextInputSubmitEditingEventData} from 'react-native/Libraries/Components/TextInput/TextInput';
25
+ import {LayoutRectangle, NativeSyntheticEvent} from 'react-native/Libraries/Types/CoreEventTypes';
26
+ import {AutoPositionedPopupProps, Data, SelectedItem} from './AutoPositionedPopupProps';
27
+ import styles from './AutoPositionedPopup.style';
28
+ import {useRootView} from './RootViewContext';
29
+
30
+ // Lightweight emitter to decouple TextInput and list without re-rendering context
31
+ type QueryListener = (query: string) => void;
32
+ const queryChangeListeners: QueryListener[] = [];
33
+ const emitQueryChange = (query: string) => {
34
+ console.log('AutoPositionedPopup.tsx emitQueryChange query=', query, ' listeners=', queryChangeListeners.length);
35
+ queryChangeListeners.forEach((l) => l(query));
36
+ };
37
+ const subscribeQueryChange = (listener: QueryListener) => {
38
+ queryChangeListeners.push(listener);
39
+ return () => {
40
+ const idx = queryChangeListeners.indexOf(listener);
41
+ if (idx !== -1) queryChangeListeners.splice(idx, 1);
42
+ };
43
+ };
44
+
45
+ // Default theme colors interface
46
+ interface Theme {
47
+ colors: {
48
+ text: string;
49
+ placeholderText: string;
50
+ background: string;
51
+ border: string;
52
+ };
53
+ }
54
+
55
+ // Default light theme
56
+ const defaultTheme: Theme = {
57
+ colors: {
58
+ text: '#333333',
59
+ placeholderText: '#999999',
60
+ background: '#FFFFFF',
61
+ border: '#E0E0E0',
62
+ },
63
+ };
64
+
65
+ // List item component for rendering individual items
66
+ const ListItem: React.FC<{
67
+ item: SelectedItem;
68
+ index: number;
69
+ selectedItem?: SelectedItem;
70
+ onItemPress: (item: SelectedItem) => void;
71
+ theme: Theme;
72
+ rootViewsRef?: React.MutableRefObject<any[]>;
73
+ selectedItemBackgroundColor?: string;
74
+ }> = memo(({item, index, selectedItem, onItemPress, theme, rootViewsRef, selectedItemBackgroundColor = 'rgba(116, 116, 128, 0.08)'}) => {
75
+ const isSelected = item.id === selectedItem?.id;
76
+
77
+ return useMemo(() => (
78
+ <TouchableOpacity
79
+ key={item.id}
80
+ style={[
81
+ styles.commonModalRow,
82
+ {
83
+ backgroundColor: isSelected ? selectedItemBackgroundColor : 'transparent',
84
+ borderColor: theme.colors.border,
85
+ },
86
+ ]}
87
+ onPress={() => {
88
+ console.log('AutoPositionedPopup.tsx ListItem onPress item=', item);
89
+ if (rootViewsRef) {
90
+ console.log('AutoPositionedPopup.tsx ListItem onPress rootViews=', rootViewsRef.current);
91
+ }
92
+ onItemPress(item);
93
+ }}
94
+ >
95
+ <Text
96
+ style={[styles.ListItemCode, {color: theme.colors.text}]}
97
+ numberOfLines={1}
98
+ ellipsizeMode="tail"
99
+ >
100
+ {item.title}
101
+ </Text>
102
+ </TouchableOpacity>
103
+ ), [item, index, selectedItem, onItemPress, theme, rootViewsRef, isSelected, selectedItemBackgroundColor]);
104
+ });
105
+
106
+ // Popup list component with AdvancedFlatList
107
+ interface PopupListProps {
108
+ data: SelectedItem[];
109
+ selectedItem?: SelectedItem;
110
+ onItemPress: (item: SelectedItem) => void;
111
+ renderItem?: ({item, index}: { item: SelectedItem; index: number }) => React.ReactElement;
112
+ keyExtractor?: (item: SelectedItem) => string;
113
+ theme: Theme;
114
+ rootViewsRef?: React.MutableRefObject<any[]>;
115
+ fetchData?: (params: { pageIndex: number; pageSize: number; searchQuery?: string }) => Promise<Data | null>;
116
+ localSearch?: boolean;
117
+ pageSize?: number;
118
+ onDataUpdate?: (newData: SelectedItem[]) => void;
119
+ selectedItemBackgroundColor?: string;
120
+ }
121
+
122
+ const PopupList: React.FC<PopupListProps> = memo(({
123
+ data,
124
+ selectedItem,
125
+ onItemPress,
126
+ renderItem,
127
+ keyExtractor = (item: SelectedItem) => String(item.id),
128
+ theme,
129
+ rootViewsRef,
130
+ fetchData,
131
+ localSearch = false,
132
+ pageSize = 20,
133
+ onDataUpdate,
134
+ selectedItemBackgroundColor,
135
+ }) => {
136
+ const [internalData, setInternalData] = useState<SelectedItem[]>(data);
137
+ const searchQueryRef = useRef<string>('');
138
+
139
+ // Sync external data changes
140
+ useEffect(() => {
141
+ setInternalData(data);
142
+ }, [data]);
143
+
144
+ // Listen to search query changes
145
+ useEffect(() => {
146
+ const unsubscribe = subscribeQueryChange(async (newQuery: string) => {
147
+ console.log('PopupList subscribeQueryChange newQuery=', newQuery);
148
+ searchQueryRef.current = newQuery;
149
+
150
+ if (fetchData) {
151
+ try {
152
+ const result = await fetchData({
153
+ pageIndex: 0,
154
+ pageSize,
155
+ searchQuery: newQuery,
156
+ });
157
+
158
+ if (result?.items) {
159
+ setInternalData(result.items);
160
+ onDataUpdate?.(result.items);
161
+ }
162
+ } catch (error) {
163
+ console.error('PopupList fetchData error:', error);
164
+ }
165
+ } else if (localSearch) {
166
+ // Local filtering
167
+ const filtered = data.filter(item =>
168
+ item.title.toLowerCase().includes(newQuery.toLowerCase())
169
+ );
170
+ setInternalData(filtered);
171
+ onDataUpdate?.(filtered);
172
+ }
173
+ });
174
+
175
+ return unsubscribe;
176
+ }, [fetchData, localSearch, pageSize, data, onDataUpdate]);
177
+ const defaultRenderItem = useCallback(
178
+ ({item, index}: { item: SelectedItem; index: number }) => (
179
+ <ListItem
180
+ item={item}
181
+ index={index}
182
+ selectedItem={selectedItem}
183
+ onItemPress={onItemPress}
184
+ theme={theme}
185
+ rootViewsRef={rootViewsRef}
186
+ selectedItemBackgroundColor={selectedItemBackgroundColor}
187
+ />
188
+ ),
189
+ [selectedItem, onItemPress, theme, rootViewsRef, selectedItemBackgroundColor]
190
+ );
191
+
192
+ return (
193
+ <View style={[styles.autoPositionedPopupList, {backgroundColor: theme.colors.background}]}>
194
+ <AdvancedFlatList
195
+ data={internalData}
196
+ keyExtractor={keyExtractor}
197
+ renderItem={renderItem || defaultRenderItem}
198
+ keyboardShouldPersistTaps="always"
199
+ showsVerticalScrollIndicator={true}
200
+ nestedScrollEnabled={true}
201
+ />
202
+ </View>
203
+ );
204
+ });
205
+
206
+ // Main AutoPositionedPopup component
207
+ const AutoPositionedPopup: MemoExoticComponent<
208
+ ForwardRefExoticComponent<AutoPositionedPopupProps>
209
+ > = memo(
210
+ forwardRef<unknown, AutoPositionedPopupProps>(
211
+ (props: AutoPositionedPopupProps, parentRef: ForwardedRef<unknown>): React.JSX.Element => {
212
+ const {
213
+ tag,
214
+ style,
215
+ AutoPositionedPopupBtnStyle,
216
+ placeholder = 'Please Select',
217
+ textAlign = 'right',
218
+ onSubmitEditing,
219
+ TextInputProps = {},
220
+ inputStyle,
221
+ labelStyle,
222
+ popUpViewStyle = {left: '5%', width: '90%'},
223
+ fetchData,
224
+ renderItem,
225
+ onItemSelected,
226
+ localSearch = false,
227
+ pageSize = 20,
228
+ selectedItem,
229
+ useTextInput = false,
230
+ btwChildren,
231
+ CustomRow = ({children}) => <View>{children}</View>,
232
+ keyExtractor = (item: any) => item?.id,
233
+ AutoPositionedPopupBtnDisabled = false,
234
+ forceRemoveAllRootViewOnItemSelected = false,
235
+ centerDisplay = false,
236
+ selectedItemBackgroundColor = 'rgba(116, 116, 128, 0.08)',
237
+ } = props;
238
+
239
+ // Use RootView context
240
+ const {addRootView, removeRootView, rootViews, searchQuery: contextSearchQuery, setSearchQuery: setContextSearchQuery} = useRootView();
241
+ const rootViewsRef = useRef(rootViews);
242
+
243
+ useEffect(() => {
244
+ rootViewsRef.current = rootViews;
245
+ }, [rootViews]);
246
+
247
+ // State management
248
+ const [isVisible, setIsVisible] = useState(false);
249
+ const [data, setData] = useState<SelectedItem[]>([]);
250
+ const [loading, setLoading] = useState(false);
251
+ const [popupPosition, setPopupPosition] = useState<{
252
+ top: number;
253
+ left: number;
254
+ width: number;
255
+ }>({top: 0, left: 0, width: 0});
256
+ const popupId = useRef(`popup-${tag}-${Date.now()}`);
257
+
258
+ // Refs for performance optimization
259
+ const containerRef = useRef<View>(null);
260
+ const textInputRef = useRef<RNTextInput>(null);
261
+ const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
262
+ const searchQueryRef = useRef<string>(''); // Use ref instead of state to avoid re-renders
263
+
264
+ // Constants
265
+ const LIST_HEIGHT = 200;
266
+ const theme = defaultTheme;
267
+
268
+ // Fetch data function
269
+ const loadData = useCallback(async (query: string = '') => {
270
+ if (!fetchData) return;
271
+
272
+ setLoading(true);
273
+ try {
274
+ const result = await fetchData({
275
+ pageIndex: 0,
276
+ pageSize,
277
+ searchQuery: query,
278
+ });
279
+
280
+ if (result?.items) {
281
+ setData(result.items);
282
+ }
283
+ } catch (error) {
284
+ console.error('Error loading data:', error);
285
+ } finally {
286
+ setLoading(false);
287
+ }
288
+ }, [fetchData, pageSize]);
289
+
290
+ // Handle search query change with debounce and event emission
291
+ const handleSearchChange = useCallback((query: string) => {
292
+ // Store in ref to avoid re-renders
293
+ searchQueryRef.current = query;
294
+
295
+ // Update TextInput value directly if needed
296
+ if (textInputRef.current) {
297
+ // The TextInput's value will be controlled by its own state
298
+ }
299
+
300
+ // Clear previous debounce timer
301
+ if (debounceTimerRef.current) {
302
+ clearTimeout(debounceTimerRef.current);
303
+ }
304
+
305
+ // Use debounce for performance optimization
306
+ debounceTimerRef.current = setTimeout(() => {
307
+ // Emit query change event to decouple components and avoid context re-rendering
308
+ emitQueryChange(searchQueryRef.current);
309
+ }, 300); // Use 300ms debounce like the original
310
+ }, []);
311
+
312
+ // Calculate popup position
313
+ const calculatePosition = useCallback(() => {
314
+ if (!containerRef.current) return;
315
+
316
+ containerRef.current.measureInWindow((x, y, width, height) => {
317
+ const screenHeight = Dimensions.get('screen').height;
318
+ const screenWidth = Dimensions.get('screen').width;
319
+
320
+ let top = y + height;
321
+ let left = x;
322
+ let popupWidth = width;
323
+
324
+ // Check if popup should appear above the input
325
+ if (y + height + LIST_HEIGHT > screenHeight) {
326
+ top = y - LIST_HEIGHT;
327
+ }
328
+
329
+ // Adjust horizontal position if needed
330
+ if (popUpViewStyle?.left && popUpViewStyle?.width) {
331
+ const leftPercent = parseFloat(String(popUpViewStyle.left).replace('%', '')) / 100;
332
+ const widthPercent = parseFloat(String(popUpViewStyle.width).replace('%', '')) / 100;
333
+ left = screenWidth * leftPercent;
334
+ popupWidth = screenWidth * widthPercent;
335
+ }
336
+
337
+ setPopupPosition({top, left, width: popupWidth});
338
+ });
339
+ }, [popUpViewStyle]);
340
+
341
+ // Hide popup using RootView
342
+ const hidePopup = useCallback(() => {
343
+ setIsVisible(false);
344
+ // Reset search query
345
+ searchQueryRef.current = '';
346
+ if (textInputRef.current) {
347
+ textInputRef.current.blur();
348
+ textInputRef.current.clear?.(); // Clear the TextInput
349
+ }
350
+ removeRootView(popupId.current, forceRemoveAllRootViewOnItemSelected, rootViewsRef.current);
351
+ }, [removeRootView, forceRemoveAllRootViewOnItemSelected]);
352
+
353
+ // Handle data updates from PopupList
354
+ const handleDataUpdate = useCallback((newData: SelectedItem[]) => {
355
+ setData(newData);
356
+ }, []);
357
+
358
+ // Handle item selection
359
+ const handleItemPress = useCallback((item: SelectedItem) => {
360
+ onItemSelected?.(item);
361
+ hidePopup();
362
+ }, [onItemSelected, hidePopup]);
363
+
364
+ // Show popup using RootView
365
+ const showPopup = useCallback(() => {
366
+ calculatePosition();
367
+ setIsVisible(true);
368
+ loadData(searchQueryRef.current);
369
+
370
+ // Wait for position to be calculated
371
+ setTimeout(() => {
372
+ const popupComponent = (
373
+ <TouchableOpacity
374
+ style={{
375
+ flex: 1,
376
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
377
+ }}
378
+ activeOpacity={1}
379
+ onPress={hidePopup}
380
+ >
381
+ <View
382
+ style={{
383
+ position: 'absolute',
384
+ top: popupPosition.top,
385
+ left: popupPosition.left,
386
+ width: popupPosition.width,
387
+ height: LIST_HEIGHT,
388
+ backgroundColor: theme.colors.background,
389
+ borderRadius: 8,
390
+ shadowColor: '#000',
391
+ shadowOffset: {width: 0, height: 2},
392
+ shadowOpacity: 0.25,
393
+ shadowRadius: 3.84,
394
+ elevation: 5,
395
+ }}
396
+ >
397
+ {useTextInput && (
398
+ <RNTextInput
399
+ ref={textInputRef}
400
+ style={[
401
+ styles.inputStyle,
402
+ {
403
+ height: 40,
404
+ borderBottomWidth: 1,
405
+ borderBottomColor: theme.colors.border,
406
+ paddingHorizontal: 12,
407
+ color: theme.colors.text,
408
+ },
409
+ inputStyle,
410
+ ]}
411
+ placeholder={placeholder}
412
+ placeholderTextColor={theme.colors.placeholderText}
413
+ defaultValue={searchQueryRef.current}
414
+ onChangeText={handleSearchChange}
415
+ onSubmitEditing={(e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => {
416
+ onSubmitEditing?.(e);
417
+ Keyboard.dismiss();
418
+ }}
419
+ returnKeyType="done"
420
+ {...TextInputProps}
421
+ />
422
+ )}
423
+
424
+ <PopupList
425
+ data={data}
426
+ selectedItem={selectedItem}
427
+ onItemPress={handleItemPress}
428
+ renderItem={renderItem}
429
+ keyExtractor={keyExtractor}
430
+ theme={theme}
431
+ rootViewsRef={rootViewsRef}
432
+ fetchData={fetchData}
433
+ localSearch={localSearch}
434
+ pageSize={pageSize}
435
+ onDataUpdate={handleDataUpdate}
436
+ selectedItemBackgroundColor={selectedItemBackgroundColor}
437
+ />
438
+ </View>
439
+ </TouchableOpacity>
440
+ );
441
+
442
+ addRootView({
443
+ id: popupId.current,
444
+ style: {
445
+ position: 'absolute',
446
+ top: 0,
447
+ left: 0,
448
+ right: 0,
449
+ bottom: 0,
450
+ },
451
+ component: popupComponent,
452
+ useModal: true,
453
+ onModalClose: hidePopup,
454
+ centerDisplay: centerDisplay,
455
+ });
456
+ }, 100);
457
+ }, [calculatePosition, loadData, popupPosition, useTextInput, placeholder, theme, inputStyle, TextInputProps, data, selectedItem, renderItem, keyExtractor, centerDisplay, addRootView, hidePopup, handleSearchChange, handleItemPress, LIST_HEIGHT, selectedItemBackgroundColor]);
458
+
459
+ // Handle button press
460
+ const handleButtonPress = useCallback(() => {
461
+ if (AutoPositionedPopupBtnDisabled) return;
462
+
463
+ if (useTextInput) {
464
+ showPopup();
465
+ // Focus text input after a short delay
466
+ setTimeout(() => {
467
+ textInputRef.current?.focus();
468
+ }, 100);
469
+ } else {
470
+ showPopup();
471
+ }
472
+ }, [AutoPositionedPopupBtnDisabled, useTextInput, showPopup]);
473
+
474
+ // Imperative handle for parent component access
475
+ useImperativeHandle(
476
+ parentRef,
477
+ () => ({
478
+ clearSelectedItem: () => {
479
+ // Clear selection logic can be implemented here
480
+ console.log('Clearing selected item for:', tag);
481
+ },
482
+ showPopup,
483
+ hidePopup,
484
+ }),
485
+ [tag, showPopup, hidePopup]
486
+ );
487
+
488
+ // Cleanup
489
+ useEffect(() => {
490
+ return () => {
491
+ if (debounceTimerRef.current) {
492
+ clearTimeout(debounceTimerRef.current);
493
+ }
494
+ };
495
+ }, []);
496
+
497
+ // Render the component
498
+ return (
499
+ <CustomRow>
500
+ <View style={[styles.contain, style]} ref={containerRef}>
501
+ <TouchableOpacity
502
+ style={[styles.AutoPositionedPopupBtn, AutoPositionedPopupBtnStyle]}
503
+ disabled={AutoPositionedPopupBtnDisabled}
504
+ onPress={handleButtonPress}
505
+ >
506
+ {btwChildren ? (
507
+ btwChildren()
508
+ ) : (
509
+ <Text
510
+ style={[
511
+ styles.searchQueryTxt,
512
+ selectedItem && {color: theme.colors.text},
513
+ labelStyle,
514
+ ]}
515
+ numberOfLines={1}
516
+ ellipsizeMode="tail"
517
+ >
518
+ {selectedItem?.title || placeholder}
519
+ </Text>
520
+ )}
521
+ </TouchableOpacity>
522
+ </View>
523
+ </CustomRow>
524
+ );
525
+ }
526
+ )
527
+ );
528
+
529
+ export default AutoPositionedPopup;
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { StyleProp, TextInputProps, TextStyle, ViewStyle } from 'react-native';
3
+ import { TextInputSubmitEditingEventData } from 'react-native/Libraries/Components/TextInput/TextInput';
4
+ import { NativeSyntheticEvent } from 'react-native/Libraries/Types/CoreEventTypes';
5
+
6
+ export interface Data {
7
+ items: any[];
8
+ pageIndex: number;
9
+ needLoadMore: boolean;
10
+ }
11
+
12
+ export interface SelectedItem {
13
+ id: string;
14
+ title: string;
15
+ }
16
+
17
+ /**
18
+ * Props interface for AutoPositionedPopup component
19
+ */
20
+ export interface AutoPositionedPopupProps {
21
+ style?: ViewStyle;
22
+ labelStyle?: ViewStyle;
23
+ tag: string;
24
+ tagStyle?: ViewStyle;
25
+ fetchData?: ({
26
+ pageIndex,
27
+ pageSize,
28
+ searchQuery,
29
+ }: {
30
+ pageIndex: number;
31
+ pageSize: number;
32
+ searchQuery?: string;
33
+ }) => Promise<Data | null>;
34
+ renderItem?: ({ item, index }: { item: SelectedItem; index: number }) => React.ReactElement;
35
+ onItemSelected?: (item: SelectedItem) => void;
36
+ onSubmitEditing?: (e: NativeSyntheticEvent<TextInputSubmitEditingEventData>) => void;
37
+ localSearch?: boolean;
38
+ placeholder?: string;
39
+ textAlign?: 'left' | 'center' | 'right' | undefined;
40
+ pageSize?: number;
41
+ selectedItem?: SelectedItem | any;
42
+ CustomRow?: React.ComponentType<ViewStyle & { children?: React.ReactNode }>;
43
+ btwChildren?: () => React.ReactNode;
44
+ useTextInput?: boolean;
45
+ keyExtractor?: (item: SelectedItem) => string;
46
+ CustomPopView?: () => React.ComponentType<
47
+ ViewStyle & {
48
+ children?: React.ReactNode;
49
+ selectedItem?: SelectedItem | any;
50
+ }
51
+ >;
52
+ CustomPopViewStyle?: ViewStyle;
53
+ forceRemoveAllRootViewOnItemSelected?: boolean;
54
+ inputStyle?: StyleProp<TextStyle>;
55
+ TextInputProps?: TextInputProps;
56
+ popUpViewStyle?: ViewStyle;
57
+ AutoPositionedPopupBtnStyle?: ViewStyle;
58
+ AutoPositionedPopupBtnDisabled?: boolean;
59
+ centerDisplay?: boolean;
60
+ selectedItemBackgroundColor?: string;
61
+ }