project setup
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, ScrollView, Image, TouchableOpacity } from "react-native";
|
||||
import { TextInput, Checkbox } from "react-native-paper";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export interface DropdownItem {
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
initials?: string;
|
||||
}
|
||||
|
||||
interface SearchableDropdownProps {
|
||||
items: DropdownItem[];
|
||||
selectedIds?: string | string[];
|
||||
onSelectionChange: (value: string | string[] | undefined) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
mode?: "single" | "multi";
|
||||
}
|
||||
|
||||
export const SearchableDropdown = ({
|
||||
items,
|
||||
selectedIds: selectedIdsProp,
|
||||
onSelectionChange,
|
||||
label = "Select Items",
|
||||
placeholder = "Search...",
|
||||
error,
|
||||
onSearchChange,
|
||||
mode = "single",
|
||||
}: SearchableDropdownProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [allSelectedItems, setAllSelectedItems] = useState<DropdownItem[]>([]);
|
||||
|
||||
const selectedIds = Array.isArray(selectedIdsProp)
|
||||
? selectedIdsProp
|
||||
: selectedIdsProp
|
||||
? [selectedIdsProp]
|
||||
: [];
|
||||
|
||||
const selectedItems = items.filter((item) => selectedIds.includes(item.id));
|
||||
|
||||
// Update allSelectedItems whenever selection changes
|
||||
// This preserves previously selected items even when they're not in the current search results
|
||||
useEffect(() => {
|
||||
// If no items selected, clear all
|
||||
if (selectedIds.length === 0) {
|
||||
setAllSelectedItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add newly selected items from current search results
|
||||
if (selectedItems.length > 0) {
|
||||
setAllSelectedItems((prev) => {
|
||||
const itemsMap = new Map(prev.map((item) => [item.id, item]));
|
||||
|
||||
selectedItems.forEach((item) => {
|
||||
itemsMap.set(item.id, item);
|
||||
});
|
||||
|
||||
// Filter to only keep items that are still selected
|
||||
return Array.from(itemsMap.values()).filter((item) =>
|
||||
selectedIds.includes(item.id)
|
||||
);
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIds.length, selectedIds.join(",")]);
|
||||
|
||||
const handleToggleItem = (itemId: string) => {
|
||||
if (mode === "single") {
|
||||
onSelectionChange(itemId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
const isSelected = selectedIds.includes(itemId);
|
||||
const newSelection = isSelected
|
||||
? selectedIds.filter((id) => id !== itemId)
|
||||
: [...selectedIds, itemId];
|
||||
onSelectionChange(newSelection);
|
||||
// Keep dropdown open in multi-select mode
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (itemId: string) => {
|
||||
if (mode === "single") {
|
||||
onSelectionChange(undefined);
|
||||
} else {
|
||||
onSelectionChange(selectedIds.filter((id) => id !== itemId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchChange = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (onSearchChange) {
|
||||
onSearchChange(query);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery("");
|
||||
if (onSearchChange) onSearchChange("");
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mb-4" style={{ zIndex: isOpen ? 1000 : 1 }}>
|
||||
<Text className="text-sm mb-1 font-medium">{label}</Text>
|
||||
|
||||
<View>
|
||||
{/* Selected Item Display (Single-select) */}
|
||||
{mode === "single" && selectedItems.length > 0 && !isOpen ? (
|
||||
<TouchableOpacity
|
||||
className="border border-gray-300 rounded p-3 bg-white flex-row items-center justify-between mb-2"
|
||||
onPress={() => setIsOpen(true)}
|
||||
>
|
||||
<View className="flex-row items-center flex-1">
|
||||
<View className="w-8 h-8 rounded-full overflow-hidden bg-primaryPurpleDark mr-2">
|
||||
{selectedItems[0].imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: selectedItems[0].imageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full bg-blue-500 items-center justify-center">
|
||||
<Text className="text-white text-xs font-bold">
|
||||
{selectedItems[0].initials || "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium" numberOfLines={1}>
|
||||
{selectedItems[0].label}
|
||||
</Text>
|
||||
{selectedItems[0].subtitle && (
|
||||
<Text className="text-xs text-gray-500" numberOfLines={1}>
|
||||
{selectedItems[0].subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
handleRemoveItem(selectedItems[0].id);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
className="ml-2 p-1"
|
||||
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{/* Search Input */}
|
||||
{((mode === "single" && selectedItems.length === 0) ||
|
||||
mode === "multi" ||
|
||||
isOpen) && (
|
||||
<TextInput
|
||||
className={`border !mr-0 ${
|
||||
error ? "border-highRisk" : "border-gray-300"
|
||||
} rounded p-2 text-base bg-white`}
|
||||
value={searchQuery}
|
||||
onChangeText={handleSearchChange}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#d9d9d9"
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
|
||||
right={
|
||||
searchQuery ? (
|
||||
<TextInput.Icon icon="close" onPress={handleClearSearch} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dropdown List - Only show when search query has 2+ characters */}
|
||||
{isOpen && searchQuery.length >= 2 && (
|
||||
<View
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 48,
|
||||
left: 0,
|
||||
right: 0,
|
||||
marginTop: 4,
|
||||
backgroundColor: "white",
|
||||
maxHeight: 240,
|
||||
borderWidth: 1,
|
||||
borderColor: "#d1d5db",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
zIndex: 9999,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 10,
|
||||
}}
|
||||
>
|
||||
<ScrollView nestedScrollEnabled keyboardShouldPersistTaps="handled">
|
||||
{items.length === 0 ? (
|
||||
<View className="p-4">
|
||||
<Text className="text-gray-500 text-center">
|
||||
No items found for "{searchQuery}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
className="flex-row items-center p-3 border-b border-gray-100"
|
||||
onPress={() => handleToggleItem(item.id)}
|
||||
>
|
||||
{mode === "multi" && (
|
||||
<Checkbox
|
||||
status={
|
||||
selectedIds.includes(item.id)
|
||||
? "checked"
|
||||
: "unchecked"
|
||||
}
|
||||
onPress={() => handleToggleItem(item.id)}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className={`w-8 h-8 rounded-full overflow-hidden bg-primaryPurpleDark ${
|
||||
mode === "multi" ? "ml-2 mr-2" : "mr-2"
|
||||
}`}
|
||||
>
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full bg-blue-500 items-center justify-center">
|
||||
<Text className="text-white text-xs font-bold">
|
||||
{item.initials || "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium" numberOfLines={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.subtitle && (
|
||||
<Text
|
||||
className="text-xs text-gray-500"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
{item.description && (
|
||||
<Text
|
||||
className="text-xs text-gray-400"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Selected Items Bubbles (Multi-select) - Show below dropdown */}
|
||||
{mode === "multi" && allSelectedItems.length > 0 && (
|
||||
<View className="mt-2 flex-row flex-wrap gap-2" style={{ zIndex: 1 }}>
|
||||
{allSelectedItems.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className="flex-row items-center bg-gray-100 rounded-full px-3 py-1"
|
||||
>
|
||||
<View className="w-6 h-6 rounded-full overflow-hidden bg-primaryPurpleDark mr-2">
|
||||
{item.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: item.imageUrl }}
|
||||
className="w-full h-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="w-full h-full bg-blue-500 items-center justify-center">
|
||||
<Text className="text-white text-xs font-bold">
|
||||
{item.initials || "?"}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-sm mr-2" numberOfLines={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleRemoveItem(item.id)}
|
||||
className="p-1"
|
||||
>
|
||||
<Ionicons name="close-circle" size={16} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{error && <Text className="text-highRisk text-xs mt-1">{error}</Text>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user