/** * SearchableList Component with React 18 Concurrent Features * Uses useTransition for non-blocking search updates */ import React, { useState, useTransition, useMemo, useCallback } from 'react'; import { Search, X, Loader2 } from 'lucide-react'; export interface SearchableListItem { id: string; title: string; description?: string; metadata?: Record; } export interface SearchableListProps { items: T[]; onItemClick?: (item: T) => void; onItemSelect?: (item: T) => void; renderItem?: (item: T, isHighlighted: boolean) => React.ReactNode; searchFields?: (keyof T)[]; placeholder?: string; emptyMessage?: string; className?: string; itemClassName?: string; enableMultiSelect?: boolean; selectedItems?: T[]; virtualize?: boolean; virtualizeThreshold?: number; // Virtualization configuration itemHeight?: number; // Height of each item in pixels (default: 80) containerHeight?: number; // Height of scrollable container in pixels (default: 600) } /** * SearchableList with React 18 concurrent features */ export function SearchableList({ items, onItemClick, onItemSelect, renderItem, searchFields = ['title', 'description'] as (keyof T)[], placeholder = 'Search...', emptyMessage = 'No items found', className = '', itemClassName = '', enableMultiSelect = false, selectedItems = [], virtualize = true, virtualizeThreshold = 100, itemHeight = 80, containerHeight = 600 }: SearchableListProps) { const [searchQuery, setSearchQuery] = useState(''); const [highlightedId, setHighlightedId] = useState(null); const [selectedIds, setSelectedIds] = useState>( new Set(selectedItems.map(item => item.id)) ); // Use transition for non-blocking search updates const [isPending, startTransition] = useTransition(); /** * Filter items based on search query with transition */ const filteredItems = useMemo(() => { if (!searchQuery.trim()) { return items; } const query = searchQuery.toLowerCase(); return items.filter(item => { return searchFields.some(field => { const value = item[field]; if (typeof value === 'string') { return value.toLowerCase().includes(query); } if (value && typeof value === 'object') { return JSON.stringify(value).toLowerCase().includes(query); } return false; }); }); }, [items, searchQuery, searchFields]); /** * Handle search input with transition */ const handleSearchChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; // Use transition for non-urgent update startTransition(() => { setSearchQuery(value); }); }, []); /** * Clear search */ const handleClearSearch = useCallback(() => { startTransition(() => { setSearchQuery(''); }); }, []); /** * Handle item selection */ const handleItemSelect = useCallback((item: T) => { if (enableMultiSelect) { setSelectedIds(prev => { const next = new Set(prev); if (next.has(item.id)) { next.delete(item.id); } else { next.add(item.id); } return next; }); } else { setSelectedIds(new Set([item.id])); } if (onItemSelect) { onItemSelect(item); } }, [enableMultiSelect, onItemSelect]); /** * Handle item click */ const handleItemClick = useCallback((item: T) => { if (onItemClick) { onItemClick(item); } else { handleItemSelect(item); } }, [onItemClick, handleItemSelect]); /** * Default item renderer */ const defaultRenderItem = useCallback((item: T, isHighlighted: boolean) => { const isSelected = selectedIds.has(item.id); return (
setHighlightedId(item.id)} onMouseLeave={() => setHighlightedId(null)} onClick={() => handleItemClick(item)} >

{item.title}

{item.description && (

{item.description}

)}
{enableMultiSelect && ( handleItemSelect(item)} onClick={(e) => e.stopPropagation()} className="ml-3 mt-1 h-4 w-4 text-blue-600 rounded focus:ring-blue-500" /> )}
); }, [selectedIds, itemClassName, handleItemClick, handleItemSelect, enableMultiSelect]); /** * Virtualized list renderer for large lists */ const [scrollTop, setScrollTop] = useState(0); const renderVirtualizedList = useCallback(() => { // Simple virtualization with configurable dimensions const visibleCount = Math.ceil(containerHeight / itemHeight); const startIndex = Math.floor(scrollTop / itemHeight); const endIndex = Math.min(startIndex + visibleCount + 1, filteredItems.length); const visibleItems = filteredItems.slice(startIndex, endIndex); const totalHeight = filteredItems.length * itemHeight; const offsetY = startIndex * itemHeight; return (
setScrollTop(e.currentTarget.scrollTop)} >
{visibleItems.map(item => (
{renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)}
))}
); }, [filteredItems, highlightedId, renderItem, defaultRenderItem, containerHeight, itemHeight, scrollTop]); /** * Regular list renderer */ const renderRegularList = useCallback(() => { return (
{filteredItems.map(item => (
{renderItem ? renderItem(item, highlightedId === item.id) : defaultRenderItem(item, highlightedId === item.id)}
))}
); }, [filteredItems, highlightedId, renderItem, defaultRenderItem]); return (
{/* Search Bar */}
{isPending ? ( ) : ( )}
{searchQuery && ( )}
{isPending && (
Searching...
)}
{/* Results Count */} {searchQuery && (
{filteredItems.length} result{filteredItems.length !== 1 ? 's' : ''} found
)} {/* List Container */}
{filteredItems.length === 0 ? (
{emptyMessage}
) : ( <> {virtualize && filteredItems.length > virtualizeThreshold ? renderVirtualizedList() : renderRegularList() } )}
{/* Selection Summary */} {enableMultiSelect && selectedIds.size > 0 && (

{selectedIds.size} item{selectedIds.size !== 1 ? 's' : ''} selected

)}
); } /** * Hook for managing searchable list state */ export function useSearchableList( items: T[], searchFields: (keyof T)[] = ['title', 'description'] as (keyof T)[] ) { const [searchQuery, setSearchQuery] = useState(''); const [isPending, startTransition] = useTransition(); const filteredItems = useMemo(() => { if (!searchQuery.trim()) { return items; } const query = searchQuery.toLowerCase(); return items.filter(item => { return searchFields.some(field => { const value = item[field]; if (typeof value === 'string') { return value.toLowerCase().includes(query); } return false; }); }); }, [items, searchQuery, searchFields]); const updateSearch = useCallback((query: string) => { startTransition(() => { setSearchQuery(query); }); }, []); const clearSearch = useCallback(() => { startTransition(() => { setSearchQuery(''); }); }, []); return { searchQuery, filteredItems, isPending, updateSearch, clearSearch }; }