project setup
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user