Last active
November 14, 2024 07:17
-
-
Save sajjadjaved01/f4d8bd0678103fca48c7a2ab67cd94d9 to your computer and use it in GitHub Desktop.
Multiselect / Single-Select dropdown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { MaterialIcons } from "@expo/vector-icons"; | |
import React, { useCallback, useEffect, useMemo, useState } from "react"; | |
import { | |
FlatList, | |
Image, | |
Pressable, | |
StyleSheet, | |
Text, | |
TouchableOpacity, | |
View, | |
ViewStyle, | |
TextStyle, | |
LayoutAnimation, | |
Platform, | |
UIManager, | |
ListRenderItem, | |
} from "react-native"; | |
import DashedLine from "react-native-dashed-line"; | |
import { RFValue } from "react-native-responsive-fontsize"; | |
// Enable LayoutAnimation for Android | |
if (Platform.OS === 'android') { | |
UIManager.setLayoutAnimationEnabledExperimental?.(true); | |
} | |
export interface DropdownItem<T = any> { | |
id: string | number; | |
label: string; | |
value: T; | |
selected?: boolean; | |
disabled?: boolean; | |
icon?: string | { uri: string }; | |
meta?: Record<string, any>; | |
} | |
export interface DropdownStyles { | |
container?: ViewStyle; | |
button?: ViewStyle; | |
buttonText?: TextStyle; | |
dropdown?: ViewStyle; | |
item?: ViewStyle; | |
itemText?: TextStyle; | |
selectedItem?: ViewStyle; | |
selectedItemText?: TextStyle; | |
disabledItem?: ViewStyle; | |
disabledItemText?: TextStyle; | |
separator?: ViewStyle; | |
icon?: ViewStyle; | |
} | |
export interface DropdownProps<T = any> { | |
items: DropdownItem<T>[]; | |
value?: T | T[]; | |
placeholder?: string; | |
multiSelect?: boolean; | |
searchable?: boolean; | |
maxHeight?: number; | |
minHeight?: number; | |
loading?: boolean; | |
disabled?: boolean; | |
showIcon?: boolean; | |
iconPosition?: 'left' | 'right'; | |
customIcon?: React.ReactNode; | |
styles?: DropdownStyles; | |
onChange?: (value: T | T[], items: DropdownItem<T>[]) => void; | |
onOpen?: () => void; | |
onClose?: () => void; | |
renderItem?: ListRenderItem<DropdownItem<T>>; | |
keyExtractor?: (item: DropdownItem<T>) => string; | |
filterFunction?: (searchText: string, item: DropdownItem<T>) => boolean; | |
} | |
function Dropdown<T = any>({ | |
items, | |
value, | |
placeholder = "Select", | |
multiSelect = false, | |
searchable = false, | |
maxHeight = 300, | |
minHeight = 0, | |
loading = false, | |
disabled = false, | |
showIcon = true, | |
iconPosition = 'right', | |
customIcon, | |
styles: customStyles, | |
onChange, | |
onOpen, | |
onClose, | |
renderItem, | |
keyExtractor, | |
filterFunction, | |
}: DropdownProps<T>) { | |
const [isOpen, setIsOpen] = useState(false); | |
const [searchText, setSearchText] = useState(""); | |
const [selectedItems, setSelectedItems] = useState<DropdownItem<T>[]>([]); | |
// Memoized filtered items | |
const filteredItems = useMemo(() => { | |
if (!searchable || !searchText) return items; | |
return items.filter(item => | |
filterFunction | |
? filterFunction(searchText, item) | |
: item.label.toLowerCase().includes(searchText.toLowerCase()) | |
); | |
}, [items, searchText, filterFunction]); | |
// Initialize selected items based on value prop | |
useEffect(() => { | |
const initialSelected = items.filter(item => | |
Array.isArray(value) | |
? value.includes(item.value) | |
: item.value === value | |
); | |
setSelectedItems(initialSelected); | |
}, [value, items]); | |
const toggleDropdown = useCallback(() => { | |
if (disabled) return; | |
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); | |
setIsOpen(prev => !prev); | |
if (!isOpen) { | |
onOpen?.(); | |
} else { | |
onClose?.(); | |
} | |
}, [disabled, isOpen, onOpen, onClose]); | |
const handleSelect = useCallback((item: DropdownItem<T>) => { | |
if (item.disabled) return; | |
let newSelected: DropdownItem<T>[]; | |
if (multiSelect) { | |
newSelected = selectedItems.some(i => i.id === item.id) | |
? selectedItems.filter(i => i.id !== item.id) | |
: [...selectedItems, item]; | |
} else { | |
newSelected = [item]; | |
toggleDropdown(); | |
} | |
setSelectedItems(newSelected); | |
onChange?.( | |
multiSelect | |
? newSelected.map(i => i.value) | |
: newSelected[0]?.value, | |
newSelected | |
); | |
}, [multiSelect, selectedItems, onChange, toggleDropdown]); | |
const renderDefaultItem: ListRenderItem<DropdownItem<T>> = ({ item }) => { | |
const isSelected = selectedItems.some(i => i.id === item.id); | |
const isDisabled = item.disabled; | |
return ( | |
<TouchableOpacity | |
style={[ | |
defaultStyles.item, | |
customStyles?.item, | |
isSelected && {...defaultStyles.selectedItem, ...customStyles?.selectedItem}, | |
isDisabled && {...defaultStyles.disabledItem, ...customStyles?.disabledItem} | |
]} | |
onPress={() => handleSelect(item)} | |
disabled={isDisabled} | |
> | |
<View style={defaultStyles.itemContent}> | |
{item.icon && ( | |
<Image | |
source={typeof item.icon === 'string' ? { uri: item.icon } : item.icon} | |
style={[defaultStyles.itemIcon, customStyles?.icon]} | |
/> | |
)} | |
<Text | |
style={[ | |
defaultStyles.itemText, | |
customStyles?.itemText, | |
isSelected && {...defaultStyles.selectedItemText, ...customStyles?.selectedItemText}, | |
isDisabled && {...defaultStyles.disabledItemText, ...customStyles?.disabledItemText} | |
]} | |
numberOfLines={1} | |
> | |
{item.label} | |
</Text> | |
{multiSelect && ( | |
<MaterialIcons | |
name={isSelected ? "check-box" : "check-box-outline-blank"} | |
size={24} | |
color={isSelected ? "#2196F3" : "#757575"} | |
/> | |
)} | |
</View> | |
</TouchableOpacity> | |
); | |
}; | |
const displayValue = useMemo(() => { | |
if (selectedItems.length === 0) return placeholder; | |
return selectedItems | |
.map(item => item.label) | |
.join(", "); | |
}, [selectedItems, placeholder]); | |
return ( | |
<View style={[defaultStyles.container, customStyles?.container]}> | |
<Pressable | |
style={[ | |
defaultStyles.button, | |
customStyles?.button, | |
disabled && defaultStyles.buttonDisabled, | |
isOpen && defaultStyles.buttonOpen | |
]} | |
onPress={toggleDropdown} | |
disabled={disabled} | |
> | |
{iconPosition === 'left' && showIcon && ( | |
customIcon || ( | |
<MaterialIcons | |
name={isOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"} | |
size={24} | |
color="#757575" | |
/> | |
) | |
)} | |
<Text style={[defaultStyles.buttonText, customStyles?.buttonText]}> | |
{displayValue} | |
</Text> | |
{iconPosition === 'right' && showIcon && ( | |
customIcon || ( | |
<MaterialIcons | |
name={isOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"} | |
size={24} | |
color="#757575" | |
/> | |
) | |
)} | |
</Pressable> | |
{isOpen && ( | |
<View | |
style={[ | |
defaultStyles.dropdown, | |
{ maxHeight }, | |
customStyles?.dropdown | |
]} | |
> | |
{searchable && ( | |
<TextInput | |
style={defaultStyles.searchInput} | |
placeholder="Search..." | |
value={searchText} | |
onChangeText={setSearchText} | |
autoCorrect={false} | |
/> | |
)} | |
<FlatList | |
data={filteredItems} | |
renderItem={renderItem || renderDefaultItem} | |
keyExtractor={keyExtractor || (item => item.id.toString())} | |
ItemSeparatorComponent={() => ( | |
<DashedLine | |
style={[defaultStyles.separator, customStyles?.separator]} | |
dashColor="#E0E0E0" | |
/> | |
)} | |
style={{ flexGrow: 0 }} | |
keyboardShouldPersistTaps="handled" | |
ListEmptyComponent={() => ( | |
<Text style={defaultStyles.emptyText}> | |
{loading ? "Loading..." : "No items found"} | |
</Text> | |
)} | |
/> | |
</View> | |
)} | |
</View> | |
); | |
} | |
const defaultStyles = StyleSheet.create({ | |
container: { | |
position: 'relative', | |
zIndex: 1000, | |
}, | |
button: { | |
flexDirection: 'row', | |
alignItems: 'center', | |
justifyContent: 'space-between', | |
paddingHorizontal: 16, | |
paddingVertical: 12, | |
backgroundColor: '#fff', | |
borderWidth: 1, | |
borderColor: '#E0E0E0', | |
borderRadius: 8, | |
}, | |
buttonOpen: { | |
borderBottomLeftRadius: 0, | |
borderBottomRightRadius: 0, | |
borderBottomWidth: 0, | |
}, | |
buttonDisabled: { | |
backgroundColor: '#F5F5F5', | |
opacity: 0.7, | |
}, | |
buttonText: { | |
flex: 1, | |
fontSize: RFValue(16), | |
color: '#212121', | |
marginHorizontal: 8, | |
}, | |
dropdown: { | |
position: 'absolute', | |
top: '100%', | |
left: 0, | |
right: 0, | |
backgroundColor: '#fff', | |
borderWidth: 1, | |
borderTopWidth: 0, | |
borderColor: '#E0E0E0', | |
borderBottomLeftRadius: 8, | |
borderBottomRightRadius: 8, | |
elevation: 5, | |
shadowColor: '#000', | |
shadowOffset: { width: 0, height: 2 }, | |
shadowOpacity: 0.25, | |
shadowRadius: 3.84, | |
}, | |
searchInput: { | |
margin: 8, | |
paddingHorizontal: 12, | |
paddingVertical: 8, | |
borderWidth: 1, | |
borderColor: '#E0E0E0', | |
borderRadius: 4, | |
fontSize: RFValue(14), | |
}, | |
item: { | |
padding: 12, | |
}, | |
itemContent: { | |
flexDirection: 'row', | |
alignItems: 'center', | |
}, | |
itemIcon: { | |
width: 24, | |
height: 24, | |
marginRight: 8, | |
}, | |
itemText: { | |
flex: 1, | |
fontSize: RFValue(14), | |
color: '#212121', | |
}, | |
selectedItem: { | |
backgroundColor: '#E3F2FD', | |
}, | |
selectedItemText: { | |
color: '#2196F3', | |
fontWeight: '600', | |
}, | |
disabledItem: { | |
opacity: 0.5, | |
}, | |
disabledItemText: { | |
color: '#9E9E9E', | |
}, | |
separator: { | |
marginHorizontal: 8, | |
}, | |
emptyText: { | |
padding: 16, | |
textAlign: 'center', | |
color: '#757575', | |
}, | |
}); | |
export default Dropdown; |
fixed selectedValue issue.
added more style props
update to handle more types
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Updated Box styling.