319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
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>
|
|
);
|
|
};
|