Files
AthenaFE/components/ui/SearchDropdown.tsx
T
2025-11-02 10:08:56 +01:00

245 lines
7.4 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback } from "react";
import {
View,
Text,
TouchableOpacity,
FlatList,
Platform,
useWindowDimensions,
ScrollView,
} from "react-native";
import { Portal } from "react-native-paper";
import { Ionicons } from "@expo/vector-icons";
import { useLazyQuickSearchQuery, SearchResult } from "@/store/api/searchApi";
import { useAppSelector } from "@/store/hooks";
import { selectSidebarCollapsed } from "@/store/screen/screenSizeSlice";
interface SearchDropdownProps {
searchQuery: string;
onResultSelect: (result: SearchResult) => void;
onViewAllResults: (query: string) => void;
}
const getIconName = (type: SearchResult["type"]) => {
switch (type) {
case "user":
return "person-outline";
case "admin":
return "shield-outline";
case "todo":
return "list-outline";
default:
return "search-outline";
}
};
const getResultTypeLabel = (type: SearchResult["type"]) => {
switch (type) {
case "user":
return "User";
case "admin":
return "Admin";
case "todo":
return "Todo";
default:
return "Result";
}
};
export const SearchDropdown = ({
searchQuery,
onResultSelect,
onViewAllResults,
}: SearchDropdownProps) => {
const [isVisible, setIsVisible] = useState(false);
const containerId = "global-search-dropdown";
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const sidebarCollapsed = useAppSelector(selectSidebarCollapsed);
const [triggerSearch, { data, isLoading, error }] = useLazyQuickSearchQuery();
// Fetch results when query changes
useEffect(() => {
if (searchQuery && searchQuery.trim().length >= 2) {
triggerSearch({ q: searchQuery })
.unwrap()
.then(() => setIsVisible(true))
.catch(() => setIsVisible(false));
} else {
setIsVisible(false);
}
}, [searchQuery, triggerSearch]);
// Hide if query cleared
useEffect(() => {
if (!searchQuery.trim()) setIsVisible(false);
}, [searchQuery]);
// Close dropdown on outside click (web only)
useEffect(() => {
if (!isVisible || Platform.OS !== "web") return;
const handler = (e: MouseEvent | TouchEvent) => {
const el = document.getElementById(containerId);
if (!el) return;
if (e.target instanceof Node && !el.contains(e.target)) {
setIsVisible(false);
}
};
document.addEventListener("mousedown", handler);
document.addEventListener("touchstart", handler, { passive: true });
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("touchstart", handler);
};
}, [isVisible]);
// Group results memoized
const groupedResults = useMemo(() => {
if (!data?.results) return [];
const groups: { type: string; items: SearchResult[] }[] = [];
const map: Record<string, SearchResult[]> = {};
data.results.forEach((r) => {
(map[r.type] ||= []).push(r);
});
for (const [type, items] of Object.entries(map)) {
groups.push({ type, items });
}
return groups;
}, [data?.results]);
const renderGroupHeader = useCallback(
({ type, items }: { type: string; items: SearchResult[] }) => (
<View>
<View className="p-2 bg-gray-50 border-b border-gray-200 flex-row items-center">
<Ionicons
name={getIconName(type as SearchResult["type"])}
size={14}
color="#6b7280"
style={{ marginRight: 4 }}
/>
<Text className="font-bold text-xs text-gray-700 uppercase tracking-wider">
{getResultTypeLabel(type as SearchResult["type"])}s ({items.length})
</Text>
</View>
<FlatList
data={items}
keyExtractor={(r) => `${r.type}-${r.id}`}
renderItem={({ item }) => (
<TouchableOpacity
className="flex-row items-center p-3 border-b border-gray-100"
onPress={() => {
setIsVisible(false);
onResultSelect(item);
}}
>
<View className="flex-1 pl-6">
<Text className="font-semibold text-gray-900" numberOfLines={1}>
{item.title}
</Text>
{item.subtitle ? (
<Text
className="text-sm text-gray-600 mb-1"
numberOfLines={1}
>
{item.subtitle}
</Text>
) : null}
{item.description ? (
<Text className="text-xs text-gray-500" numberOfLines={1}>
{item.description}
</Text>
) : null}
</View>
</TouchableOpacity>
)}
/>
</View>
),
[onResultSelect]
);
if (!isVisible || !searchQuery || searchQuery.length < 2) return null;
// Calculate dropdown positioning based on sidebar and content area
// Sidebar widths: collapsed = 80px, expanded = 200px
// We want dropdown to span from sidebar edge to content area with padding
const sidebarWidth = sidebarCollapsed ? 80 : 200;
const contentPadding = 20;
const rightPadding = 4;
const leftOffset = sidebarWidth + contentPadding;
const dropdownWidth = windowWidth - leftOffset - rightPadding - 16;
// Calculate max height: leave space at bottom (48px from top + some bottom margin)
const maxDropdownHeight = windowHeight - 48 - 32;
// For empty results, make it narrower and align to the right
const isEmpty = data && data.results.length === 0 && !isLoading;
return (
<Portal>
<View
className="absolute top-12 bg-white rounded-md shadow-lg z-50 p-2"
style={{
left: leftOffset,
right: rightPadding + 16,
width: dropdownWidth,
maxWidth: dropdownWidth,
zIndex: 9999,
}}
nativeID={containerId}
>
{isLoading && (
<View className="p-4">
<Text className="text-gray-500 text-center">Searching...</Text>
</View>
)}
{error && (
<View className="p-4">
<Text className="text-red-500 text-center">Search failed</Text>
</View>
)}
{isEmpty && (
<View className="p-4">
<Text className="text-gray-500 text-center">
No results found for &quot;{searchQuery}&quot;
</Text>
</View>
)}
{groupedResults.length > 0 && (
<ScrollView
style={{ maxHeight: maxDropdownHeight }}
showsVerticalScrollIndicator={true}
>
<FlatList
data={groupedResults}
keyExtractor={(g) => g.type}
renderItem={({ item }) => renderGroupHeader(item)}
scrollEnabled={false}
ListFooterComponent={
<TouchableOpacity
className="p-3 border-t border-gray-200 bg-gray-50"
onPress={() => {
setIsVisible(false);
onViewAllResults(searchQuery);
}}
>
<Text className="text-center text-blue-600 font-medium">
View all results for &quot;{searchQuery}&quot;
</Text>
</TouchableOpacity>
}
/>
</ScrollView>
)}
</View>
</Portal>
);
};