Files
AthenaFE/components/ui/SearchableDropdown.tsx
2026-05-24 21:34:25 +02:00

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 &quot;{searchQuery}&quot;
</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>
);
};