216 lines
7.8 KiB
TypeScript
216 lines
7.8 KiB
TypeScript
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<SearchResult[]>([]);
|
|
const [notices, setNotices] = useState<string[]>([]);
|
|
const [hasErrors, setHasErrors] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={styles.container}
|
|
>
|
|
<input
|
|
className={styles.input}
|
|
type="search"
|
|
placeholder="Поиск..."
|
|
value={query}
|
|
onChange={handleChange}
|
|
onKeyDown={handleKeyDown}
|
|
aria-label="Поиск по документации"
|
|
aria-expanded={open}
|
|
aria-autocomplete="list"
|
|
role="combobox"
|
|
/>
|
|
{loading && (
|
|
<span
|
|
className={styles.spinner}
|
|
aria-label="Загрузка..."
|
|
/>
|
|
)}
|
|
{open && (
|
|
<div
|
|
className={styles.dropdown}
|
|
role="listbox"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
>
|
|
{hasErrors && (
|
|
<div className={styles.errorBadge}>Некоторые источники недоступны</div>
|
|
)}
|
|
{results.length === 0 && !loading && (
|
|
<div className={styles.empty}>Ничего не найдено</div>
|
|
)}
|
|
{results.map((result, i) => (
|
|
<a
|
|
key={`${result.url}-${i}`}
|
|
href={result.url}
|
|
role="option"
|
|
aria-selected={i === selectedIndex}
|
|
className={
|
|
i === selectedIndex
|
|
? `${styles.item} ${styles.selectedItem}`
|
|
: styles.item
|
|
}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<div className={styles.itemHeading}>{result.title}</div>
|
|
<div className={styles.itemFile}>
|
|
<span
|
|
className={styles.badge}
|
|
data-type={result.type}
|
|
>
|
|
{result.type}
|
|
</span>
|
|
{result.path}
|
|
<span className={styles.itemScore}>
|
|
{Math.round(result.relevance * 100)}%
|
|
</span>
|
|
</div>
|
|
{result.content && (
|
|
<div className={styles.itemContent}>{result.content}</div>
|
|
)}
|
|
</a>
|
|
))}
|
|
{notices.map((notice, i) => (
|
|
<div
|
|
key={i}
|
|
className={styles.notice}
|
|
>
|
|
{notice}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|