Skip to main content
DeveloperExamplesSearch Autocomplete

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