import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useHistory } from '@docusaurus/router'; import searchConfig from '@docuservix-search/config'; import type { SearchResult } from '../../types'; import styles from './styles.module.css'; const MAX_DROPDOWN_RESULTS = 10; const DEBOUNCE_MS = 300; export default function SearchBar(): JSX.Element { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [notices, setNotices] = useState([]); const [hasErrors, setHasErrors] = useState(false); const [loading, setLoading] = useState(false); const [open, setOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); const timerRef = useRef | null>(null); const abortRef = useRef(null); const history = useHistory(); const runSearch = useCallback(async (q: string) => { if (q.trim().length < 2) { setResults([]); setNotices([]); setHasErrors(false); setOpen(false); return; } if (abortRef.current) { abortRef.current.abort(); } const controller = new AbortController(); abortRef.current = controller; const timeout = searchConfig.timeout ?? 5000; const timeoutId = setTimeout(() => controller.abort(), timeout); setLoading(true); setHasErrors(false); try { const settled = await Promise.allSettled( searchConfig.providers.map((provider) => { const signal = controller.signal; return provider.search(q, signal); }), ); if (controller.signal.aborted) return; const allResults: SearchResult[] = []; const allNotices: string[] = []; let anyError = false; for (const outcome of settled) { if (outcome.status === 'fulfilled') { allResults.push(...outcome.value.results); if (outcome.value.notice) { allNotices.push(outcome.value.notice); } } else { anyError = true; } } allResults.sort((a, b) => b.relevance - a.relevance); const top = allResults.slice(0, MAX_DROPDOWN_RESULTS); setResults(top); setNotices(allNotices); setHasErrors(anyError); setOpen(true); setSelectedIndex(-1); } finally { clearTimeout(timeoutId); setLoading(false); } }, []); const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; setQuery(value); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!open) { if (e.key === 'Enter' && query.trim()) { history.push(`/search?q=${encodeURIComponent(query.trim())}`); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((prev) => Math.max(prev - 1, -1)); } else if (e.key === 'Enter') { e.preventDefault(); if (selectedIndex >= 0 && results[selectedIndex]) { window.location.href = results[selectedIndex].url; } else if (query.trim()) { history.push(`/search?q=${encodeURIComponent(query.trim())}`); } setOpen(false); } else if (e.key === 'Escape') { setOpen(false); setSelectedIndex(-1); } }; useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setSelectedIndex(-1); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); if (abortRef.current) abortRef.current.abort(); }; }, []); return (
{loading && ( )} {open && (
e.preventDefault()} > {hasErrors && (
Некоторые источники недоступны
)} {results.length === 0 && !loading && (
Ничего не найдено
)} {results.map((result, i) => ( setOpen(false)} >
{result.title}
{result.type} {result.path} {Math.round(result.relevance * 100)}%
{result.content && (
{result.content}
)}
))} {notices.map((notice, i) => (
{notice}
))}
)}
); }