UNPKG

react-native-google-places-textinput

Version:

A customizable React Native TextInput component for Google Places Autocomplete using the Places API (New)

513 lines (501 loc) 15.6 kB
"use strict"; import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'; import { ActivityIndicator, FlatList, I18nManager, Keyboard, Platform, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native'; // Import the API functions import { fetchPlaceDetails as fetchPlaceDetailsApi, fetchPredictions as fetchPredictionsApi, generateUUID, isRTLText } from "./services/googlePlacesApi.js"; // Type definitions import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; const GooglePlacesTextInput = /*#__PURE__*/forwardRef(({ apiKey, value, placeHolderText, proxyUrl, languageCode, includedRegionCodes, types = [], biasPrefixText, minCharsToFetch = 1, onPlaceSelect, onTextChange, debounceDelay = 200, showLoadingIndicator = true, showClearButton = true, forceRTL = undefined, style = {}, clearElement, hideOnKeyboardDismiss = false, scrollEnabled = true, nestedScrollEnabled = true, fetchDetails = false, detailsProxyUrl = null, detailsFields = [], onError, enableDebug = false, onFocus, onBlur, accessibilityLabels = {} }, ref) => { const [predictions, setPredictions] = useState([]); const [loading, setLoading] = useState(false); const [inputText, setInputText] = useState(value || ''); const [showSuggestions, setShowSuggestions] = useState(false); const [sessionToken, setSessionToken] = useState(null); const [detailsLoading, setDetailsLoading] = useState(false); const debounceTimeout = useRef(null); const inputRef = useRef(null); const suggestionPressing = useRef(false); const skipNextFocusFetch = useRef(false); const generateSessionToken = () => { return generateUUID(); }; // Initialize session token on mount useEffect(() => { setSessionToken(generateSessionToken()); return () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } }; }, []); useEffect(() => { setInputText(value ?? ''); }, [value]); // Add keyboard listener useEffect(() => { if (hideOnKeyboardDismiss) { const keyboardDidHideSubscription = Keyboard.addListener('keyboardDidHide', () => setShowSuggestions(false)); return () => { keyboardDidHideSubscription.remove(); }; } return () => {}; }, [hideOnKeyboardDismiss]); // Expose methods to parent through ref useImperativeHandle(ref, () => ({ clear: () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } skipNextFocusFetch.current = true; setInputText(''); setPredictions([]); setShowSuggestions(false); setSessionToken(generateSessionToken()); }, blur: () => { inputRef.current?.blur(); }, focus: () => { inputRef.current?.focus(); }, getSessionToken: () => sessionToken })); // RTL detection logic const isRTL = forceRTL !== undefined ? forceRTL : isRTLText(placeHolderText ?? ''); // Add missing CORS warning effect useEffect(() => { if (Platform.OS === 'web' && fetchDetails && !detailsProxyUrl) { console.warn('Google Places Details API does not support CORS. ' + 'To fetch place details on web, provide a detailsProxyUrl prop that points to a CORS-enabled proxy.'); } }, [fetchDetails, detailsProxyUrl]); // Debug logger utility const debugLog = (category, message, data) => { if (enableDebug) { const timestamp = new Date().toISOString(); console.log(`[GooglePlacesTextInput:${category}] ${timestamp} - ${message}`); if (data) { console.log(`[GooglePlacesTextInput:${category}] Data:`, data); } } }; const fetchPredictions = async text => { debugLog('PREDICTIONS', `Starting fetch for text: "${text}"`); debugLog('PREDICTIONS', 'Request params', { text, apiKey: apiKey ? '[PROVIDED]' : '[MISSING]', // ✅ Security fix proxyUrl, sessionToken, languageCode, includedRegionCodes, types, minCharsToFetch }); if (!text || text.length < minCharsToFetch) { debugLog('PREDICTIONS', `Text too short (${text.length} < ${minCharsToFetch})`); setPredictions([]); return; } setLoading(true); const { error, predictions: fetchedPredictions } = await fetchPredictionsApi({ text, apiKey, proxyUrl, sessionToken, languageCode, includedRegionCodes, types, biasPrefixText }); if (error) { debugLog('PREDICTIONS', 'API Error occurred', { errorType: error.constructor.name, errorMessage: error.message, errorStack: error.stack }); onError?.(error); setPredictions([]); } else { debugLog('PREDICTIONS', `Success: ${fetchedPredictions.length} predictions received`); debugLog('PREDICTIONS', 'Predictions data', fetchedPredictions); setPredictions(fetchedPredictions); setShowSuggestions(fetchedPredictions.length > 0); } setLoading(false); }; const fetchPlaceDetails = async placeId => { debugLog('DETAILS', `Starting details fetch for placeId: ${placeId}`); debugLog('DETAILS', 'Request params', { placeId, apiKey: apiKey ? '[PROVIDED]' : '[MISSING]', // ✅ Security fix detailsProxyUrl, sessionToken, languageCode, detailsFields, fetchDetails, platform: Platform.OS }); if (!fetchDetails || !placeId) { debugLog('DETAILS', 'Skipping details fetch', { fetchDetails, placeId }); return null; } // Web CORS warning if (Platform.OS === 'web' && !detailsProxyUrl) { debugLog('DETAILS', 'WARNING: Web platform detected without detailsProxyUrl - CORS issues likely'); } setDetailsLoading(true); const { error, details } = await fetchPlaceDetailsApi({ placeId, apiKey, detailsProxyUrl, sessionToken, languageCode, detailsFields }); setDetailsLoading(false); if (error) { debugLog('DETAILS', 'API Error occurred', { errorType: error.constructor.name, errorMessage: error.message, errorStack: error.stack }); onError?.(error); return null; } debugLog('DETAILS', 'Success: Details received', details); return details; }; const handleTextChange = text => { setInputText(text); onTextChange?.(text); if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } debounceTimeout.current = setTimeout(() => { fetchPredictions(text); }, debounceDelay); }; const handleSuggestionPress = async suggestion => { const place = suggestion.placePrediction; debugLog('SELECTION', `User selected place: ${place.structuredFormat.mainText.text}`); debugLog('SELECTION', 'Selected place data', place); setInputText(place.structuredFormat.mainText.text); setShowSuggestions(false); Keyboard.dismiss(); if (fetchDetails) { debugLog('SELECTION', 'Fetching place details...'); setLoading(true); const details = await fetchPlaceDetails(place.placeId); const enrichedPlace = details ? { ...place, details } : place; debugLog('SELECTION', 'Final place object being sent to onPlaceSelect', { hasDetails: !!details, placeKeys: Object.keys(enrichedPlace), detailsKeys: details ? Object.keys(details) : null }); onPlaceSelect?.(enrichedPlace, sessionToken); setLoading(false); } else { debugLog('SELECTION', 'Sending place without details (fetchDetails=false)'); onPlaceSelect?.(place, sessionToken); } setSessionToken(generateSessionToken()); }; const handleFocus = event => { onFocus?.(event); if (skipNextFocusFetch.current) { skipNextFocusFetch.current = false; return; } if (inputText.length >= minCharsToFetch) { fetchPredictions(inputText); setShowSuggestions(true); } }; const handleBlur = event => { onBlur?.(event); setTimeout(() => { if (suggestionPressing.current) { suggestionPressing.current = false; } else { setShowSuggestions(false); } }, 10); }; const renderSuggestion = ({ item }) => { const { mainText, secondaryText } = item.placePrediction.structuredFormat; // Safely extract backgroundColor from style const suggestionsContainerStyle = StyleSheet.flatten(style.suggestionsContainer); const backgroundColor = suggestionsContainerStyle?.backgroundColor || '#efeff1'; const defaultAccessibilityLabel = `${mainText.text}${secondaryText ? `, ${secondaryText.text}` : ''}`; const accessibilityLabel = accessibilityLabels.suggestionItem?.(item.placePrediction) || defaultAccessibilityLabel; return /*#__PURE__*/_jsxs(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel, accessibilityHint: "Double tap to select this place", style: [styles.suggestionItem, { backgroundColor }, style.suggestionItem], onPress: () => { suggestionPressing.current = false; handleSuggestionPress(item); } // Fix for web: onBlur fires before onPress, hiding suggestions too early. , ...(Platform.OS === 'web' && { onMouseDown: () => { suggestionPressing.current = true; } }), children: [/*#__PURE__*/_jsx(Text, { style: [styles.mainText, style.suggestionText?.main, getTextAlign()], children: mainText.text }), secondaryText && /*#__PURE__*/_jsx(Text, { style: [styles.secondaryText, style.suggestionText?.secondary, getTextAlign()], children: secondaryText.text })] }); }; const getPadding = () => { const physicalRTL = I18nManager.isRTL; const clearButtonPadding = showClearButton ? 75 : 45; if (isRTL !== physicalRTL) { return { paddingStart: clearButtonPadding, paddingEnd: 15 }; } return { paddingStart: 15, paddingEnd: clearButtonPadding }; }; const getTextAlign = () => { const isDeviceRTL = I18nManager.isRTL; if (isDeviceRTL) { return { textAlign: isRTL ? 'left' : 'right' }; } else { return { textAlign: isRTL ? 'right' : 'left' }; } }; const getIconPosition = paddingValue => { const physicalRTL = I18nManager.isRTL; if (isRTL !== physicalRTL) { return { start: paddingValue }; } return { end: paddingValue }; }; // Debug initialization useEffect(() => { if (enableDebug) { debugLog('INIT', 'Component initialized with props', { apiKey: apiKey ? '[PROVIDED]' : '[MISSING]', // ✅ Security fix fetchDetails, detailsProxyUrl, detailsFields, platform: Platform.OS, minCharsToFetch, debounceDelay }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return /*#__PURE__*/_jsxs(View, { style: [styles.container, style.container], children: [/*#__PURE__*/_jsxs(View, { children: [/*#__PURE__*/_jsx(TextInput, { ref: inputRef, style: [styles.input, style.input, getPadding(), getTextAlign()], placeholder: placeHolderText, placeholderTextColor: style.placeholder?.color || '#666666', value: inputText, onChangeText: handleTextChange, onFocus: handleFocus, onBlur: handleBlur, clearButtonMode: "never" // Disable iOS native clear button , accessibilityRole: "search", accessibilityLabel: accessibilityLabels.input || placeHolderText }), showClearButton && inputText !== '' && /*#__PURE__*/_jsx(TouchableOpacity, { style: [styles.clearButton, getIconPosition(12)], onPress: () => { if (debounceTimeout.current) { clearTimeout(debounceTimeout.current); } skipNextFocusFetch.current = true; setInputText(''); setPredictions([]); setShowSuggestions(false); onTextChange?.(''); setSessionToken(generateSessionToken()); inputRef.current?.focus(); }, accessibilityRole: "button", accessibilityLabel: accessibilityLabels.clearButton || 'Clear input text', children: clearElement || /*#__PURE__*/_jsx(View, { style: styles.clearTextWrapper, children: /*#__PURE__*/_jsx(Text, { style: [Platform.select({ ios: styles.iOSclearText, android: styles.androidClearText }), style.clearButtonText], accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: '×' }) }) }), (loading || detailsLoading) && showLoadingIndicator && /*#__PURE__*/_jsx(ActivityIndicator, { style: [styles.loadingIndicator, getIconPosition(45)], size: 'small', color: style.loadingIndicator?.color || '#000000', accessibilityLiveRegion: "polite", accessibilityLabel: accessibilityLabels.loadingIndicator || 'Loading suggestions' })] }), showSuggestions && predictions.length > 0 && /*#__PURE__*/_jsx(View, { style: [styles.suggestionsContainer, style.suggestionsContainer], children: /*#__PURE__*/_jsx(FlatList, { data: predictions, renderItem: renderSuggestion, keyExtractor: item => item.placePrediction.placeId, keyboardShouldPersistTaps: "always", scrollEnabled: scrollEnabled, nestedScrollEnabled: nestedScrollEnabled, bounces: false, style: style.suggestionsList, accessibilityRole: "list", accessibilityLabel: `${predictions.length} place suggestion resuts` }) })] }); }); const styles = StyleSheet.create({ container: {}, input: { height: 50, borderRadius: 6, borderWidth: 1, paddingHorizontal: 10, backgroundColor: 'white', fontSize: 16 }, suggestionsContainer: { backgroundColor: '#efeff1', // default background borderRadius: 6, marginTop: 3, overflow: 'hidden', maxHeight: 200 }, suggestionItem: { padding: 10, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#c8c7cc' }, mainText: { fontSize: 16, textAlign: 'left', color: '#000000' }, secondaryText: { fontSize: 14, color: '#666', marginTop: 2, textAlign: 'left' }, clearButton: { position: 'absolute', top: '50%', transform: [{ translateY: -13 }], padding: 0 }, loadingIndicator: { position: 'absolute', top: '50%', transform: [{ translateY: -10 }] }, clearTextWrapper: { backgroundColor: '#999', borderRadius: 12, width: 24, height: 24, alignItems: 'center', justifyContent: 'center' }, //this is never going to be consistent between different phone fonts and sizes iOSclearText: { fontSize: 22, fontWeight: '400', color: 'white', lineHeight: 24, includeFontPadding: false }, androidClearText: { fontSize: 24, fontWeight: '400', color: 'white', lineHeight: 25.5, includeFontPadding: false } }); export default GooglePlacesTextInput; //# sourceMappingURL=GooglePlacesTextInput.js.map