Search Autocomplete
Intermediate
A type-ahead search component with debouncing, keyboard navigation, and filter toggles for approved therapies, trials, and genes.
DiseaseSearch.tsx
/**
* Disease Search Autocomplete Component
*
* A type-ahead search component with debouncing, keyboard navigation,
* and filter toggles for approved therapies, trials, and genes.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
// Types
interface SearchResult {
mondoId: string;
name: string;
definition: string | null;
synonyms: string[];
summary: {
approvedCount: number;
designatedCount: number;
trialCount: number;
geneCount: number;
hasExpertReview: boolean;
};
}
interface SearchFilters {
hasApprovedTherapies?: boolean;
hasActiveClinicalTrials?: boolean;
hasKnownGeneticCause?: boolean;
gene?: string;
}
interface DiseaseSearchProps {
apiKey: string;
onSelect: (disease: SearchResult) => void;
placeholder?: string;
filters?: SearchFilters;
showFilters?: boolean;
className?: string;
debounceMs?: number;
minChars?: number;
maxResults?: number;
}
// Debounce Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}
// Click Outside Hook
function useClickOutside(ref: React.RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// Main Component
export const DiseaseSearch: React.FC<DiseaseSearchProps> = ({
apiKey,
onSelect,
placeholder = 'Search diseases...',
filters: externalFilters = {},
showFilters = false,
className = '',
debounceMs = 300,
minChars = 2,
maxResults = 8
}) => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [filters, setFilters] = useState<SearchFilters>(externalFilters);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debouncedQuery = useDebounce(query, debounceMs);
useClickOutside(containerRef, () => setIsOpen(false));
const performSearch = useCallback(async (searchQuery: string) => {
if (searchQuery.length < minChars) {
setResults([]);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({ q: searchQuery, limit: String(maxResults) });
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined) params.set(key, String(value));
});
const response = await fetch(
`https://kishomed.io/api/v1/diseases/search?${params}`,
{
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const data = await response.json();
setResults(data.data || []);
setHighlightedIndex(0);
} catch (err) {
console.error('Search error:', err);
setResults([]);
} finally {
setLoading(false);
}
}, [apiKey, filters, maxResults, minChars]);
useEffect(() => {
performSearch(debouncedQuery);
}, [debouncedQuery, performSearch]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || results.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(i => (i + 1) % results.length);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(i => (i - 1 + results.length) % results.length);
break;
case 'Enter':
e.preventDefault();
if (results[highlightedIndex]) handleSelect(results[highlightedIndex]);
break;
case 'Escape':
setIsOpen(false);
inputRef.current?.blur();
break;
}
};
const handleSelect = (result: SearchResult) => {
setQuery(result.name);
setIsOpen(false);
onSelect(result);
};
const toggleFilter = (key: keyof SearchFilters) => {
setFilters(prev => ({
...prev,
[key]: prev[key] === undefined ? true : prev[key] ? undefined : true
}));
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{/* Search Input */}
<div className="relative">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full px-4 py-2 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
/>
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
{loading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
{/* Filter toggles */}
{showFilters && (
<div className="flex flex-wrap gap-2 mt-2">
<button
onClick={() => toggleFilter('hasApprovedTherapies')}
className={`px-2 py-1 text-xs rounded-full border transition-colors ${
filters.hasApprovedTherapies ? 'bg-green-100 border-green-300 text-green-700' : 'bg-gray-50 border-gray-200'
}`}
>
Has Approved Tx
</button>
<button
onClick={() => toggleFilter('hasActiveClinicalTrials')}
className={`px-2 py-1 text-xs rounded-full border transition-colors ${
filters.hasActiveClinicalTrials ? 'bg-blue-100 border-blue-300 text-blue-700' : 'bg-gray-50 border-gray-200'
}`}
>
Has Trials
</button>
<button
onClick={() => toggleFilter('hasKnownGeneticCause')}
className={`px-2 py-1 text-xs rounded-full border transition-colors ${
filters.hasKnownGeneticCause ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-gray-50 border-gray-200'
}`}
>
Known Gene
</button>
</div>
)}
{/* Results dropdown */}
{isOpen && query.length >= minChars && (
<div className="absolute z-50 w-full mt-1 bg-white rounded-lg border border-gray-200 shadow-lg max-h-96 overflow-y-auto">
{results.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500">
{loading ? 'Searching...' : 'No diseases found'}
</div>
) : (
<div className="divide-y divide-gray-100">
{results.map((result, index) => (
<button
key={result.mondoId}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 ${index === highlightedIndex ? 'bg-indigo-50' : ''}`}
onClick={() => handleSelect(result)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{result.name}</div>
<div className="text-xs text-gray-500 font-mono">{result.mondoId}</div>
</div>
<div className="flex flex-col items-end ml-3 text-xs">
{result.summary.approvedCount > 0 && (
<span className="text-green-600">{result.summary.approvedCount} approved</span>
)}
{result.summary.trialCount > 0 && (
<span className="text-blue-600">{result.summary.trialCount} trials</span>
)}
</div>
</div>
</button>
))}
</div>
)}
{results.length > 0 && (
<div className="px-4 py-2 bg-gray-50 border-t text-xs text-gray-500">
<span>↑↓ navigate</span> · <span>↵ select</span> · <span>esc close</span>
</div>
)}
</div>
)}
</div>
);
};
export default DiseaseSearch;Key Features
- •Debounced queries - Configurable delay prevents excessive API calls
- •Keyboard navigation - Arrow keys, Enter, and Escape for full accessibility
- •Filter toggles - Quick filters for approved therapies, trials, and known genes
- •Click outside - Dropdown closes when clicking outside the component
- •Summary stats - Shows approved count and trial count in results