245 lines
7.4 KiB
TypeScript
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 "{searchQuery}"
|
|
</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 "{searchQuery}"
|
|
</Text>
|
|
</TouchableOpacity>
|
|
}
|
|
/>
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
</Portal>
|
|
);
|
|
};
|