Files
docs/plugins/docuservix-search/theme/SearchBar/index.tsx
T
2026-06-18 13:43:32 +03:00

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>
);
}